├── .github ├── CODE_OF_CONDUCT ├── CONTRIBUTING ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── config.yml └── workflows │ └── pythonpackage.yml ├── .gitignore ├── .idea └── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── assets └── images │ ├── circle_full_logo.png │ ├── circle_logo.png │ ├── no_circle_full_logo.png │ └── no_circle_logo.png ├── docker-bake.hcl ├── make.bat ├── notifiers ├── __init__.py ├── _version.py ├── core.py ├── exceptions.py ├── logging.py ├── providers │ ├── __init__.py │ ├── dingtalk.py │ ├── email.py │ ├── gitter.py │ ├── gmail.py │ ├── icloud.py │ ├── join.py │ ├── mailgun.py │ ├── notify.py │ ├── pagerduty.py │ ├── popcornnotify.py │ ├── pushbullet.py │ ├── pushover.py │ ├── simplepush.py │ ├── slack.py │ ├── statuspage.py │ ├── telegram.py │ ├── twilio.py │ ├── victorops.py │ └── zulip.py └── utils │ ├── __init__.py │ ├── helpers.py │ ├── requests.py │ └── schema │ ├── __init__.py │ ├── formats.py │ └── helpers.py ├── notifiers_cli ├── __init__.py ├── core.py └── utils │ ├── __init__.py │ ├── callbacks.py │ └── dynamic_click.py ├── pyproject.toml ├── ruff.toml ├── source ├── CLI.rst ├── Logger.rst ├── about.rst ├── api │ ├── core.rst │ ├── exceptions.rst │ ├── index.rst │ ├── providers.rst │ └── utils.rst ├── changelog.rst ├── conf.py ├── index.rst ├── installation.rst ├── providers │ ├── dingtalk.rst │ ├── email.rst │ ├── gitter.rst │ ├── gmail.rst │ ├── icloud.rst │ ├── index.rst │ ├── join.rst │ ├── mailgun.rst │ ├── notify.rst │ ├── pagerduty.rst │ ├── popcornnotify.rst │ ├── pushbullet.rst │ ├── pushover.rst │ ├── simplepush.rst │ ├── slack.rst │ ├── statuspage.rst │ ├── telegram.rst │ ├── twilio.rst │ ├── victorops.rst │ └── zulip.rst └── usage.rst ├── tests ├── conftest.py ├── providers │ ├── test_dingtalk.py │ ├── test_gitter.py │ ├── test_gmail.py │ ├── test_icloud.py │ ├── test_join.py │ ├── test_mailgun.py │ ├── test_notify.py │ ├── test_pagerduty.py │ ├── test_popcornnotify.py │ ├── test_pushbullet.py │ ├── test_pushover.py │ ├── test_simplepush.py │ ├── test_slack.py │ ├── test_smtp.py │ ├── test_statuspage.py │ ├── test_telegram.py │ ├── test_twilio.py │ ├── test_victorops.py │ └── test_zulip.py ├── test_cli.py ├── test_core.py ├── test_json_schema.py ├── test_logger.py └── test_utils.py └── uv.lock /.github/CODE_OF_CONDUCT: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at python.notifiers@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Issues 2 | 3 | Please describe the issue, add relevant errors/crash and specify exact python version, notifiers version and installation method (pip, docker, from source, etc.) 4 | 5 | # Pull requests and Development 6 | 7 | Detailed developmental docs are planned but for the meanwhile this is the bare minimum: 8 | 9 | - Fork repo. 10 | - Install requirements via `pip install -r requirements.txt` 11 | - Install dev requirements via `pip install -r dev-requirements.txt` 12 | - Run tests via `pytest -m "not online"` 13 | 14 | ## Tests 15 | 16 | When adding new functionality, please add new tests to it. If new account and/or secret keys are needed to be created and added to the repo, please reach out via [gitter](https://gitter.im/notifiers/notifiers). 17 | 18 | There are 3 types of tests: 19 | 20 | - "Dry" tests, tests that check the code base directly without needing 3rd party providers. 21 | - "Wet" tests, tests that do require 3rd party providers but are negative tests, or expected to fail. They can pass even without passing the required credentials to them. 22 | - "Online" tests, tests that require real API access to the various providers. Any secret information that is needed to to make these test pass is encrypted via CI settings and is accessible only to owners of the project. When running tests locally run via the negative marker as written above. When a pull request is created, these tests are skipped. 23 | 24 | Skipping these tests will have the appearance that the test coverage dropped considerably (because for the scope of this PR, it did). Feel free to ignore this. -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: liiight 4 | custom: https://paypal.me/notifiers 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'Issue with notifiers' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen. 17 | 18 | **Additional context** 19 | Python version: 20 | OS: 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | # Configuration for welcome - https://github.com/behaviorbot/welcome 2 | 3 | # Configuration for new-issue-welcome - https://github.com/behaviorbot/new-issue-welcome 4 | 5 | # Comment to be posted to on first time issues 6 | newIssueWelcomeComment: > 7 | Thanks for opening your first issue here! Be sure to follow the issue template! 👋🐞👋 8 | 9 | # Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome 10 | 11 | # Comment to be posted to on PRs from first time contributors in your repository 12 | newPRWelcomeComment: > 13 | 💖 Thanks for opening this pull request! Please check out our contributing guidelines. 💖 14 | 15 | # Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge 16 | 17 | # Comment to be posted to on pull requests merged by a first time user 18 | firstPRMergeComment: > 19 | Congrats on merging your first pull request! We here at Notifiers are proud of you! 🎉🎉🎉 20 | 21 | # It is recommended to include as many gifs and emojis as possible! -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Tests and coverage 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 1 10 | matrix: 11 | python-version: 12 | - 3.9 13 | - "3.10" 14 | - "3.11" 15 | - "3.12" 16 | - "3.13" 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Install uv and set the python version 20 | uses: astral-sh/setup-uv@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Test with pytest 24 | shell: bash 25 | run: uv run pytest --cov=./ --junit-xml=report.xml 26 | env: 27 | NOTIFIERS_EMAIL_PASSWORD: ${{secrets.NOTIFIERS_EMAIL_PASSWORD}} 28 | NOTIFIERS_EMAIL_TO: ${{secrets.NOTIFIERS_EMAIL_TO}} 29 | NOTIFIERS_EMAIL_USERNAME: ${{secrets.NOTIFIERS_EMAIL_USERNAME}} 30 | NOTIFIERS_GITTER_ROOM_ID: ${{secrets.NOTIFIERS_GITTER_ROOM_ID}} 31 | NOTIFIERS_GITTER_TOKEN: ${{secrets.NOTIFIERS_GITTER_TOKEN}} 32 | NOTIFIERS_GMAIL_PASSWORD: ${{secrets.NOTIFIERS_GMAIL_PASSWORD}} 33 | NOTIFIERS_GMAIL_TO: ${{secrets.NOTIFIERS_GMAIL_TO}} 34 | NOTIFIERS_GMAIL_USERNAME: ${{secrets.NOTIFIERS_GMAIL_USERNAME}} 35 | NOTIFIERS_ICLOUD_TO: ${{secrets.NOTIFIERS_ICLOUD_TO}} 36 | NOTIFIERS_ICLOUD_FROM: ${{secrets.NOTIFIERS_ICLOUD_FROM}} 37 | NOTIFIERS_ICLOUD_USERNAME: ${{secrets.NOTIFIERS_ICLOUD_USERNAME}} 38 | NOTIFIERS_ICLOUD_PASSWORD: ${{secrets.NOTIFIERS_ICLOUD_PASSWORD}} 39 | NOTIFIERS_JOIN_APIKEY: ${{secrets.NOTIFIERS_JOIN_APIKEY}} 40 | NOTIFIERS_MAILGUN_API_KEY: ${{secrets.NOTIFIERS_MAILGUN_API_KEY}} 41 | NOTIFIERS_MAILGUN_DOMAIN: ${{secrets.NOTIFIERS_MAILGUN_DOMAIN}} 42 | NOTIFIERS_MAILGUN_FROM: ${{secrets.NOTIFIERS_MAILGUN_FROM}} 43 | NOTIFIERS_MAILGUN_TO: ${{secrets.NOTIFIERS_MAILGUN_TO}} 44 | NOTIFIERS_PAGERDUTY_ROUTING_KEY: ${{secrets.NOTIFIERS_PAGERDUTY_ROUTING_KEY}} 45 | NOTIFIERS_POPCORNNOTIFY_API_KEY: ${{secrets.NOTIFIERS_POPCORNNOTIFY_API_KEY}} 46 | NOTIFIERS_POPCORNNOTIFY_RECIPIENTS: ${{secrets.NOTIFIERS_POPCORNNOTIFY_RECIPIENTS}} 47 | NOTIFIERS_PUSHBULLET_TOKEN: ${{secrets.NOTIFIERS_PUSHBULLET_TOKEN}} 48 | NOTIFIERS_PUSHOVER_TOKEN: ${{secrets.NOTIFIERS_PUSHOVER_TOKEN}} 49 | NOTIFIERS_PUSHOVER_USER: ${{secrets.NOTIFIERS_PUSHOVER_USER}} 50 | NOTIFIERS_SIMPLEPUSH_KEY: ${{secrets.NOTIFIERS_SIMPLEPUSH_KEY}} 51 | NOTIFIERS_SLACK_WEBHOOK_URL: ${{secrets.NOTIFIERS_SLACK_WEBHOOK_URL}} 52 | NOTIFIERS_STATUSPAGE_API_KEY: ${{secrets.NOTIFIERS_STATUSPAGE_API_KEY}} 53 | NOTIFIERS_STATUSPAGE_PAGE_ID: ${{secrets.NOTIFIERS_STATUSPAGE_PAGE_ID}} 54 | NOTIFIERS_TELEGRAM_CHAT_ID: ${{secrets.NOTIFIERS_TELEGRAM_CHAT_ID}} 55 | NOTIFIERS_TELEGRAM_TOKEN: ${{secrets.NOTIFIERS_TELEGRAM_TOKEN}} 56 | NOTIFIERS_TWILIO_ACCOUNT_SID: ${{secrets.NOTIFIERS_TWILIO_ACCOUNT_SID}} 57 | NOTIFIERS_TWILIO_AUTH_TOKEN: ${{secrets.NOTIFIERS_TWILIO_AUTH_TOKEN}} 58 | NOTIFIERS_TWILIO_FROM: ${{secrets.NOTIFIERS_TWILIO_FROM}} 59 | NOTIFIERS_TWILIO_TO: ${{secrets.NOTIFIERS_TWILIO_TO}} 60 | NOTIFIERS_ZULIP_API_KEY: ${{secrets.NOTIFIERS_ZULIP_API_KEY}} 61 | NOTIFIERS_ZULIP_EMAIL: ${{secrets.NOTIFIERS_ZULIP_EMAIL}} 62 | NOTIFIERS_ZULIP_TO: ${{secrets.NOTIFIERS_ZULIP_TO}} 63 | NOTIFIERS_VICTOROPS_REST_URL: ${{secrets.NOTIFIERS_VICTOROPS_REST_URL}} 64 | - name: JUnit Report Action 65 | uses: mikepenz/action-junit-report@v5 66 | if: success() || failure() 67 | with: 68 | report_paths: report.xml 69 | - name: Upload coverage to Codecov 70 | if: success() 71 | uses: codecov/codecov-action@v2 72 | with: 73 | token: ${{secrets.CODECOV_TOKEN}} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | poc* 103 | /docs/ 104 | .idea/* -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.4 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | - repo: https://github.com/astral-sh/uv-pre-commit 9 | # uv version. 10 | rev: 0.6.12 11 | hooks: 12 | # Locks the uv.lock file based on our pyproject.toml files 13 | - id: uv-lock 14 | args: [ -v ] 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | version: 2 3 | 4 | # Set the OS, Python version and other tools you might need 5 | build: 6 | os: ubuntu-24.04 7 | tools: 8 | python: "3.12" 9 | jobs: 10 | post_install: 11 | - pip install uv 12 | - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy 13 | 14 | # Build documentation in the "docs/" directory with Sphinx 15 | sphinx: 16 | configuration: source/conf.py 17 | fail_on_warning: true 18 | 19 | # Optionally build your docs in additional formats such as PDF and ePub 20 | formats: 21 | - pdf 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.13-alpine 2 | 3 | ADD . /notifiers 4 | WORKDIR /notifiers 5 | ENV UV_COMPILE_BYTECODE=1 6 | ENV UV_LINK_MODE=copy 7 | 8 | RUN --mount=type=cache,target=/root/.cache/uv \ 9 | uv sync --no-dev 10 | 11 | USER user 12 | 13 | ENTRYPOINT ["notifiers"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Or Carmi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.MD 3 | include requirements.txt 4 | include dev-requirements.txt 5 | recursive-exclude tests * -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = notifiers 8 | SOURCEDIR = source 9 | BUILDDIR = docs 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /assets/images/circle_full_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liiight/notifiers/936c7d62724d342a853bebbd4ba9e1361c07d421/assets/images/circle_full_logo.png -------------------------------------------------------------------------------- /assets/images/circle_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liiight/notifiers/936c7d62724d342a853bebbd4ba9e1361c07d421/assets/images/circle_logo.png -------------------------------------------------------------------------------- /assets/images/no_circle_full_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liiight/notifiers/936c7d62724d342a853bebbd4ba9e1361c07d421/assets/images/no_circle_full_logo.png -------------------------------------------------------------------------------- /assets/images/no_circle_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liiight/notifiers/936c7d62724d342a853bebbd4ba9e1361c07d421/assets/images/no_circle_logo.png -------------------------------------------------------------------------------- /docker-bake.hcl: -------------------------------------------------------------------------------- 1 | target "default" { 2 | dockerfile = "Dockerfile" 3 | tags = ["docker.io/liiight/notifiers:latest"] 4 | } 5 | -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=notifiers 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /notifiers/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from ._version import __version__ 4 | from .core import all_providers, get_notifier, notify 5 | 6 | logging.getLogger("notifiers").addHandler(logging.NullHandler()) 7 | 8 | __all__ = ["__version__", "all_providers", "get_notifier", "notify"] 9 | -------------------------------------------------------------------------------- /notifiers/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.3.0" 2 | -------------------------------------------------------------------------------- /notifiers/exceptions.py: -------------------------------------------------------------------------------- 1 | class NotifierException(Exception): 2 | """Base notifier exception. Catch this to catch all of :mod:`notifiers` errors""" 3 | 4 | def __init__(self, *args, **kwargs): 5 | """ 6 | Looks for ``provider``, ``message`` and ``data`` in kwargs 7 | :param args: Exception arguments 8 | :param kwargs: Exception kwargs 9 | """ 10 | self.provider = kwargs.get("provider") 11 | self.message = kwargs.get("message") 12 | self.data = kwargs.get("data") 13 | self.response = kwargs.get("response") 14 | super().__init__(self.message) 15 | 16 | def __repr__(self): 17 | return f"" 18 | 19 | 20 | class BadArguments(NotifierException): 21 | """ 22 | Raised on schema data validation issues 23 | 24 | :param validation_error: The validation error message 25 | :param args: Exception arguments 26 | :param kwargs: Exception kwargs 27 | """ 28 | 29 | def __init__(self, validation_error: str, *args, **kwargs): 30 | kwargs["message"] = f"Error with sent data: {validation_error}" 31 | super().__init__(*args, **kwargs) 32 | 33 | def __repr__(self): 34 | return f"" 35 | 36 | 37 | class SchemaError(NotifierException): 38 | """ 39 | Raised on schema issues, relevant probably when creating or changing a provider schema 40 | 41 | :param schema_error: The schema error that was raised 42 | :param args: Exception arguments 43 | :param kwargs: Exception kwargs 44 | """ 45 | 46 | def __init__(self, schema_error: str, *args, **kwargs): 47 | kwargs["message"] = f"Schema error: {schema_error}" 48 | super().__init__(*args, **kwargs) 49 | 50 | def __repr__(self): 51 | return f"" 52 | 53 | 54 | class NotificationError(NotifierException): 55 | """ 56 | A notification error. Raised after an issue with the sent notification. 57 | Looks for ``errors`` key word in kwargs. 58 | 59 | :param args: Exception arguments 60 | :param kwargs: Exception kwargs 61 | """ 62 | 63 | def __init__(self, *args, **kwargs): 64 | self.errors = kwargs.pop("errors", None) 65 | kwargs["message"] = f"Notification errors: {','.join(self.errors)}" 66 | super().__init__(*args, **kwargs) 67 | 68 | def __repr__(self): 69 | return f"" 70 | 71 | 72 | class ResourceError(NotifierException): 73 | """ 74 | A notifier resource request error, occurs when an error happened in a 75 | :meth:`notifiers.core.ProviderResource._get_resource` call 76 | """ 77 | 78 | def __init__(self, *args, **kwargs): 79 | self.errors = kwargs.pop("errors", None) 80 | self.resource = kwargs.pop("resource", None) 81 | kwargs["message"] = f"Notifier resource errors: {','.join(self.errors)}" 82 | super().__init__(*args, **kwargs) 83 | 84 | def __repr__(self): 85 | return f"" 86 | 87 | 88 | class NoSuchNotifierError(NotifierException): 89 | """ 90 | An unknown notifier was requests, one that was not registered 91 | """ 92 | 93 | def __init__(self, name: str, *args, **kwargs): 94 | self.name = name 95 | kwargs["message"] = f"No such notifier with name {name}" 96 | super().__init__(*args, **kwargs) 97 | 98 | def __repr__(self): 99 | return f"" 100 | -------------------------------------------------------------------------------- /notifiers/logging.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | import logging 5 | import sys 6 | 7 | import notifiers 8 | from notifiers.exceptions import NotifierException 9 | 10 | 11 | class NotificationHandler(logging.Handler): 12 | """A :class:`logging.Handler` that enables directly sending log messages to notifiers""" 13 | 14 | def __init__(self, provider: str, defaults: dict | None = None, **kwargs): 15 | """ 16 | Sets ups the handler 17 | 18 | :param provider: Provider name to use 19 | :param defaults: Default provider data to use. Can fallback to environs 20 | :param kwargs: Additional kwargs 21 | """ 22 | self.defaults = defaults or {} 23 | self.provider = None 24 | self.fallback = None 25 | self.fallback_defaults = None 26 | self.init_providers(provider, kwargs) 27 | super().__init__(**kwargs) 28 | 29 | def init_providers(self, provider, kwargs): 30 | """ 31 | Inits main and fallback provider if relevant 32 | 33 | :param provider: Provider name to use 34 | :param kwargs: Additional kwargs 35 | :raises ValueError: If provider name or fallback names are not valid providers, a :exc:`ValueError` will 36 | be raised 37 | """ 38 | self.provider = notifiers.get_notifier(provider, strict=True) 39 | if kwargs.get("fallback"): 40 | self.fallback = notifiers.get_notifier(kwargs.pop("fallback"), strict=True) 41 | self.fallback_defaults = kwargs.pop("fallback_defaults", {}) 42 | 43 | def emit(self, record): 44 | """ 45 | Override the :meth:`~logging.Handler.emit` method that takes the ``msg`` attribute from the log record passed 46 | 47 | :param record: :class:`logging.LogRecord` 48 | """ 49 | data = copy.deepcopy(self.defaults) 50 | data["message"] = self.format(record) 51 | try: 52 | self.provider.notify(raise_on_errors=True, **data) 53 | except Exception: 54 | self.handleError(record) 55 | 56 | def __repr__(self): 57 | level = logging.getLevelName(self.level) 58 | name = self.provider.name 59 | return f"<{self.__class__.__name__} {name}({level})>" 60 | 61 | def handleError(self, record): 62 | """ 63 | Handles any errors raised during the :meth:`emit` method. Will only try to pass exceptions to fallback notifier 64 | (if defined) in case the exception is a sub-class of :exc:`~notifiers.exceptions.NotifierException` 65 | 66 | :param record: :class:`logging.LogRecord` 67 | """ 68 | if logging.raiseExceptions: 69 | t, v, tb = sys.exc_info() 70 | if issubclass(t, NotifierException) and self.fallback: 71 | msg = f"Could not log msg to provider '{self.provider.name}'!\n{v}" 72 | self.fallback_defaults["message"] = msg 73 | self.fallback.notify(**self.fallback_defaults) 74 | else: 75 | super().handleError(record) 76 | -------------------------------------------------------------------------------- /notifiers/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | dingtalk, 3 | email, 4 | gitter, 5 | gmail, 6 | icloud, 7 | join, 8 | mailgun, 9 | notify, 10 | pagerduty, 11 | popcornnotify, 12 | pushbullet, 13 | pushover, 14 | simplepush, 15 | slack, 16 | statuspage, 17 | telegram, 18 | twilio, 19 | victorops, 20 | zulip, 21 | ) 22 | 23 | _all_providers = { 24 | "pushover": pushover.Pushover, 25 | "simplepush": simplepush.SimplePush, 26 | "slack": slack.Slack, 27 | "email": email.SMTP, 28 | "dingtalk": dingtalk.DingTalk, 29 | "gmail": gmail.Gmail, 30 | "icloud": icloud.iCloud, 31 | "telegram": telegram.Telegram, 32 | "gitter": gitter.Gitter, 33 | "pushbullet": pushbullet.Pushbullet, 34 | "join": join.Join, 35 | "zulip": zulip.Zulip, 36 | "twilio": twilio.Twilio, 37 | "pagerduty": pagerduty.PagerDuty, 38 | "mailgun": mailgun.MailGun, 39 | "popcornnotify": popcornnotify.PopcornNotify, 40 | "statuspage": statuspage.Statuspage, 41 | "victorops": victorops.VictorOps, 42 | "notify": notify.Notify, 43 | } 44 | -------------------------------------------------------------------------------- /notifiers/providers/dingtalk.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, Response 2 | from ..utils import requests 3 | 4 | 5 | class DingTalk(Provider): 6 | """Send DingTalk notifications via Robot Webhook""" 7 | 8 | base_url = "https://oapi.dingtalk.com/robot/send" 9 | site_url = "https://open.dingtalk.com/document/" 10 | name = "dingtalk" 11 | path_to_errors = ("errmsg",) 12 | 13 | _required = { 14 | "required": ["access_token", "msg_data"], 15 | "oneOf": [{"required": ["msg_data.text"]}, {"required": ["msg_data.markdown"]}, {"required": ["msg_data.link"]}, {"required": ["msg_data.actionCard"]}], 16 | } 17 | 18 | _schema = { 19 | "type": "object", 20 | "properties": { 21 | "access_token": {"type": "string", "title": "Webhook access token", "description": "Obtain from DingTalk Robot settings", "minLength": 1}, 22 | "msg_data": { 23 | "type": "object", 24 | "properties": { 25 | "msgtype": {"type": "string", "enum": ["text", "markdown", "link", "actionCard"], "default": "text"}, 26 | "text": { 27 | "type": "object", 28 | "properties": {"content": {"type": "string", "title": "Message content", "maxLength": 20000, "minLength": 1}}, 29 | "required": ["content"], 30 | "additionalProperties": False, 31 | }, 32 | "markdown": { 33 | "type": "object", 34 | "properties": { 35 | "title": {"type": "string", "title": "Message title", "maxLength": 100, "minLength": 1}, 36 | "text": {"type": "string", "title": "Markdown content", "maxLength": 20000, "minLength": 1}, 37 | }, 38 | "required": ["title", "text"], 39 | "additionalProperties": False, 40 | }, 41 | "link": { 42 | "type": "object", 43 | "properties": { 44 | "title": {"type": "string", "title": "Link title", "maxLength": 100, "minLength": 1}, 45 | "text": {"type": "string", "title": "Link description", "maxLength": 500, "minLength": 1}, 46 | "messageUrl": {"type": "string", "title": "Link URL", "format": "uri", "minLength": 1}, 47 | "picUrl": {"type": "string", "title": "Image URL", "format": "uri", "default": ""}, 48 | }, 49 | "required": ["title", "text", "messageUrl"], 50 | "additionalProperties": False, 51 | }, 52 | "actionCard": { 53 | "type": "object", 54 | "properties": { 55 | "title": {"type": "string", "title": "Card title", "maxLength": 100, "minLength": 1}, 56 | "text": {"type": "string", "title": "Card content", "maxLength": 20000, "minLength": 1}, 57 | "singleTitle": {"type": "string", "title": "Button text", "maxLength": 50, "minLength": 1}, 58 | "singleURL": {"type": "string", "title": "Button URL", "format": "uri", "minLength": 1}, 59 | "btnOrientation": {"type": "string", "title": "Button layout", "enum": ["0", "1"], "default": "0"}, 60 | }, 61 | "required": ["title", "text", "singleTitle", "singleURL"], 62 | "additionalProperties": False, 63 | }, 64 | }, 65 | "required": ["msgtype"], 66 | "additionalProperties": False, 67 | }, 68 | "at": { 69 | "type": "object", 70 | "properties": { 71 | "atMobiles": {"type": "array", "title": "Phone numbers to @", "items": {"type": "string", "pattern": "^1[3-9]\\d{9}$"}, "maxItems": 20}, 72 | "atUserIds": {"type": "array", "title": "User IDs to @", "items": {"type": "string", "minLength": 1}, "maxItems": 20}, 73 | "isAtAll": {"type": "boolean", "title": "Notify all members", "default": False}, 74 | }, 75 | "additionalProperties": False, 76 | }, 77 | "sign": {"type": "string", "title": "Secret signature", "description": "Required if secret is set in webhook", "minLength": 1}, 78 | "timestamp": {"type": "string", "title": "Sign timestamp", "pattern": "^\\d{13}$"}, 79 | }, 80 | "additionalProperties": False, 81 | } 82 | 83 | def _prepare_url(self) -> str: 84 | """返回基础URL, access_token将通过params传递""" 85 | return self.base_url 86 | 87 | def _prepare_data(self, data: dict) -> dict: 88 | """ 89 | 构造钉钉机器人要求的消息格式 90 | 文档: https://open.dingtalk.com/document/orgapp-server/custom-robot-access 91 | """ 92 | payload = {"msgtype": data["msg_data"]["msgtype"], data["msg_data"]["msgtype"]: data["msg_data"][data["msg_data"]["msgtype"]]} 93 | 94 | if "at" in data: 95 | payload["at"] = data["at"] 96 | 97 | # 安全签名处理 98 | if "sign" in data and "timestamp" in data: 99 | payload["sign"] = data["sign"] 100 | payload["timestamp"] = data["timestamp"] 101 | 102 | return payload 103 | 104 | def _send_notification(self, data: dict) -> Response: 105 | url = self._prepare_url() 106 | params = {"access_token": data["access_token"]} 107 | payload = self._prepare_data(data) 108 | 109 | response = requests.post(url, params=params, json=payload, headers={"Content-Type": "application/json", "Accept": "application/json"}) 110 | 111 | return self._create_response(response) 112 | -------------------------------------------------------------------------------- /notifiers/providers/gitter.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, ProviderResource, Response 2 | from ..exceptions import ResourceError 3 | from ..utils import requests 4 | 5 | 6 | class GitterMixin: 7 | """Shared attributes between :class:`~notifiers.providers.gitter.GitterRooms` and 8 | :class:`~notifiers.providers.gitter.Gitter`""" 9 | 10 | name = "gitter" 11 | path_to_errors = "errors", "error" 12 | base_url = "https://api.gitter.im/v1/rooms" 13 | 14 | def _get_headers(self, token: str) -> dict: 15 | """ 16 | Builds Gitter requests header bases on the token provided 17 | 18 | :param token: App token 19 | :return: Authentication header dict 20 | """ 21 | return {"Authorization": f"Bearer {token}"} 22 | 23 | 24 | class GitterRooms(GitterMixin, ProviderResource): 25 | """Returns a list of Gitter rooms via token""" 26 | 27 | resource_name = "rooms" 28 | 29 | _required = {"required": ["token"]} 30 | 31 | _schema = { 32 | "type": "object", 33 | "properties": { 34 | "token": {"type": "string", "title": "access token"}, 35 | "filter": {"type": "string", "title": "Filter results"}, 36 | }, 37 | "additionalProperties": False, 38 | } 39 | 40 | def _get_resource(self, data: dict) -> list: 41 | headers = self._get_headers(data["token"]) 42 | filter_ = data.get("filter") 43 | params = {"q": filter_} if filter_ else {} 44 | response, errors = requests.get( 45 | self.base_url, 46 | headers=headers, 47 | params=params, 48 | path_to_errors=self.path_to_errors, 49 | ) 50 | if errors: 51 | raise ResourceError( 52 | errors=errors, 53 | resource=self.resource_name, 54 | provider=self.name, 55 | data=data, 56 | response=response, 57 | ) 58 | rsp = response.json() 59 | return rsp["results"] if filter_ else rsp 60 | 61 | 62 | class Gitter(GitterMixin, Provider): 63 | """Send Gitter notifications""" 64 | 65 | message_url = "/{room_id}/chatMessages" 66 | site_url = "https://gitter.im" 67 | 68 | _resources = {"rooms": GitterRooms()} 69 | 70 | _required = {"required": ["message", "token", "room_id"]} 71 | _schema = { 72 | "type": "object", 73 | "properties": { 74 | "message": {"type": "string", "title": "Body of the message"}, 75 | "token": {"type": "string", "title": "access token"}, 76 | "room_id": { 77 | "type": "string", 78 | "title": "ID of the room to send the notification to", 79 | }, 80 | }, 81 | "additionalProperties": False, 82 | } 83 | 84 | def _prepare_data(self, data: dict) -> dict: 85 | data["text"] = data.pop("message") 86 | return data 87 | 88 | @property 89 | def metadata(self) -> dict: 90 | metadata = super().metadata 91 | metadata["message_url"] = self.message_url 92 | return metadata 93 | 94 | def _send_notification(self, data: dict) -> Response: 95 | room_id = data.pop("room_id") 96 | url = self.base_url + self.message_url.format(room_id=room_id) 97 | 98 | headers = self._get_headers(data.pop("token")) 99 | response, errors = requests.post(url, json=data, headers=headers, path_to_errors=self.path_to_errors) 100 | return self.create_response(data, response, errors) 101 | -------------------------------------------------------------------------------- /notifiers/providers/gmail.py: -------------------------------------------------------------------------------- 1 | from . import email 2 | 3 | 4 | class Gmail(email.SMTP): 5 | """Send email via Gmail""" 6 | 7 | site_url = "https://www.google.com/gmail/about/" 8 | base_url = "smtp.gmail.com" 9 | name = "gmail" 10 | 11 | @property 12 | def defaults(self) -> dict: 13 | data = super().defaults 14 | data["host"] = self.base_url 15 | data["port"] = 587 16 | data["tls"] = True 17 | return data 18 | -------------------------------------------------------------------------------- /notifiers/providers/icloud.py: -------------------------------------------------------------------------------- 1 | from . import email 2 | 3 | 4 | class iCloud(email.SMTP): 5 | """Send email via iCloud""" 6 | 7 | _required = {"required": ["message", "from", "to", "username", "password"]} 8 | 9 | site_url = "https://www.icloud.com/mail" 10 | base_url = "smtp.mail.me.com" 11 | name = "icloud" 12 | 13 | @property 14 | def defaults(self) -> dict: 15 | data = super().defaults 16 | data["host"] = self.base_url 17 | data["port"] = 587 18 | data["tls"] = True 19 | return data 20 | -------------------------------------------------------------------------------- /notifiers/providers/notify.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, Response 2 | from ..utils import requests 3 | 4 | 5 | class NotifyMixin: 6 | name = "notify" 7 | site_url = "https://github.com/K0IN/Notify" 8 | base_url = "{base_url}/api/notify" 9 | path_to_errors = ("message",) 10 | 11 | def _get_headers(self, token: str) -> dict: 12 | """ 13 | Builds Notify's requests header bases on the token provided 14 | 15 | :param token: Send token 16 | :return: Authentication header dict 17 | """ 18 | return {"Authorization": f"Bearer {token}"} 19 | 20 | 21 | class Notify(NotifyMixin, Provider): 22 | """Send Notify notifications""" 23 | 24 | site_url = "https://github.com/K0IN/Notify" 25 | name = "notify" 26 | 27 | _required = {"required": ["title", "message", "base_url"]} 28 | _schema = { 29 | "type": "object", 30 | "properties": { 31 | "base_url": {"type": "string"}, 32 | "message": {"type": "string", "title": "your message"}, 33 | "title": {"type": "string", "title": "your message's title"}, 34 | "token": { 35 | "type": "string", 36 | "title": "your application's send key, see https://github.com/K0IN/Notify/blob/main/doc/docker.md", 37 | }, 38 | "tags": { 39 | "type": "array", 40 | "title": "your message's tags", 41 | "items": {"type": "string"}, 42 | }, 43 | }, 44 | "additionalProperties": False, 45 | } 46 | 47 | def _prepare_data(self, data: dict) -> dict: 48 | return data 49 | 50 | def _send_notification(self, data: dict) -> Response: 51 | url = self.base_url.format(base_url=data.pop("base_url")) 52 | token = data.pop("token", None) 53 | headers = self._get_headers(token) if token else {} 54 | response, errors = requests.post( 55 | url, 56 | json={ 57 | "message": data.pop("message"), 58 | "title": data.pop("title", None), 59 | "tags": data.pop("tags", []), 60 | }, 61 | headers=headers, 62 | path_to_errors=self.path_to_errors, 63 | ) 64 | return self.create_response(data, response, errors) 65 | -------------------------------------------------------------------------------- /notifiers/providers/pagerduty.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, Response 2 | from ..utils import requests 3 | 4 | 5 | class PagerDuty(Provider): 6 | """Send PagerDuty Events""" 7 | 8 | name = "pagerduty" 9 | base_url = "https://events.pagerduty.com/v2/enqueue" 10 | site_url = "https://v2.developer.pagerduty.com/" 11 | path_to_errors = ("errors",) 12 | 13 | __payload_attributes = [ 14 | "message", 15 | "source", 16 | "severity", 17 | "timestamp", 18 | "component", 19 | "group", 20 | "class", 21 | "custom_details", 22 | ] 23 | 24 | __images = { 25 | "type": "array", 26 | "items": { 27 | "type": "object", 28 | "properties": { 29 | "src": { 30 | "type": "string", 31 | "title": "The source of the image being attached to the incident. This image must be served via HTTPS.", 32 | }, 33 | "href": { 34 | "type": "string", 35 | "title": "Optional URL; makes the image a clickable link", 36 | }, 37 | "alt": { 38 | "type": "string", 39 | "title": "Optional alternative text for the image", 40 | }, 41 | }, 42 | "required": ["src"], 43 | "additionalProperties": False, 44 | }, 45 | } 46 | 47 | __links = { 48 | "type": "array", 49 | "items": { 50 | "type": "object", 51 | "properties": { 52 | "href": {"type": "string", "title": "URL of the link to be attached"}, 53 | "text": { 54 | "type": "string", 55 | "title": "Plain text that describes the purpose of the link, and can be used as the link's text", 56 | }, 57 | }, 58 | "required": ["href", "text"], 59 | "additionalProperties": False, 60 | }, 61 | } 62 | 63 | _required = {"required": ["routing_key", "event_action", "source", "severity", "message"]} 64 | 65 | _schema = { 66 | "type": "object", 67 | "properties": { 68 | "message": { 69 | "type": "string", 70 | "title": "A brief text summary of the event, used to generate the summaries/titles of any associated alerts", 71 | }, 72 | "routing_key": { 73 | "type": "string", 74 | "title": 'The GUID of one of your Events API V2 integrations. This is the "Integration Key" listed on the Events API V2 integration\'s detail page', 75 | }, 76 | "event_action": { 77 | "type": "string", 78 | "enum": ["trigger", "acknowledge", "resolve"], 79 | "title": "The type of event", 80 | }, 81 | "dedup_key": { 82 | "type": "string", 83 | "title": "Deduplication key for correlating triggers and resolves", 84 | "maxLength": 255, 85 | }, 86 | "source": { 87 | "type": "string", 88 | "title": "The unique location of the affected system, preferably a hostname or FQDN", 89 | }, 90 | "severity": { 91 | "type": "string", 92 | "enum": ["critical", "error", "warning", "info"], 93 | "title": "The perceived severity of the status the event is describing with respect to the affected system", 94 | }, 95 | "timestamp": { 96 | "type": "string", 97 | "format": "iso8601", 98 | "title": "The time at which the emitting tool detected or generated the event in ISO 8601", 99 | }, 100 | "component": { 101 | "type": "string", 102 | "title": "Component of the source machine that is responsible for the event", 103 | }, 104 | "group": { 105 | "type": "string", 106 | "title": "Logical grouping of components of a service", 107 | }, 108 | "class": {"type": "string", "title": "The class/type of the event"}, 109 | "custom_details": { 110 | "type": "object", 111 | "title": "Additional details about the event and affected system", 112 | }, 113 | "images": __images, 114 | "links": __links, 115 | }, 116 | } 117 | 118 | def _prepare_data(self, data: dict) -> dict: 119 | payload = {attribute: data.pop(attribute) for attribute in self.__payload_attributes if data.get(attribute)} 120 | payload["summary"] = payload.pop("message") 121 | data["payload"] = payload 122 | return data 123 | 124 | def _send_notification(self, data: dict) -> Response: 125 | url = self.base_url 126 | response, errors = requests.post(url, json=data, path_to_errors=self.path_to_errors) 127 | return self.create_response(data, response, errors) 128 | -------------------------------------------------------------------------------- /notifiers/providers/popcornnotify.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, Response 2 | from ..utils import requests 3 | from ..utils.schema.helpers import list_to_commas, one_or_more 4 | 5 | 6 | class PopcornNotify(Provider): 7 | """Send PopcornNotify notifications""" 8 | 9 | base_url = "https://popcornnotify.com/notify" 10 | site_url = "https://popcornnotify.com/" 11 | name = "popcornnotify" 12 | path_to_errors = ("error",) 13 | 14 | _required = {"required": ["message", "api_key", "recipients"]} 15 | 16 | _schema = { 17 | "type": "object", 18 | "properties": { 19 | "message": {"type": "string", "title": "The message to send"}, 20 | "api_key": {"type": "string", "title": "The API key"}, 21 | "recipients": one_or_more( 22 | { 23 | "type": "string", 24 | "format": "email", 25 | "title": "The recipient email address or phone number. Or an array of email addresses and phone numbers", 26 | } 27 | ), 28 | "subject": { 29 | "type": "string", 30 | "title": "The subject of the email. It will not be included in text messages.", 31 | }, 32 | }, 33 | } 34 | 35 | def _prepare_data(self, data: dict) -> dict: 36 | if isinstance(data["recipients"], str): 37 | data["recipients"] = [data["recipients"]] 38 | data["recipients"] = list_to_commas(data["recipients"]) 39 | return data 40 | 41 | def _send_notification(self, data: dict) -> Response: 42 | response, errors = requests.post(url=self.base_url, json=data, path_to_errors=self.path_to_errors) 43 | return self.create_response(data, response, errors) 44 | -------------------------------------------------------------------------------- /notifiers/providers/pushbullet.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, ProviderResource, Response 2 | from ..exceptions import ResourceError 3 | from ..utils import requests 4 | 5 | 6 | class PushbulletMixin: 7 | """Shared attributes between :class:`PushbulletDevices` and :class:`Pushbullet`""" 8 | 9 | name = "pushbullet" 10 | path_to_errors = "error", "message" 11 | 12 | def _get_headers(self, token: str) -> dict: 13 | return {"Access-Token": token} 14 | 15 | 16 | class PushbulletDevices(PushbulletMixin, ProviderResource): 17 | """Return a list of Pushbullet devices associated to a token""" 18 | 19 | resource_name = "devices" 20 | devices_url = "https://api.pushbullet.com/v2/devices" 21 | 22 | _required = {"required": ["token"]} 23 | _schema = { 24 | "type": "object", 25 | "properties": {"token": {"type": "string", "title": "API access token"}}, 26 | "additionalProperties": False, 27 | } 28 | 29 | def _get_resource(self, data: dict) -> list: 30 | headers = self._get_headers(data["token"]) 31 | response, errors = requests.get(self.devices_url, headers=headers, path_to_errors=self.path_to_errors) 32 | if errors: 33 | raise ResourceError( 34 | errors=errors, 35 | resource=self.resource_name, 36 | provider=self.name, 37 | data=data, 38 | response=response, 39 | ) 40 | return response.json()["devices"] 41 | 42 | 43 | class Pushbullet(PushbulletMixin, Provider): 44 | """Send Pushbullet notifications""" 45 | 46 | base_url = "https://api.pushbullet.com/v2/pushes" 47 | site_url = "https://www.pushbullet.com" 48 | 49 | __type = { 50 | "type": "string", 51 | "title": 'Type of the push, one of "note" or "link"', 52 | "enum": ["note", "link"], 53 | } 54 | 55 | _resources = {"devices": PushbulletDevices()} 56 | _required = {"required": ["message", "token"]} 57 | _schema = { 58 | "type": "object", 59 | "properties": { 60 | "message": {"type": "string", "title": "Body of the push"}, 61 | "token": {"type": "string", "title": "API access token"}, 62 | "title": {"type": "string", "title": "Title of the push"}, 63 | "type": __type, 64 | "type_": __type, 65 | "url": { 66 | "type": "string", 67 | "title": 'URL field, used for type="link" pushes', 68 | }, 69 | "source_device_iden": { 70 | "type": "string", 71 | "title": "Device iden of the sending device", 72 | }, 73 | "device_iden": { 74 | "type": "string", 75 | "title": "Device iden of the target device, if sending to a single device", 76 | }, 77 | "client_iden": { 78 | "type": "string", 79 | "title": "Client iden of the target client, sends a push to all users who have granted access to this client. The current user must own this client", 80 | }, 81 | "channel_tag": { 82 | "type": "string", 83 | "title": "Channel tag of the target channel, sends a push to all people who are subscribed to this channel. The current user must own this channel.", 84 | }, 85 | "email": { 86 | "type": "string", 87 | "format": "email", 88 | "title": "Email address to send the push to. If there is a pushbullet user with this address, they get a push, otherwise they get an email", 89 | }, 90 | "guid": { 91 | "type": "string", 92 | "title": "Unique identifier set by the client, used to identify a push in case you receive it " 93 | "from /v2/everything before the call to /v2/pushes has completed. This should be a unique" 94 | " value. Pushes with guid set are mostly idempotent, meaning that sending another push " 95 | "with the same guid is unlikely to create another push (it will return the previously" 96 | " created push).", 97 | }, 98 | }, 99 | "additionalProperties": False, 100 | } 101 | 102 | @property 103 | def defaults(self) -> dict: 104 | return {"type": "note"} 105 | 106 | def _prepare_data(self, data: dict) -> dict: 107 | data["body"] = data.pop("message") 108 | 109 | # Workaround since `type` is a reserved word 110 | if data.get("type_"): 111 | data["type"] = data.pop("type_") 112 | return data 113 | 114 | def _send_notification(self, data: dict) -> Response: 115 | headers = self._get_headers(data.pop("token")) 116 | response, errors = requests.post( 117 | self.base_url, 118 | json=data, 119 | headers=headers, 120 | path_to_errors=self.path_to_errors, 121 | ) 122 | return self.create_response(data, response, errors) 123 | -------------------------------------------------------------------------------- /notifiers/providers/simplepush.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, Response 2 | from ..utils import requests 3 | 4 | 5 | class SimplePush(Provider): 6 | """Send SimplePush notifications""" 7 | 8 | base_url = "https://api.simplepush.io/send" 9 | site_url = "https://simplepush.io/" 10 | name = "simplepush" 11 | 12 | _required = {"required": ["key", "message"]} 13 | _schema = { 14 | "type": "object", 15 | "properties": { 16 | "key": {"type": "string", "title": "your user key"}, 17 | "message": {"type": "string", "title": "your message"}, 18 | "title": {"type": "string", "title": "message title"}, 19 | "event": {"type": "string", "title": "Event ID"}, 20 | }, 21 | "additionalProperties": False, 22 | } 23 | 24 | def _prepare_data(self, data: dict) -> dict: 25 | data["msg"] = data.pop("message") 26 | return data 27 | 28 | def _send_notification(self, data: dict) -> Response: 29 | path_to_errors = ("message",) 30 | response, errors = requests.post(self.base_url, data=data, path_to_errors=path_to_errors) 31 | return self.create_response(data, response, errors) 32 | -------------------------------------------------------------------------------- /notifiers/providers/telegram.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, ProviderResource, Response 2 | from ..exceptions import ResourceError 3 | from ..utils import requests 4 | 5 | 6 | class TelegramMixin: 7 | """Shared resources between :class:`TelegramUpdates` and :class:`Telegram`""" 8 | 9 | base_url = "https://api.telegram.org/bot{token}" 10 | name = "telegram" 11 | path_to_errors = ("description",) 12 | 13 | 14 | class TelegramUpdates(TelegramMixin, ProviderResource): 15 | """Return Telegram bot updates, correlating to the `getUpdates` method. Returns chat IDs needed to notifications""" 16 | 17 | resource_name = "updates" 18 | updates_endpoint = "/getUpdates" 19 | 20 | _required = {"required": ["token"]} 21 | 22 | _schema = { 23 | "type": "object", 24 | "properties": {"token": {"type": "string", "title": "Bot token"}}, 25 | "additionalProperties": False, 26 | } 27 | 28 | def _get_resource(self, data: dict) -> list: 29 | url = self.base_url.format(token=data["token"]) + self.updates_endpoint 30 | response, errors = requests.get(url, path_to_errors=self.path_to_errors) 31 | if errors: 32 | raise ResourceError( 33 | errors=errors, 34 | resource=self.resource_name, 35 | provider=self.name, 36 | data=data, 37 | response=response, 38 | ) 39 | return response.json()["result"] 40 | 41 | 42 | class Telegram(TelegramMixin, Provider): 43 | """Send Telegram notifications""" 44 | 45 | site_url = "https://core.telegram.org/" 46 | push_endpoint = "/sendMessage" 47 | 48 | _resources = {"updates": TelegramUpdates()} 49 | 50 | _required = {"required": ["message", "chat_id", "token"]} 51 | _schema = { 52 | "type": "object", 53 | "properties": { 54 | "message": { 55 | "type": "string", 56 | "title": "Text of the message to be sent", 57 | "maxLength": 4096, 58 | }, 59 | "token": {"type": "string", "title": "Bot token"}, 60 | "chat_id": { 61 | "oneOf": [{"type": "string"}, {"type": "integer"}], 62 | "title": "Unique identifier for the target chat or username of the target channel (in the format @channelusername)", 63 | }, 64 | "parse_mode": { 65 | "type": "string", 66 | "title": "Send Markdown or HTML, if you want Telegram apps to show bold, italic, fixed-width text or inline URLs in your bot's message.", 67 | "enum": ["markdown", "html"], 68 | }, 69 | "disable_web_page_preview": { 70 | "type": "boolean", 71 | "title": "Disables link previews for links in this message", 72 | }, 73 | "disable_notification": { 74 | "type": "boolean", 75 | "title": "Sends the message silently. Users will receive a notification with no sound.", 76 | }, 77 | "reply_to_message_id": { 78 | "type": "integer", 79 | "title": "If the message is a reply, ID of the original message", 80 | }, 81 | }, 82 | "additionalProperties": False, 83 | } 84 | 85 | def _prepare_data(self, data: dict) -> dict: 86 | data["text"] = data.pop("message") 87 | return data 88 | 89 | def _send_notification(self, data: dict) -> Response: 90 | token = data.pop("token") 91 | url = self.base_url.format(token=token) + self.push_endpoint 92 | response, errors = requests.post(url, json=data, path_to_errors=self.path_to_errors) 93 | return self.create_response(data, response, errors) 94 | -------------------------------------------------------------------------------- /notifiers/providers/twilio.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, Response 2 | from ..utils import requests 3 | from ..utils.helpers import snake_to_camel_case 4 | 5 | 6 | class Twilio(Provider): 7 | """Send an SMS via a Twilio number""" 8 | 9 | name = "twilio" 10 | base_url = "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json" 11 | site_url = "https://www.twilio.com/" 12 | path_to_errors = ("message",) 13 | 14 | _required = { 15 | "allOf": [ 16 | { 17 | "anyOf": [ 18 | {"anyOf": [{"required": ["from"]}, {"required": ["from_"]}]}, 19 | {"required": ["messaging_service_id"]}, 20 | ], 21 | "error_anyOf": "Either 'from' or 'messaging_service_id' are required", 22 | }, 23 | { 24 | "anyOf": [{"required": ["message"]}, {"required": ["media_url"]}], 25 | "error_anyOf": "Either 'message' or 'media_url' are required", 26 | }, 27 | {"required": ["to", "account_sid", "auth_token"]}, 28 | ] 29 | } 30 | 31 | _schema = { 32 | "type": "object", 33 | "properties": { 34 | "message": { 35 | "type": "string", 36 | "title": "The text body of the message. Up to 1,600 characters long.", 37 | "maxLength": 1_600, 38 | }, 39 | "account_sid": { 40 | "type": "string", 41 | "title": "The unique id of the Account that sent this message.", 42 | }, 43 | "auth_token": {"type": "string", "title": "The user's auth token"}, 44 | "to": { 45 | "type": "string", 46 | "format": "e164", 47 | "title": "The recipient of the message, in E.164 format", 48 | }, 49 | "from": { 50 | "type": "string", 51 | "title": "Twilio phone number or the alphanumeric sender ID used", 52 | }, 53 | "from_": { 54 | "type": "string", 55 | "title": "Twilio phone number or the alphanumeric sender ID used", 56 | "duplicate": True, 57 | }, 58 | "messaging_service_id": { 59 | "type": "string", 60 | "title": "The unique id of the Messaging Service used with the message", 61 | }, 62 | "media_url": { 63 | "type": "string", 64 | "format": "uri", 65 | "title": "The URL of the media you wish to send out with the message", 66 | }, 67 | "status_callback": { 68 | "type": "string", 69 | "format": "uri", 70 | "title": "A URL where Twilio will POST each time your message status changes", 71 | }, 72 | "application_sid": { 73 | "type": "string", 74 | "title": "Twilio will POST MessageSid as well as MessageStatus=sent or MessageStatus=failed to the URL in the MessageStatusCallback property of this Application", 75 | }, 76 | "max_price": { 77 | "type": "number", 78 | "title": "The total maximum price up to the fourth decimal (0.0001) in US dollars acceptable for the message to be delivered", 79 | }, 80 | "provide_feedback": { 81 | "type": "boolean", 82 | "title": "Set this value to true if you are sending messages that have a trackable user action and " 83 | "you intend to confirm delivery of the message using the Message Feedback API", 84 | }, 85 | "validity_period": { 86 | "type": "integer", 87 | "title": "The number of seconds that the message can remain in a Twilio queue", 88 | "minimum": 1, 89 | "maximum": 14_400, 90 | }, 91 | }, 92 | } 93 | 94 | def _prepare_data(self, data: dict) -> dict: 95 | if data.get("message"): 96 | data["body"] = data.pop("message") 97 | new_data = { 98 | "auth_token": data.pop("auth_token"), 99 | "account_sid": data.pop("account_sid"), 100 | } 101 | for key, value in data.items(): 102 | camel_case_key = snake_to_camel_case(key) 103 | new_data[camel_case_key] = value 104 | return new_data 105 | 106 | def _send_notification(self, data: dict) -> Response: 107 | account_sid = data.pop("account_sid") 108 | url = self.base_url.format(account_sid) 109 | auth = (account_sid, data.pop("auth_token")) 110 | response, errors = requests.post(url, data=data, auth=auth, path_to_errors=self.path_to_errors) 111 | return self.create_response(data, response, errors) 112 | -------------------------------------------------------------------------------- /notifiers/providers/victorops.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, Response 2 | from ..utils import requests 3 | 4 | 5 | class VictorOps(Provider): 6 | """Send VictorOps webhook notifications""" 7 | 8 | base_url = "https://portal.victorops.com/ui/{ORGANIZATION_ID}/incidents" 9 | site_url = "https://portal.victorops.com/dash/{ORGANIZATION_ID}#/advanced/rest" 10 | name = "victorops" 11 | 12 | _required = { 13 | "required": [ 14 | "rest_url", 15 | "message_type", 16 | "entity_id", 17 | "entity_display_name", 18 | "message", 19 | ] 20 | } 21 | _schema = { 22 | "type": "object", 23 | "properties": { 24 | "rest_url": { 25 | "type": "string", 26 | "format": "uri", 27 | "title": "the REST URL to use with routing_key. create one in victorops `integrations` tab.", 28 | }, 29 | "message_type": { 30 | "type": "string", 31 | "title": "severity level can be: " 32 | "- critical or warning: Triggers an incident " 33 | "- acknowledgement: sends Acknowledgment to an incident " 34 | "- info: Creates a timeline event but doesn't trigger an incident " 35 | "- recovery or ok: Resolves an incident", 36 | "enum": [ 37 | "critical", 38 | "warning", 39 | "acknowledgement", 40 | "info", 41 | "recovery", 42 | "ok", 43 | ], 44 | }, 45 | "entity_id": { 46 | "type": "string", 47 | "title": "Unique id for the incident for aggregation ,Acknowledging, or resolving.", 48 | }, 49 | "entity_display_name": { 50 | "type": "string", 51 | "title": "Display Name in the UI and Notifications.", 52 | }, 53 | "message": { 54 | "type": "string", 55 | "title": "This is the description that will be posted in the incident.", 56 | }, 57 | "annotations": { 58 | "type": "object", 59 | "patternProperties": { 60 | "^vo_annotate.u.": {"type": "string"}, 61 | "^vo_annotate.s.": {"type": "string"}, 62 | "^vo_annotate.i.": {"type": "string"}, 63 | }, 64 | "minProperties": 1, 65 | "title": "annotations can be of three types: vo_annotate.u.{custom_name}, vo_annotate.s.{custom_name}, vo_annotate.i.{custom_name} .", 66 | "additionalProperties": False, 67 | }, 68 | "additional_keys": { 69 | "type": "object", 70 | "title": "any additional keys that can be passed in the body", 71 | }, 72 | }, 73 | "additionalProperties": False, 74 | } 75 | 76 | def _prepare_data(self, data: dict) -> dict: 77 | annotations = data.pop("annotations", {}) 78 | for annotation, value in annotations.items(): 79 | data[annotation] = value 80 | 81 | additional_keys = data.pop("additional_keys", {}) 82 | for additional_key, value in additional_keys.items(): 83 | data[additional_key] = value 84 | return data 85 | 86 | def _send_notification(self, data: dict) -> Response: 87 | url = data.pop("rest_url") 88 | response, errors = requests.post(url, json=data) 89 | return self.create_response(data, response, errors) 90 | -------------------------------------------------------------------------------- /notifiers/providers/zulip.py: -------------------------------------------------------------------------------- 1 | from ..core import Provider, Response 2 | from ..exceptions import NotifierException 3 | from ..utils import requests 4 | 5 | 6 | class Zulip(Provider): 7 | """Send Zulip notifications""" 8 | 9 | name = "zulip" 10 | site_url = "https://zulipchat.com/api/" 11 | api_endpoint = "/api/v1/messages" 12 | base_url = "https://{domain}.zulipchat.com" 13 | path_to_errors = ("msg",) 14 | 15 | __type = { 16 | "type": "string", 17 | "enum": ["stream", "private"], 18 | "title": "Type of message to send", 19 | } 20 | _required = { 21 | "allOf": [ 22 | {"required": ["message", "email", "api_key", "to"]}, 23 | { 24 | "oneOf": [{"required": ["domain"]}, {"required": ["server"]}], 25 | "error_oneOf": "Only one of 'domain' or 'server' is allowed", 26 | }, 27 | ] 28 | } 29 | 30 | _schema = { 31 | "type": "object", 32 | "properties": { 33 | "message": {"type": "string", "title": "Message content"}, 34 | "email": {"type": "string", "format": "email", "title": "User email"}, 35 | "api_key": {"type": "string", "title": "User API Key"}, 36 | "type": __type, 37 | "type_": __type, 38 | "to": {"type": "string", "title": "Target of the message"}, 39 | "subject": { 40 | "type": "string", 41 | "title": "Title of the stream message. Required when using stream.", 42 | }, 43 | "domain": {"type": "string", "minLength": 1, "title": "Zulip cloud domain"}, 44 | "server": { 45 | "type": "string", 46 | "format": "uri", 47 | "title": "Zulip server URL. Example: https://myzulip.server.com", 48 | }, 49 | }, 50 | "additionalProperties": False, 51 | } 52 | 53 | @property 54 | def defaults(self) -> dict: 55 | return {"type": "stream"} 56 | 57 | def _prepare_data(self, data: dict) -> dict: 58 | base_url = self.base_url.format(domain=data.pop("domain")) if data.get("domain") else data.pop("server") 59 | data["url"] = base_url + self.api_endpoint 60 | data["content"] = data.pop("message") 61 | # A workaround since `type` is a reserved word 62 | if data.get("type_"): 63 | data["type"] = data.pop("type_") 64 | return data 65 | 66 | def _validate_data_dependencies(self, data: dict) -> dict: 67 | if data["type"] == "stream" and not data.get("subject"): 68 | raise NotifierException( 69 | provider=self.name, 70 | message="'subject' is required when 'type' is 'stream'", 71 | data=data, 72 | ) 73 | return data 74 | 75 | def _send_notification(self, data: dict) -> Response: 76 | url = data.pop("url") 77 | auth = (data.pop("email"), data.pop("api_key")) 78 | response, errors = requests.post(url, data=data, auth=auth, path_to_errors=self.path_to_errors) 79 | return self.create_response(data, response, errors) 80 | -------------------------------------------------------------------------------- /notifiers/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liiight/notifiers/936c7d62724d342a853bebbd4ba9e1361c07d421/notifiers/utils/__init__.py -------------------------------------------------------------------------------- /notifiers/utils/helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | 5 | log = logging.getLogger("notifiers") 6 | 7 | 8 | def text_to_bool(value: str) -> bool: 9 | """ 10 | Tries to convert a text value to a bool. If unsuccessful returns if value is None or not 11 | 12 | :param value: Value to check 13 | """ 14 | try: 15 | return value.lower() in {"y", "yes", "t", "true", "on", "1"} 16 | except (ValueError, AttributeError): 17 | return value is not None 18 | 19 | 20 | def merge_dicts(target_dict: dict, merge_dict: dict) -> dict: 21 | """ 22 | Merges ``merge_dict`` into ``target_dict`` if the latter does not already contain a value for each of the key 23 | names in ``merge_dict``. Used to cleanly merge default and environ data into notification payload. 24 | 25 | :param target_dict: The target dict to merge into and return, the user provided data for example 26 | :param merge_dict: The data that should be merged into the target data 27 | :return: A dict of merged data 28 | """ 29 | log.debug("merging dict %s into %s", merge_dict, target_dict) 30 | for key, value in merge_dict.items(): 31 | if key not in target_dict: 32 | target_dict[key] = value 33 | return target_dict 34 | 35 | 36 | def dict_from_environs(prefix: str, name: str, args: list) -> dict: 37 | """ 38 | Return a dict of environment variables correlating to the arguments list, main name and prefix like so: 39 | [prefix]_[name]_[arg] 40 | 41 | :param prefix: The environ prefix to use 42 | :param name: Main part 43 | :param args: List of args to iterate over 44 | :return: A dict of found environ values 45 | """ 46 | environs = {} 47 | log.debug("starting to collect environs using prefix: '%s'", prefix) 48 | for arg in args: 49 | environ = f"{prefix}{name}_{arg}".upper() 50 | if os.environ.get(environ): 51 | environs[arg] = os.environ[environ] 52 | return environs 53 | 54 | 55 | def snake_to_camel_case(value: str) -> str: 56 | """ 57 | Convert a snake case param to CamelCase 58 | 59 | :param value: The value to convert 60 | :return: A CamelCase value 61 | """ 62 | log.debug("trying to convert %s to camel case", value) 63 | return "".join(word.capitalize() for word in value.split("_")) 64 | 65 | 66 | def valid_file(path: str) -> bool: 67 | """ 68 | Verifies that a string path actually exists and is a file 69 | 70 | :param path: The path to verify 71 | :return: **True** if path exist and is a file 72 | """ 73 | path = Path(path).expanduser() 74 | log.debug("checking if %s is a valid file", path) 75 | return path.exists() and path.is_file() 76 | -------------------------------------------------------------------------------- /notifiers/utils/requests.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | 6 | import requests 7 | 8 | log = logging.getLogger("notifiers") 9 | 10 | 11 | class RequestsHelper: 12 | """A wrapper around :class:`requests.Session` which enables generically handling HTTP requests""" 13 | 14 | @classmethod 15 | def request( 16 | self, 17 | url: str, 18 | method: str, 19 | raise_for_status: bool = True, 20 | path_to_errors: tuple | None = None, 21 | *args, 22 | **kwargs, 23 | ) -> tuple: 24 | """ 25 | A wrapper method for :meth:`~requests.Session.request``, which adds some defaults and logging 26 | 27 | :param url: The URL to send the reply to 28 | :param method: The method to use 29 | :param raise_for_status: Should an exception be raised for a failed response. Default is **True** 30 | :param args: Additional args to be sent to the request 31 | :param kwargs: Additional args to be sent to the request 32 | :return: Dict of response body or original :class:`requests.Response` 33 | """ 34 | session = kwargs.get("session", requests.Session()) 35 | if "timeout" not in kwargs: 36 | kwargs["timeout"] = (5, 20) 37 | log.debug( 38 | "sending a %s request to %s with args: %s kwargs: %s", 39 | method.upper(), 40 | url, 41 | args, 42 | kwargs, 43 | ) 44 | 45 | if raise_for_status: 46 | try: 47 | rsp = session.request(method, url, *args, **kwargs) 48 | log.debug("response: %s", rsp.text) 49 | errors = None 50 | rsp.raise_for_status() 51 | except requests.RequestException as e: 52 | if e.response is not None: 53 | rsp = e.response 54 | if path_to_errors: 55 | try: 56 | errors = rsp.json() 57 | for arg in path_to_errors: 58 | if errors.get(arg): 59 | errors = errors[arg] 60 | except json.decoder.JSONDecodeError: 61 | errors = [rsp.text] 62 | else: 63 | errors = [rsp.text] 64 | if not isinstance(errors, list): 65 | errors = [errors] 66 | else: 67 | rsp = None 68 | errors = [str(e)] 69 | log.debug("errors when trying to access %s: %s", url, errors) 70 | log.debug("returning response %s, errors %s", rsp, errors) 71 | return rsp, errors 72 | 73 | 74 | def get(url: str, *args, **kwargs) -> tuple: 75 | """Send a GET request. Returns a dict or :class:`requests.Response `""" 76 | return RequestsHelper.request(url, "get", *args, **kwargs) 77 | 78 | 79 | def post(url: str, *args, **kwargs) -> tuple: 80 | """Send a POST request. Returns a dict or :class:`requests.Response `""" 81 | return RequestsHelper.request(url, "post", *args, **kwargs) 82 | 83 | 84 | def file_list_for_request(list_of_paths: list, key_name: str, mimetype: str | None = None) -> list: 85 | """ 86 | Convenience function to construct a list of files for multiple files upload by :mod:`requests` 87 | 88 | :param list_of_paths: Lists of strings to include in files. Should be pre validated for correctness 89 | :param key_name: The key name to use for the file list in the request 90 | :param mimetype: If specified, will be included in the requests 91 | :return: List of open files ready to be used in a request 92 | """ 93 | if mimetype: 94 | return [(key_name, (file, open(file, mode="rb"), mimetype)) for file in list_of_paths] 95 | return [(key_name, (file, open(file, mode="rb"))) for file in list_of_paths] 96 | -------------------------------------------------------------------------------- /notifiers/utils/schema/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liiight/notifiers/936c7d62724d342a853bebbd4ba9e1361c07d421/notifiers/utils/schema/__init__.py -------------------------------------------------------------------------------- /notifiers/utils/schema/formats.py: -------------------------------------------------------------------------------- 1 | import email 2 | import re 3 | from datetime import datetime 4 | 5 | import jsonschema 6 | 7 | from notifiers.utils.helpers import valid_file 8 | 9 | # Taken from https://gist.github.com/codehack/6350492822e52b7fa7fe 10 | ISO8601 = re.compile( 11 | r"^(?P(" 12 | r"(?P\d{4})([/-]?" 13 | r"(?P(0[1-9])|(1[012]))([/-]?" 14 | r"(?P(0[1-9])|([12]\d)|(3[01])))?)?(?:T" 15 | r"(?P([01][0-9])|(?:2[0123]))(:?" 16 | r"(?P[0-5][0-9])(:?" 17 | r"(?P[0-5][0-9]([,.]\d{1,10})?))?)?" 18 | r"(?:Z|([\-+](?:([01][0-9])|(?:2[0123]))(:?(?:[0-5][0-9]))?))?)?))$" 19 | ) 20 | E164 = re.compile(r"^\+?[1-9]\d{1,14}$") 21 | format_checker = jsonschema.FormatChecker() 22 | 23 | 24 | @format_checker.checks("iso8601", raises=ValueError) 25 | def is_iso8601(instance: str): 26 | """Validates ISO8601 format""" 27 | if not isinstance(instance, str): 28 | return True 29 | return ISO8601.match(instance) is not None 30 | 31 | 32 | @format_checker.checks("rfc2822", raises=ValueError) 33 | def is_rfc2822(instance: str): 34 | """Validates RFC2822 format""" 35 | if not isinstance(instance, str): 36 | return True 37 | return email.utils.parsedate(instance) is not None 38 | 39 | 40 | @format_checker.checks("ascii", raises=ValueError) 41 | def is_ascii(instance: str): 42 | """Validates data is ASCII encodable""" 43 | if not isinstance(instance, str): 44 | return True 45 | return instance.encode("ascii") 46 | 47 | 48 | @format_checker.checks("valid_file", raises=ValueError) 49 | def is_valid_file(instance: str): 50 | """Validates data is a valid file""" 51 | if not isinstance(instance, str): 52 | return True 53 | return valid_file(instance) 54 | 55 | 56 | @format_checker.checks("port", raises=ValueError) 57 | def is_valid_port(instance: int): 58 | """Validates data is a valid port""" 59 | if not isinstance(instance, (int, str)): 60 | return True 61 | return int(instance) in range(65535) 62 | 63 | 64 | @format_checker.checks("timestamp", raises=ValueError) 65 | def is_timestamp(instance): 66 | """Validates data is a timestamp""" 67 | if not isinstance(instance, (int, str)): 68 | return True 69 | return datetime.fromtimestamp(int(instance)) 70 | 71 | 72 | @format_checker.checks("e164", raises=ValueError) 73 | def is_e164(instance): 74 | """Validates data is E.164 format""" 75 | if not isinstance(instance, str): 76 | return True 77 | return E164.match(instance) is not None 78 | -------------------------------------------------------------------------------- /notifiers/utils/schema/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def one_or_more(schema: dict, unique_items: bool = True, min: int = 1, max: int | None = None) -> dict: 5 | """ 6 | Helper function to construct a schema that validates items matching 7 | `schema` or an array containing items matching `schema`. 8 | 9 | :param schema: The schema to use 10 | :param unique_items: Flag if array items should be unique 11 | :param min: Correlates to ``minLength`` attribute of JSON Schema array 12 | :param max: Correlates to ``maxLength`` attribute of JSON Schema array 13 | """ 14 | multi_schema = { 15 | "type": "array", 16 | "items": schema, 17 | "minItems": min, 18 | "uniqueItems": unique_items, 19 | } 20 | if max: 21 | multi_schema["maxItems"] = max 22 | return {"oneOf": [multi_schema, schema]} 23 | 24 | 25 | def list_to_commas(list_of_args) -> str: 26 | """ 27 | Converts a list of items to a comma separated list. If ``list_of_args`` is 28 | not a list, just return it back 29 | 30 | :param list_of_args: List of items 31 | :return: A string representing a comma separated list. 32 | """ 33 | if isinstance(list_of_args, list): 34 | return ",".join(list_of_args) 35 | return list_of_args 36 | # todo change or create a new util that handle conversion to list as well 37 | -------------------------------------------------------------------------------- /notifiers_cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liiight/notifiers/936c7d62724d342a853bebbd4ba9e1361c07d421/notifiers_cli/__init__.py -------------------------------------------------------------------------------- /notifiers_cli/core.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from functools import partial 3 | 4 | import click 5 | 6 | from notifiers import __version__, get_notifier 7 | from notifiers.core import all_providers 8 | from notifiers.exceptions import NotifierException 9 | from notifiers_cli.utils.callbacks import _notify, _resource, _resources, func_factory 10 | from notifiers_cli.utils.dynamic_click import CORE_COMMANDS, schema_to_command 11 | 12 | 13 | def provider_group_factory(): 14 | """Dynamically generate provider groups for all providers, and add all basic command to it""" 15 | for provider in all_providers(): 16 | p = get_notifier(provider) 17 | provider_name = p.name 18 | help = f"Options for '{provider_name}'" 19 | group = click.Group(name=provider_name, help=help) 20 | 21 | # Notify command 22 | notify = partial(_notify, p=p) 23 | group.add_command(schema_to_command(p, "notify", notify, add_message=True)) 24 | 25 | # Resources command 26 | resources_callback = partial(_resources, p=p) 27 | resources_cmd = click.Command( 28 | "resources", 29 | callback=resources_callback, 30 | help="Show provider resources list", 31 | ) 32 | group.add_command(resources_cmd) 33 | 34 | pretty_opt = click.Option(["--pretty/--not-pretty"], help="Output a pretty version of the JSON") 35 | 36 | # Add any provider resources 37 | for resource in p.resources: 38 | rsc = getattr(p, resource) 39 | rsrc_callback = partial(_resource, rsc) 40 | rsrc_command = schema_to_command(rsc, resource, rsrc_callback, add_message=False) 41 | rsrc_command.params.append(pretty_opt) 42 | group.add_command(rsrc_command) 43 | 44 | for name, description in CORE_COMMANDS.items(): 45 | callback = func_factory(p, name) 46 | params = [pretty_opt] 47 | command = click.Command( 48 | name, 49 | callback=callback, 50 | help=description.format(provider_name), 51 | params=params, 52 | ) 53 | group.add_command(command) 54 | 55 | notifiers_cli.add_command(group) 56 | 57 | 58 | @click.group() 59 | @click.version_option(version=__version__, prog_name="notifiers", message=("%(prog)s %(version)s")) 60 | @click.option("--env-prefix", help="Set a custom prefix for env vars usage") 61 | @click.pass_context 62 | def notifiers_cli(ctx, env_prefix): 63 | """Notifiers CLI operation""" 64 | ctx.obj["env_prefix"] = env_prefix 65 | 66 | 67 | @notifiers_cli.command() 68 | def providers(): 69 | """Shows all available providers""" 70 | click.echo(", ".join(all_providers())) 71 | 72 | 73 | def entry_point(): 74 | """The entry that CLI is executed from""" 75 | try: 76 | provider_group_factory() 77 | notifiers_cli(obj={}) 78 | except NotifierException as e: 79 | click.secho(f"ERROR: {e.message}", bold=True, fg="red") 80 | sys.exit(1) 81 | 82 | 83 | if __name__ == "__main__": 84 | entry_point() 85 | -------------------------------------------------------------------------------- /notifiers_cli/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liiight/notifiers/936c7d62724d342a853bebbd4ba9e1361c07d421/notifiers_cli/utils/__init__.py -------------------------------------------------------------------------------- /notifiers_cli/utils/callbacks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Callbacks and callback factories to enable dynamically associating :class:`~notifiers.core.Provider` methods to 3 | :class:`click.Group` and :class:`click.Command` 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import json 9 | import sys 10 | from functools import partial 11 | 12 | import click 13 | 14 | from notifiers_cli.utils.dynamic_click import clean_data 15 | 16 | 17 | def func_factory(p, method: str) -> callable: 18 | """ 19 | Dynamically generates callback commands to correlate to provider public methods 20 | 21 | :param p: A :class:`notifiers.core.Provider` object 22 | :param method: A string correlating to a provider method 23 | :return: A callback func 24 | """ 25 | 26 | def callback(pretty: bool = False): 27 | res = getattr(p, method) 28 | dump = partial(json.dumps, indent=4) if pretty else partial(json.dumps) 29 | click.echo(dump(res)) 30 | 31 | return callback 32 | 33 | 34 | def _notify(p, **data): 35 | """The callback func that will be hooked to the ``notify`` command""" 36 | message = data.get("message") 37 | if not message and not sys.stdin.isatty(): 38 | message = click.get_text_stream("stdin").read() 39 | data["message"] = message 40 | 41 | data = clean_data(data) 42 | 43 | ctx = click.get_current_context() 44 | if ctx.obj.get("env_prefix"): 45 | data["env_prefix"] = ctx.obj["env_prefix"] 46 | 47 | rsp = p.notify(**data) 48 | rsp.raise_on_errors() 49 | click.secho(f"Succesfully sent a notification to {p.name}!", fg="green") 50 | 51 | 52 | def _resource(resource, pretty: bool | None = None, **data): 53 | """The callback func that will be hooked to the generic resource commands""" 54 | data = clean_data(data) 55 | 56 | ctx = click.get_current_context() 57 | if ctx.obj.get("env_prefix"): 58 | data["env_prefix"] = ctx.obj["env_prefix"] 59 | 60 | rsp = resource(**data) 61 | dump = partial(json.dumps, indent=4) if pretty else partial(json.dumps) 62 | click.echo(dump(rsp)) 63 | 64 | 65 | def _resources(p): 66 | """Callback func to display provider resources""" 67 | if p.resources: 68 | click.echo(",".join(p.resources)) 69 | else: 70 | click.echo(f"Provider '{p.name}' does not have resource helpers") 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.build.targets.wheel] 6 | include = [ 7 | "notifiers", 8 | "notifiers_cli", 9 | "LICENSE", 10 | ] 11 | 12 | [project] 13 | name = "notifiers" 14 | version = "1.3.6" 15 | description = "The easy way to send notifications" 16 | authors = [{ name = "liiight", email = "python.notifiers@gmail.com" }] 17 | requires-python = ">=3.8" 18 | readme = "README.md" 19 | license = "MIT" 20 | keywords = [ 21 | "notifications", 22 | "messaging", 23 | "email", 24 | "push", 25 | ] 26 | classifiers = [ 27 | "Development Status :: 5 - Production/Stable", 28 | "License :: OSI Approved :: MIT License", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Programming Language :: Python :: 3.13", 36 | "Intended Audience :: Developers", 37 | "Intended Audience :: End Users/Desktop", 38 | ] 39 | dependencies = [ 40 | "click>=8.0.3", 41 | "importlib-metadata>=3.6", 42 | "jsonschema>=4.4.0", 43 | "requests>=2.27.1,<3", 44 | ] 45 | 46 | [project.urls] 47 | Homepage = "https://github.com/liiight/notifiers" 48 | Repository = "https://github.com/liiight/notifiers" 49 | Documentation = "https://notifiers.readthedocs.io/en/latest/" 50 | 51 | [project.scripts] 52 | notifiers = "notifiers_cli.core:entry_point" 53 | 54 | [dependency-groups] 55 | dev = [ 56 | "pytest", 57 | "codecov", 58 | "pytest-cov", 59 | "sphinx-autodoc-annotation~=1.0.post1", 60 | "sphinx-rtd-theme", 61 | "hypothesis", 62 | "pre-commit", 63 | "retry", 64 | "Sphinx", 65 | ] 66 | 67 | [tool.hatch.build.targets.sdist] 68 | include = [ 69 | "notifiers", 70 | "notifiers_cli", 71 | "LICENSE", 72 | ] 73 | exclude = ["tests"] 74 | 75 | [tool.pytest.ini_options] 76 | markers = [ 77 | "online: marks tests running online", 78 | ] 79 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | show-fixes = true 2 | line-length = 180 3 | [lint] 4 | ignore = [ 5 | # The following rules are conflicting with ruff formatter: https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 6 | "W191", # https://docs.astral.sh/ruff/rules/tab-indentation/ 7 | "E111", # https://docs.astral.sh/ruff/rules/indentation-with-invalid-multiple/ 8 | "Q000", # https://docs.astral.sh/ruff/rules/bad-quotes-inline-string/ 9 | "Q001", # https://docs.astral.sh/ruff/rules/bad-quotes-multiline-string/ 10 | "Q002", # https://docs.astral.sh/ruff/rules/bad-quotes-docstring/ 11 | "Q003", # https://docs.astral.sh/ruff/rules/avoidable-escaped-quote/ 12 | "COM812", # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ 13 | "PLR2004", 14 | "ARG002", 15 | "RUF012", 16 | "PLR0912", 17 | ] 18 | select = [ 19 | "ARG", 20 | "B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b 21 | "C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4 22 | "COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com 23 | "E", 24 | "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w 25 | "EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe 26 | "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f 27 | "FA", # https://docs.astral.sh/ruff/rules/#flake8-future-annotations-fa 28 | "I", # https://docs.astral.sh/ruff/rules/#isort-i 29 | "PGH", # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh 30 | "PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie 31 | "PL", # https://docs.astral.sh/ruff/rules/#pylint-pl 32 | "PT", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt 33 | "PYI", # https://docs.astral.sh/ruff/rules/#flake8-pyi-pyi 34 | "Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q 35 | "RET", # https://docs.astral.sh/ruff/rules/#flake8-return-ret 36 | "RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse 37 | "RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf 38 | "SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim 39 | "T10", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10 40 | "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up 41 | # "G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g 42 | ] 43 | 44 | [format] 45 | line-ending = "lf" 46 | 47 | [lint.isort] 48 | known-first-party = ["notifiers_cli"] 49 | 50 | [lint.pylint] 51 | max-args = 6 52 | -------------------------------------------------------------------------------- /source/Logger.rst: -------------------------------------------------------------------------------- 1 | .. _notification_logger: 2 | 3 | Notification logger 4 | ------------------- 5 | 6 | Notifiers enable you to log directly to a notifier via a stdlib logging handler, :class:`~notifiers.logging.NotificationHandler`: 7 | 8 | .. code-block:: python 9 | 10 | >>> import logging 11 | >>> from notifiers.logging import NotificationHandler 12 | 13 | >>> log = logging.getLogger(__name__) 14 | >>> defaults = { 15 | ... 'token': 'foo, 16 | ... 'user': 'bar 17 | ... } 18 | 19 | >>> hdlr = NotificationHandler('pushover', defaults=defaults) 20 | >>> hdlr.setLevel(logging.ERROR) 21 | 22 | >>> log.addHandler(hdlr) 23 | >>> log.error('And just like that, you get notified about all your errors!') 24 | 25 | By setting the handler level to the desired one, you can directly get notified about relevant event in your application, without needing to change a single line of code. 26 | 27 | Using environs 28 | ============== 29 | 30 | Like any other usage of notifiers, you can pass any relevant provider arguments via :ref:`environs`. 31 | 32 | Fallback notifiers 33 | ================== 34 | 35 | If you rely on 3rd party notifiers to send you notification about errors, you may want to have a fallback in case those notification fail for any reason. You can define a fallback notifier like so: 36 | 37 | .. code-block:: python 38 | 39 | >>> fallback_defaults = { 40 | ... 'host': 'http://localhost, 41 | ... 'port': 80, 42 | ... 'username': 'foo', 43 | ... 'password': 'bar 44 | ... } 45 | 46 | >>> hdlr = NotificationHandler('pushover', fallback='email', fallback_defaults=fallback_defaults) 47 | 48 | Then in case there is an error with the main notifier, ``pushover`` in this case, you'll get a notification sent via ``email``. 49 | 50 | .. note:: 51 | 52 | :class:`~notifiers.logging.NotificationHandler` respect the standard :mod:`logging` ``raiseExceptions`` flag to determine if fallback should be used. Also, fallback is used only when any subclass of :class:`~notifiers.exceptions.NotifierException` occurs. 53 | 54 | 55 | -------------------------------------------------------------------------------- /source/about.rst: -------------------------------------------------------------------------------- 1 | About 2 | ===== 3 | 4 | The problem 5 | ----------- 6 | 7 | While I was working on a different project, I needed to enable its users to send notifications. 8 | Soon I discovered that this was a project by itself, discovering and implementing different provider API, testing it, relying on outdated documentation at times and etc. It was quite the endeavour. 9 | Some providers offered their own SDK packages, but that meant adding more dependencies to an already dependency rich project, which was not ideal. 10 | There has to be a better way, right? 11 | 12 | The solution 13 | ------------ 14 | Enter :mod:`notifiers`. A common interface to many, many notification providers, with a minimal set of dependencies. 15 | 16 | The interface 17 | ------------- 18 | 19 | Consistent naming 20 | ~~~~~~~~~~~~~~~~~ 21 | 22 | Right out of the gate there was an issue of consistent naming. Different API providers have different names for similar properties. 23 | For example, one provider can name its API key as ``api_key``, another as ``token``, the third as ``apikey`` and etc. 24 | The solution I chose was to be as fateful to the original API properties as possible, with the only exception being the ``message`` property, 25 | which is shared among all notifiers and replaced internally as needed. 26 | 27 | Snake Case 28 | ~~~~~~~~~~ 29 | 30 | While the majority of providers already expect lower case and speicfically snake cased properties in their request, some do not. 31 | Notifiers normalizes this by making all request properties snake case and converting to relevant usage behind the scenes. 32 | 33 | Reserved words issue 34 | ~~~~~~~~~~~~~~~~~~~~ 35 | 36 | Some provider properties clash with python's reserved words, ``from`` being the most prominent example of that. There are two solutions to that issues. 37 | The first is to construct data via a dict and unpack it into the :meth:`~notifiers.core.Provider.notify` command like so: 38 | 39 | .. code:: python 40 | 41 | >>> data = { 42 | ... 'to': 'foo@bar.com', 43 | ... 'from': 'bar@foo.com' 44 | ... } 45 | >>> provider.notify(**data) 46 | 47 | The other is to use an alternate key word, which would always be the reserved key word followed by an underscore: 48 | 49 | .. code:: python 50 | 51 | >>> provider.notify(to='foo@bar.com', from_='bar@foo.com') 52 | 53 | What this is 54 | ------------ 55 | A general wrapper for a variety of 3rd party providers and built in ones (like SMTP) aimed solely at sending notifications. 56 | 57 | Who is this for 58 | --------------- 59 | * Developers aiming to integrate 3rd party notifications into their application 60 | * Script makes aiming to enable 3rd party notification abilities, either via python script or any CLI script 61 | * Anyone that want to programmatically send notification and not be concerned about familiarizing themselves with 3rd party APIs or built in abilities 62 | 63 | What this isn't 64 | --------------- 65 | Many providers API enable many other capabilities other than sending notification. None of those will be handled by this module as it's outside its scope. If you need API access other than sending notification, look into implementing the 3rd party API directly, or using an SDK if available. 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /source/api/core.rst: -------------------------------------------------------------------------------- 1 | Core 2 | ==== 3 | 4 | Core API reference 5 | 6 | .. autoclass:: notifiers.core.SchemaResource 7 | :members: 8 | :private-members: 9 | 10 | .. autoclass:: notifiers.core.Provider 11 | :members: 12 | :private-members: 13 | 14 | .. autoclass:: notifiers.core.ProviderResource 15 | :members: 16 | :private-members: 17 | 18 | .. autoclass:: notifiers.core.Response 19 | :members: 20 | 21 | .. autofunction:: notifiers.core.get_notifier 22 | 23 | .. autofunction:: notifiers.core.all_providers 24 | 25 | .. autofunction:: notifiers.core.get_all_providers 26 | 27 | .. autofunction:: notifiers.core.load_provider_from_points 28 | 29 | .. autofunction:: notifiers.core.get_providers_from_entry_points 30 | 31 | .. autofunction:: notifiers.core.notify 32 | 33 | Logging 34 | ======= 35 | 36 | .. autoclass:: notifiers.logging.NotificationHandler 37 | :members: 38 | 39 | -------------------------------------------------------------------------------- /source/api/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | APi documentation of exceptions 5 | 6 | .. autoexception:: notifiers.exceptions.NotifierException 7 | .. autoexception:: notifiers.exceptions.BadArguments 8 | .. autoexception:: notifiers.exceptions.SchemaError 9 | .. autoexception:: notifiers.exceptions.NotificationError 10 | .. autoexception:: notifiers.exceptions.NoSuchNotifierError 11 | -------------------------------------------------------------------------------- /source/api/index.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | Internal API documentation 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | core 10 | providers 11 | exceptions 12 | utils -------------------------------------------------------------------------------- /source/api/providers.rst: -------------------------------------------------------------------------------- 1 | Providers 2 | ========= 3 | API documentation for the different providers. 4 | 5 | .. automodule:: notifiers.providers.email 6 | :members: 7 | :undoc-members: 8 | 9 | .. automodule:: notifiers.providers.gitter 10 | :members: 11 | :undoc-members: 12 | 13 | .. automodule:: notifiers.providers.gmail 14 | :members: 15 | :undoc-members: 16 | 17 | .. automodule:: notifiers.providers.icloud 18 | :members: 19 | :undoc-members: 20 | 21 | .. automodule:: notifiers.providers.join 22 | :members: 23 | :undoc-members: 24 | 25 | .. automodule:: notifiers.providers.pushbullet 26 | :members: 27 | :undoc-members: 28 | 29 | .. automodule:: notifiers.providers.pushover 30 | :members: 31 | :undoc-members: 32 | 33 | .. automodule:: notifiers.providers.simplepush 34 | :members: 35 | :undoc-members: 36 | 37 | .. automodule:: notifiers.providers.slack 38 | :members: 39 | :undoc-members: 40 | 41 | .. automodule:: notifiers.providers.telegram 42 | :members: 43 | :undoc-members: 44 | 45 | .. automodule:: notifiers.providers.zulip 46 | :members: 47 | :undoc-members: 48 | 49 | .. automodule:: notifiers.providers.twilio 50 | :members: 51 | :undoc-members: 52 | 53 | .. automodule:: notifiers.providers.pagerduty 54 | :members: 55 | :undoc-members: 56 | 57 | .. automodule:: notifiers.providers.mailgun 58 | :members: 59 | :undoc-members: 60 | 61 | .. automodule:: notifiers.providers.popcornnotify 62 | :members: 63 | :undoc-members: 64 | 65 | .. automodule:: notifiers.providers.statuspage 66 | :members: 67 | :undoc-members: 68 | 69 | .. automodule:: notifiers.providers.victorops 70 | :members: 71 | :undoc-members: 72 | 73 | 74 | -------------------------------------------------------------------------------- /source/api/utils.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ===== 3 | 4 | Assorted helper utils 5 | 6 | .. autofunction:: notifiers.utils.helpers.text_to_bool 7 | .. autofunction:: notifiers.utils.helpers.merge_dicts 8 | .. autofunction:: notifiers.utils.helpers.dict_from_environs 9 | 10 | JSON schema related utils 11 | 12 | .. autofunction:: notifiers.utils.schema.helpers.one_or_more 13 | .. autofunction:: notifiers.utils.schema.helpers.list_to_commas 14 | 15 | JSON schema custom formats 16 | 17 | .. autofunction:: notifiers.utils.schema.formats.is_iso8601 18 | .. autofunction:: notifiers.utils.schema.formats.is_rfc2822 19 | .. autofunction:: notifiers.utils.schema.formats.is_ascii 20 | .. autofunction:: notifiers.utils.schema.formats.is_valid_file 21 | .. autofunction:: notifiers.utils.schema.formats.is_valid_port 22 | .. autofunction:: notifiers.utils.schema.formats.is_timestamp 23 | .. autofunction:: notifiers.utils.schema.formats.is_e164 24 | 25 | .. autoclass:: notifiers.utils.requests.RequestsHelper 26 | :members: 27 | 28 | 29 | -------------------------------------------------------------------------------- /source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | Changelog 4 | ========= 5 | 6 | 1.3.0 7 | ------ 8 | 9 | - Removed HipChat (`#404 `_) 10 | - Added VictorOps (`#401 `_) 11 | - Added iCloud (`#412 `_) 12 | - Drop Python 3.6 support 13 | 14 | 1.2.1 15 | ------------ 16 | 17 | - Adds a default timeout of (5, 20) seconds for all HTTP requests. (`#388 `_) 18 | 19 | 1.2.0 20 | ----- 21 | 22 | - Added ability to cancel login to SMTP/GMAIL if credentials are used (`#210 `_, `#266 `_) 23 | - Loosened dependencies (`#209 `_, `#271 `_) 24 | - Added mimetype guessing for email (`#239 `_, `#272 `_) 25 | 26 | 27 | 1.0.4 28 | ------ 29 | 30 | - Added `black `_ and `pre-commit `_ 31 | - Updated deps 32 | 33 | 1.0.0 34 | ----- 35 | 36 | - Added JSON Schema formatter support (`#107 `_) 37 | - Improved documentation across the board 38 | 39 | 0.7.4 40 | ----- 41 | 42 | Maintenance release, broke markdown on pypi 43 | 44 | 0.7.3 45 | ----- 46 | 47 | Added 48 | ~~~~~ 49 | 50 | - Added ability to add email attachment via SMTP (`#91 `_) via (`#99 `_). Thanks `@grabear `_ 51 | - Added direct notify ability via :meth:`notifiers.core.notify` via (`#101 `_). 52 | 53 | 0.7.2 54 | ----- 55 | 56 | Added 57 | ~~~~~ 58 | 59 | - `Mailgun `_ support (`#96 `_) 60 | - `PopcornNotify `_ support (`#97 `_) 61 | - `StatusPage.io `_ support (`#98 `_) 62 | 63 | Dependency changes 64 | ~~~~~~~~~~~~~~~~~~ 65 | 66 | - Removed :mod:`requests-toolbelt` (it wasn't actually needed, :mod:`requests` was sufficient) 67 | 68 | 0.7.1 69 | ----- 70 | 71 | Maintenance release (added logo and donation link) 72 | 73 | 0.7.0 74 | ----- 75 | 76 | Added 77 | ~~~~~ 78 | 79 | - `Pagerduty `_ support (`#95 `_) 80 | - `Twilio `_ support (`#93 `_) 81 | - Added :ref:`notification_logger` 82 | 83 | **Note** - For earlier changes please see `Github releases `_ 84 | -------------------------------------------------------------------------------- /source/index.rst: -------------------------------------------------------------------------------- 1 | .. notifiers documentation master file, created by 2 | sphinx-quickstart on Thu Aug 10 18:14:08 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Notifiers documentation! 7 | ===================================== 8 | 9 | Got an app or service and you want to enable your users to use notifications with their provider of choice? Working on a script and you want to receive notification based on its output? You don't need to implement a solution yourself, or use individual provider libs. A one stop shop for all notification providers with a unified and simple interface. 10 | 11 | Click for a list of currently supported :ref:`providers`. 12 | See latest changes in :ref:`changelog`. 13 | 14 | Advantages 15 | ---------- 16 | - Spend your precious time on your own code base, instead of chasing down 3rd party provider APIs. That's what we're here for! 17 | - With a minimal set of well known and stable dependencies (`requests `_, `jsonschema `_ and `click `_) you're better off than installing 3rd party SDKs. 18 | - A unified interface means that you already support any new providers that will be added, no more work needed! 19 | - Thorough testing means protection against any breaking API changes. We make sure your code your notifications will always get delivered! 20 | 21 | Installation 22 | ------------ 23 | Via pip: 24 | 25 | .. code-block:: console 26 | 27 | $ pip install notifiers 28 | 29 | Via Dockerhub: 30 | 31 | .. code-block:: console 32 | 33 | $ docker pull liiight/notifiers 34 | 35 | 36 | Basic Usage 37 | ----------- 38 | 39 | .. code-block:: python 40 | 41 | >>> from notifiers import get_notifier 42 | >>> pushover = get_notifier('pushover') 43 | >>> pushover.required 44 | {'required': ['user', 'message', 'token']} 45 | >>> pushover.notify(user='foo', token='bar', message='test') 46 | 47 | 48 | From CLI 49 | -------- 50 | 51 | .. code-block:: console 52 | 53 | $ notifiers pushover notify --user foo --token baz "This is so easy!" 54 | 55 | As a logger 56 | ----------- 57 | 58 | Directly add to your existing stdlib logging: 59 | 60 | .. code-block:: python 61 | 62 | >>> import logging 63 | >>> from notifiers.logging import NotificationHandler 64 | >>> log = logging.getLogger(__name__) 65 | >>> defaults = { 66 | ... 'token': 'foo', 67 | ... 'user': 'bar' 68 | ... } 69 | >>> hdlr = NotificationHandler('pushover', defaults=defaults) 70 | >>> hdlr.setLevel(logging.ERROR) 71 | >>> log.addHandler(hdlr) 72 | >>> log.error('And just like that, you get notified about all your errors!') 73 | 74 | Documentation 75 | ------------- 76 | 77 | .. toctree:: 78 | :maxdepth: 2 79 | :caption: Contents: 80 | 81 | changelog 82 | about 83 | installation 84 | usage 85 | CLI 86 | Logger 87 | 88 | Providers documentation 89 | ----------------------- 90 | 91 | .. toctree:: 92 | 93 | providers/index 94 | 95 | API documentation 96 | ----------------- 97 | 98 | .. toctree:: 99 | 100 | api/index 101 | 102 | Development documentation 103 | ------------------------- 104 | 105 | Notifiers uses `poetry `_ 106 | 107 | .. code-block:: bash 108 | 109 | $ poetry install && poetry run pytest 110 | 111 | Donations 112 | --------- 113 | 114 | If you like this and want to buy me a cup of coffee, please click the donation button above or click this `link `_ ☕ 115 | 116 | 117 | **Indices and tables** 118 | 119 | * :ref:`genindex` 120 | * :ref:`modindex` 121 | * :ref:`search` -------------------------------------------------------------------------------- /source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ------------ 3 | 4 | Via pip 5 | ======= 6 | You can install via pip: 7 | 8 | .. code-block:: console 9 | 10 | $ pip install notifiers 11 | 12 | Or install from source: 13 | 14 | .. code-block:: console 15 | 16 | $ pip install https://github.com/notifiers/notifiers/master.zip 17 | 18 | Use ``develop`` branch for cutting edge (not recommended): 19 | 20 | .. code-block:: console 21 | 22 | $ pip install https://github.com/notifiers/notifiers/develop.zip 23 | 24 | .. note:: Python 3.6 and higher is required when installing via pip 25 | 26 | Via docker 27 | ========== 28 | Alternatively, use DockerHub: 29 | 30 | .. code-block:: console 31 | 32 | $ docker pull liiight/notifiers 33 | 34 | Use ``develop`` tag for cutting edge (still not recommended): 35 | 36 | .. code-block:: console 37 | 38 | $ docker pull liiight/notifiers:develop 39 | 40 | Or build from ``DockerFile`` locally -------------------------------------------------------------------------------- /source/providers/dingtalk.rst: -------------------------------------------------------------------------------- 1 | DingTalk 2 | ---------- 3 | Send `DingTalk Robot `_ notifications 4 | 5 | Minimal example: 6 | 7 | .. code-block:: python 8 | 9 | >>> from notifiers import get_notifier 10 | >>> dingtalk = get_notifier('dingtalk') 11 | >>> dingtalk.notify(access_token='token', message='Hi there!') 12 | 13 | Full schema: 14 | 15 | .. code-block:: yaml 16 | 17 | additionalProperties: false 18 | properties: 19 | access_token: 20 | title: your access token 21 | type: string 22 | message: 23 | title: message content 24 | type: string 25 | required: 26 | - access_token 27 | - message 28 | type: object 29 | 30 | -------------------------------------------------------------------------------- /source/providers/email.rst: -------------------------------------------------------------------------------- 1 | Email (SMTP) 2 | ------------ 3 | 4 | Enables sending email messages to SMTP servers. 5 | 6 | .. code-block:: python 7 | 8 | >>> from notifiers import get_notifier 9 | >>> email = get_notifier('email') 10 | >>> email.required 11 | {'required': ['message', 'to']} 12 | 13 | >>> email.notify(to='email@addrees.foo', message='hi!') 14 | 15 | 16 | It uses several defaults: 17 | 18 | .. code-block:: python 19 | 20 | >>> email.defaults 21 | {'subject': "New email from 'notifiers'!", 'from': '[USER@HOSTNAME]', 'host': 'localhost', 'port': 25, 'tls': False, 'ssl': False, 'html': False} 22 | 23 | Any of these can be overridden by sending them to the :func:`notify` command. 24 | 25 | Full schema: 26 | 27 | .. code-block:: yaml 28 | 29 | additionalProperties: false 30 | dependencies: 31 | password: 32 | - username 33 | ssl: 34 | - tls 35 | username: 36 | - password 37 | properties: 38 | attachments: 39 | oneOf: 40 | - items: 41 | format: valid_file 42 | title: one or more attachments to use in the email 43 | type: string 44 | minItems: 1 45 | type: array 46 | uniqueItems: true 47 | - format: valid_file 48 | title: one or more attachments to use in the email 49 | type: string 50 | from: 51 | format: email 52 | title: the FROM address to use in the email 53 | type: string 54 | from_: 55 | duplicate: true 56 | format: email 57 | title: the FROM address to use in the email 58 | type: string 59 | host: 60 | format: hostname 61 | title: the host of the SMTP server 62 | type: string 63 | html: 64 | title: should the email be parse as an HTML file 65 | type: boolean 66 | message: 67 | title: the content of the email message 68 | type: string 69 | password: 70 | title: password if relevant 71 | type: string 72 | port: 73 | format: port 74 | title: the port number to use 75 | type: integer 76 | ssl: 77 | title: should SSL be used 78 | type: boolean 79 | subject: 80 | title: the subject of the email message 81 | type: string 82 | tls: 83 | title: should TLS be used 84 | type: boolean 85 | to: 86 | oneOf: 87 | - items: 88 | format: email 89 | title: one or more email addresses to use 90 | type: string 91 | minItems: 1 92 | type: array 93 | uniqueItems: true 94 | - format: email 95 | title: one or more email addresses to use 96 | type: string 97 | cc: 98 | oneOf: 99 | - items: 100 | format: email 101 | title: one or more email addresses to use 102 | type: string 103 | minItems: 0 104 | type: array 105 | uniqueItems: true 106 | - format: email 107 | title: one or more email addresses to use 108 | type: string 109 | bcc: 110 | oneOf: 111 | - items: 112 | format: email 113 | title: one or more email addresses to use 114 | type: string 115 | minItems: 0 116 | type: array 117 | uniqueItems: true 118 | - format: email 119 | title: one or more email addresses to use 120 | type: string 121 | username: 122 | title: username if relevant 123 | type: string 124 | login: 125 | title: Trigger login to server 126 | type: boolean 127 | required: 128 | - message 129 | - to 130 | type: object 131 | -------------------------------------------------------------------------------- /source/providers/gitter.rst: -------------------------------------------------------------------------------- 1 | Gitter 2 | ------ 3 | 4 | Send notifications via `Gitter `_ 5 | 6 | .. code-block:: python 7 | 8 | >>> from notifiers import get_notifier 9 | >>> gitter = get_notifier('gitter') 10 | >>> gitter.required 11 | {'required': ['message', 'token', 'room_id']} 12 | 13 | >>> gitter.notify(message='Hi!', token='SECRET_TOKEN', room_id=1234) 14 | 15 | Full schema: 16 | 17 | .. code-block:: yaml 18 | 19 | additionalProperties: false 20 | properties: 21 | message: 22 | title: Body of the message 23 | type: string 24 | room_id: 25 | title: ID of the room to send the notification to 26 | type: string 27 | token: 28 | title: access token 29 | type: string 30 | required: 31 | - message 32 | - token 33 | - room_id 34 | type: object 35 | 36 | You can view the available rooms you can access via the ``rooms`` resource 37 | 38 | .. code-block:: python 39 | 40 | >>> gitter.rooms(token="SECRET_TOKEN") 41 | {'id': '...', 'name': 'Foo/bar', ... } 42 | 43 | 44 | -------------------------------------------------------------------------------- /source/providers/gmail.rst: -------------------------------------------------------------------------------- 1 | Gmail 2 | ----- 3 | Send emails via `Gmail `_ 4 | 5 | This is a private use case of the :class:`~notifiers.providers.email.SMTP` provider 6 | 7 | .. code-block:: python 8 | 9 | >>> from notifiers import get_notifier 10 | >>> gmail = get_notifier('gmail') 11 | >>> gmail.defaults 12 | {'subject': "New email from 'notifiers'!", 'from': '', 'host': 'smtp.gmail.com', 'port': 587, 'tls': True, 'ssl': False, 'html': False} 13 | 14 | >>> gmail.notify(to='email@addrees.foo', message='hi!') 15 | 16 | -------------------------------------------------------------------------------- /source/providers/icloud.rst: -------------------------------------------------------------------------------- 1 | iCloud 2 | ----- 3 | Send emails via `iCLoud `_ 4 | 5 | This is a private use case of the :class:`~notifiers.providers.email.SMTP` provider 6 | 7 | .. code-block:: python 8 | 9 | >>> from notifiers import get_notifier 10 | >>> icloud = get_notifier('icloud') 11 | >>> icloud.defaults 12 | {'subject': "New email from 'notifiers'!", 'from': '', 'host': 'smtp.mail.me.com', 'port': 587, 'tls': True, 'ssl': False, 'html': True} 13 | 14 | >>> icloud.notify(to='email@addrees.foo', message='hi!', username='username@icloud.com', password='my-icloud-app-password', from_='username@icloud.com') 15 | 16 | 17 | .. code-block:: yaml 18 | 19 | required: 20 | - username 21 | - password 22 | - from_ 23 | - to 24 | type: object 25 | 26 | ``from_`` can be an iCloud alias 27 | username must be your primary iCloud username -------------------------------------------------------------------------------- /source/providers/index.rst: -------------------------------------------------------------------------------- 1 | .. _providers: 2 | 3 | Providers 4 | ========= 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | email 10 | gitter 11 | gmail 12 | join 13 | mailgun 14 | pagerduty 15 | popcornnotify 16 | pushbullet 17 | pushover 18 | simplepush 19 | slack 20 | statuspage 21 | telegram 22 | twilio 23 | zulip 24 | -------------------------------------------------------------------------------- /source/providers/join.rst: -------------------------------------------------------------------------------- 1 | Join 2 | ---- 3 | Send notification via `Join `_ 4 | 5 | .. code-block:: python 6 | 7 | >>> from notifiers import get_notifier 8 | >>> join = get_notifier('join') 9 | >>> join.notify(apikey='SECRET', message='Hi!') 10 | 11 | You can view the devices you can send to via the ``devices`` resource: 12 | 13 | .. code-block:: python 14 | 15 | >>> join.devices(apikey='SECRET') 16 | {'items': [{'id': 9, 'is_archived': False, ... }] 17 | 18 | Full schema: 19 | 20 | .. code-block:: yaml 21 | 22 | additionalProperties: false 23 | anyOf: 24 | - dependencies: 25 | smsnumber: 26 | - smstext 27 | - dependencies: 28 | smsnumber: 29 | - mmsfile 30 | dependencies: 31 | callnumber: 32 | - smsnumber 33 | smstext: 34 | - smsnumber 35 | error_anyOf: Must use either 'smstext' or 'mmsfile' with 'smsnumber' 36 | properties: 37 | alarmVolume: 38 | title: set device alarm volume 39 | type: string 40 | apikey: 41 | title: user API key 42 | type: string 43 | callnumber: 44 | title: number to call to 45 | type: string 46 | clipboard: 47 | title: "some text you want to set on the receiving device\u2019s clipboard" 48 | type: string 49 | deviceId: 50 | title: The device ID or group ID of the device you want to send the message to 51 | type: string 52 | deviceIds: 53 | oneOf: 54 | - items: 55 | title: A comma separated list of device IDs you want to send the push to 56 | type: string 57 | minItems: 1 58 | type: array 59 | uniqueItems: true 60 | - title: A comma separated list of device IDs you want to send the push to 61 | type: string 62 | deviceNames: 63 | oneOf: 64 | - items: 65 | title: A comma separated list of device names you want to send the push to 66 | type: string 67 | minItems: 1 68 | type: array 69 | uniqueItems: true 70 | - title: A comma separated list of device names you want to send the push to 71 | type: string 72 | file: 73 | format: uri 74 | title: a publicly accessible URL of a file 75 | type: string 76 | find: 77 | title: set to true to make your device ring loudly 78 | type: boolean 79 | group: 80 | title: allows you to join notifications in different groups 81 | type: string 82 | icon: 83 | format: uri 84 | title: notification's icon URL 85 | type: string 86 | image: 87 | format: uri 88 | title: Notification image URL 89 | type: string 90 | interruptionFilter: 91 | maximum: 4 92 | minimum: 1 93 | title: set interruption filter mode 94 | type: integer 95 | mediaVolume: 96 | title: set device media volume 97 | type: integer 98 | message: 99 | title: usually used as a Tasker or EventGhost command. Can also be used with URLs 100 | and Files to add a description for those elements 101 | type: string 102 | mmsfile: 103 | format: uri 104 | title: publicly accessible mms file url 105 | type: string 106 | priority: 107 | maximum: 2 108 | minimum: -2 109 | title: control how your notification is displayed 110 | type: integer 111 | ringVolume: 112 | title: set device ring volume 113 | type: string 114 | smallicon: 115 | format: uri 116 | title: Status Bar Icon URL 117 | type: string 118 | smsnumber: 119 | title: phone number to send an SMS to 120 | type: string 121 | smstext: 122 | title: some text to send in an SMS 123 | type: string 124 | title: 125 | title: "If used, will always create a notification on the receiving device with\ 126 | \ this as the title and text as the notification\u2019s text" 127 | type: string 128 | url: 129 | format: uri 130 | title: ' A URL you want to open on the device. If a notification is created with 131 | this push, this will make clicking the notification open this URL' 132 | type: string 133 | wallpaper: 134 | format: uri 135 | title: a publicly accessible URL of an image file 136 | type: string 137 | required: 138 | - apikey 139 | - message 140 | type: object -------------------------------------------------------------------------------- /source/providers/notify.rst: -------------------------------------------------------------------------------- 1 | Notify 2 | ------ 3 | 4 | Send notifications via `Notify `_ 5 | 6 | .. code-block:: python 7 | 8 | >>> from notifiers import get_notifier 9 | >>> notify = get_notifier('notify') 10 | >>> notify.required 11 | {'required': ['title', 'message', 'base_url']} 12 | 13 | >>> notify.notify(title='Hi!', message='my message', base_url='http://localhost:8787') 14 | # some instances may need a token 15 | >>> notify.notify(title='Hi!', message='my message', base_url='http://localhost:8787', token="send_key") 16 | 17 | Full schema: 18 | 19 | .. code-block:: yaml 20 | 21 | additionalProperties: false 22 | properties: 23 | title: 24 | title: Title of the message 25 | type: string 26 | message: 27 | title: Body of the message 28 | type: string 29 | base_url: 30 | title: URL of the Notify instance 31 | type: string 32 | description: | 33 | The URL of the Notify instance. For example, if you are using the the demo instance you would use ``https://notify-demo.deno.dev``. 34 | tags: 35 | title: Tags to send the notification to 36 | type: array 37 | items: 38 | type: string 39 | token: 40 | title: access token 41 | type: string 42 | required: 43 | - title 44 | - message 45 | - base_url 46 | type: object 47 | -------------------------------------------------------------------------------- /source/providers/pagerduty.rst: -------------------------------------------------------------------------------- 1 | Pagerduty 2 | --------- 3 | 4 | Open `Pagerduty `_ incidents 5 | 6 | .. code-block:: python 7 | 8 | >>> from notifiers import get_notifier 9 | >>> pagerduty = get_notifier('pagerduty') 10 | >>> pagerduty.notify( 11 | ... message='Oh oh...', 12 | ... event_action='trigger', 13 | ... source='prod', 14 | ... severity='info' 15 | ... ) 16 | 17 | Full schema: 18 | 19 | .. code-block:: yaml 20 | 21 | properties: 22 | class: 23 | title: The class/type of the event 24 | type: string 25 | component: 26 | title: Component of the source machine that is responsible for the event 27 | type: string 28 | custom_details: 29 | title: Additional details about the event and affected system 30 | type: object 31 | dedup_key: 32 | maxLength: 255 33 | title: Deduplication key for correlating triggers and resolves 34 | type: string 35 | event_action: 36 | enum: 37 | - trigger 38 | - acknowledge 39 | - resolve 40 | title: The type of event 41 | type: string 42 | group: 43 | title: Logical grouping of components of a service 44 | type: string 45 | images: 46 | items: 47 | additionalProperties: false 48 | properties: 49 | alt: 50 | title: Optional alternative text for the image 51 | type: string 52 | href: 53 | title: Optional URL; makes the image a clickable link 54 | type: string 55 | src: 56 | title: The source of the image being attached to the incident. This image 57 | must be served via HTTPS. 58 | type: string 59 | required: 60 | - src 61 | type: object 62 | type: array 63 | links: 64 | items: 65 | additionalProperties: false 66 | properties: 67 | href: 68 | title: URL of the link to be attached 69 | type: string 70 | text: 71 | title: Plain text that describes the purpose of the link, and can be used 72 | as the link's text 73 | type: string 74 | required: 75 | - href 76 | - text 77 | type: object 78 | type: array 79 | message: 80 | title: A brief text summary of the event, used to generate the summaries/titles 81 | of any associated alerts 82 | type: string 83 | routing_key: 84 | title: The GUID of one of your Events API V2 integrations. This is the "Integration 85 | Key" listed on the Events API V2 integration's detail page 86 | type: string 87 | severity: 88 | enum: 89 | - critical 90 | - error 91 | - warning 92 | - info 93 | title: The perceived severity of the status the event is describing with respect 94 | to the affected system 95 | type: string 96 | source: 97 | title: The unique location of the affected system, preferably a hostname or FQDN 98 | type: string 99 | timestamp: 100 | format: iso8601 101 | title: The time at which the emitting tool detected or generated the event in 102 | ISO 8601 103 | type: string 104 | required: 105 | - routing_key 106 | - event_action 107 | - source 108 | - severity 109 | - message 110 | type: object 111 | -------------------------------------------------------------------------------- /source/providers/popcornnotify.rst: -------------------------------------------------------------------------------- 1 | PopcornNotify 2 | ------------- 3 | Send `PopcornNotify `_ notifications 4 | 5 | .. code-block:: python 6 | 7 | >>> from notifiers import get_notifier 8 | >>> popcornnotify = get_notifier('popcornnotify') 9 | >>> popcornnotify.notify( 10 | ... message='Hi!', 11 | ... api_key='SECRET', 12 | ... recipients=[ 13 | ... 'foo@bar.com', 14 | ... ], 15 | ... subject='Message subject!' 16 | ... ) 17 | 18 | Full schema: 19 | 20 | .. code-block:: yaml 21 | 22 | properties: 23 | api_key: 24 | title: The API key 25 | type: string 26 | message: 27 | title: The message to send 28 | type: string 29 | recipients: 30 | oneOf: 31 | - items: 32 | format: email 33 | title: The recipient email address or phone number. Or an array of email addresses 34 | and phone numbers 35 | type: string 36 | minItems: 1 37 | type: array 38 | uniqueItems: true 39 | - format: email 40 | title: The recipient email address or phone number. Or an array of email addresses 41 | and phone numbers 42 | type: string 43 | subject: 44 | title: The subject of the email. It will not be included in text messages. 45 | type: string 46 | required: 47 | - message 48 | - api_key 49 | - recipients 50 | type: object 51 | 52 | -------------------------------------------------------------------------------- /source/providers/pushbullet.rst: -------------------------------------------------------------------------------- 1 | Pushbullet 2 | ---------- 3 | Send `Pushbullet `_ notifications. 4 | 5 | .. code-block:: python 6 | 7 | >>> from notifiers import get_notifier 8 | >>> pushbullet = get_notifier('pushbullet') 9 | >>> pushbullet.notify( 10 | ... message='Hi!', 11 | ... token='SECRET', 12 | ... title='Message title', 13 | ... type_='note', 14 | ... url='https://url.in/message', 15 | ... source_device_iden='FOO', 16 | ... device_iden='bar', 17 | ... client_iden='baz', 18 | ... channel_tag='channel tag', 19 | ... email='foo@bar.com', 20 | ... guid='1234abcd', 21 | ... ) 22 | 23 | You can view the devices you can send to via the ``devices`` resource: 24 | 25 | .. code-block:: python 26 | 27 | >>> pushbullet.devices(token='SECRET') 28 | [{'active': True, 'iden': ... }] 29 | 30 | Full schema: 31 | 32 | .. code-block:: yaml 33 | 34 | additionalProperties: false 35 | properties: 36 | channel_tag: 37 | title: Channel tag of the target channel, sends a push to all people who are subscribed 38 | to this channel. The current user must own this channel. 39 | type: string 40 | client_iden: 41 | title: Client iden of the target client, sends a push to all users who have granted 42 | access to this client. The current user must own this client 43 | type: string 44 | device_iden: 45 | title: Device iden of the target device, if sending to a single device 46 | type: string 47 | email: 48 | format: email 49 | title: Email address to send the push to. If there is a pushbullet user with this 50 | address, they get a push, otherwise they get an email 51 | type: string 52 | guid: 53 | title: Unique identifier set by the client, used to identify a push in case you 54 | receive it from /v2/everything before the call to /v2/pushes has completed. 55 | This should be a unique value. Pushes with guid set are mostly idempotent, meaning 56 | that sending another push with the same guid is unlikely to create another push 57 | (it will return the previously created push). 58 | type: string 59 | message: 60 | title: Body of the push 61 | type: string 62 | source_device_iden: 63 | title: Device iden of the sending device 64 | type: string 65 | title: 66 | title: Title of the push 67 | type: string 68 | token: 69 | title: API access token 70 | type: string 71 | type: 72 | enum: 73 | - note 74 | - link 75 | title: Type of the push, one of "note" or "link" 76 | type: string 77 | type_: 78 | enum: 79 | - note 80 | - link 81 | title: Type of the push, one of "note" or "link" 82 | type: string 83 | url: 84 | title: URL field, used for type="link" pushes 85 | type: string 86 | required: 87 | - message 88 | - token 89 | type: object 90 | -------------------------------------------------------------------------------- /source/providers/pushover.rst: -------------------------------------------------------------------------------- 1 | Pushover 2 | -------- 3 | 4 | Send `Pushover `_ notifications 5 | 6 | Minimal example: 7 | 8 | .. code-block:: python 9 | 10 | >>> from notifiers import get_notifier 11 | >>> pushover = get_notifier('pushover') 12 | >>> pushover.notify(message='Hi!', user='USER', token='TOKEN') 13 | 14 | You can view the sounds you can user in the notification via the ``sounds`` resource: 15 | 16 | .. code-block:: python 17 | 18 | >>> pushover.sounds(token='SECRET') 19 | ['pushover', 'bike', 'bugle', 'cashregister', 'classical', 'cosmic', 'falling', 'gamelan', 'incoming', 'intermission', 'magic', 'mechanical', 'pianobar', 'siren', 'spacealarm', 'tugboat', 'alien', 'climb', 'persistent', 'echo', 'updown', 'none'] 20 | 21 | You can view the limits of your application token using the ``limits`` resource: 22 | 23 | .. code-block:: python 24 | 25 | >>> pushover.limits(token='SECRET') 26 | {'limit': 7500, 'remaining': 6841, 'reset': 1535778000, 'status': 1, 'request': 'f0cb73b1-810d-4b9a-b275-394481bceb74'} 27 | 28 | Full schema: 29 | 30 | .. code-block:: yaml 31 | 32 | additionalProperties: false 33 | properties: 34 | attachment: 35 | format: valid_file 36 | title: an image attachment to send with the message 37 | type: string 38 | callback: 39 | format: uri 40 | title: a publicly-accessible URL that our servers will send a request to when 41 | the user has acknowledged your notification. priority must be set to 2 42 | type: string 43 | device: 44 | oneOf: 45 | - items: 46 | title: your user's device name to send the message directly to that device 47 | type: string 48 | minItems: 1 49 | type: array 50 | uniqueItems: true 51 | - title: your user's device name to send the message directly to that device 52 | type: string 53 | expire: 54 | maximum: 86400 55 | title: how many seconds your notification will continue to be retried for. priority 56 | must be set to 2 57 | type: integer 58 | html: 59 | title: enable HTML formatting 60 | type: boolean 61 | message: 62 | title: your message 63 | type: string 64 | priority: 65 | maximum: 2 66 | minimum: -2 67 | title: notification priority 68 | type: integer 69 | retry: 70 | minimum: 30 71 | title: how often (in seconds) the Pushover servers will send the same notification 72 | to the user. priority must be set to 2 73 | type: integer 74 | sound: 75 | title: the name of one of the sounds supported by device clients to override the 76 | user's default sound choice. See `sounds` resource 77 | type: string 78 | timestamp: 79 | format: timestamp 80 | minimum: 0 81 | title: a Unix timestamp of your message's date and time to display to the user, 82 | rather than the time your message is received by our API 83 | type: 84 | - integer 85 | - string 86 | title: 87 | title: your message's title, otherwise your app's name is used 88 | type: string 89 | token: 90 | title: your application's API token 91 | type: string 92 | url: 93 | format: uri 94 | title: a supplementary URL to show with your message 95 | type: string 96 | url_title: 97 | title: a title for your supplementary URL, otherwise just the URL is shown 98 | type: string 99 | user: 100 | oneOf: 101 | - items: 102 | title: the user/group key (not e-mail address) of your user (or you) 103 | type: string 104 | minItems: 1 105 | type: array 106 | uniqueItems: true 107 | - title: the user/group key (not e-mail address) of your user (or you) 108 | type: string 109 | required: 110 | - user 111 | - message 112 | - token 113 | type: object 114 | -------------------------------------------------------------------------------- /source/providers/simplepush.rst: -------------------------------------------------------------------------------- 1 | SimplePush 2 | ---------- 3 | Send `SimplePush `_ notifications 4 | 5 | Minimal example: 6 | 7 | .. code-block:: python 8 | 9 | >>> from notifiers import get_notifier 10 | >>> simplepush = get_notifier('simplepush') 11 | >>> simplepush.notify(message='Hi!', key='KEY') 12 | 13 | Full schema: 14 | 15 | .. code-block:: yaml 16 | 17 | additionalProperties: false 18 | properties: 19 | event: 20 | title: Event ID 21 | type: string 22 | key: 23 | title: your user key 24 | type: string 25 | message: 26 | title: your message 27 | type: string 28 | title: 29 | title: message title 30 | type: string 31 | required: 32 | - key 33 | - message 34 | type: object 35 | 36 | -------------------------------------------------------------------------------- /source/providers/slack.rst: -------------------------------------------------------------------------------- 1 | Slack (Webhooks) 2 | ---------------- 3 | 4 | Send `Slack `_ webhook notifications. 5 | 6 | Minimal example: 7 | 8 | .. code-block:: python 9 | 10 | >>> from notifiers import get_notifier 11 | >>> slack = get_notifier('slack') 12 | >>> slack.notify(message='Hi!', webhook_url='https://url.to/webhook') 13 | 14 | Full schema: 15 | 16 | .. code-block:: yaml 17 | 18 | additionalProperties: false 19 | properties: 20 | attachments: 21 | items: 22 | additionalProperties: false 23 | properties: 24 | author_icon: 25 | title: A valid URL that displays a small 16x16px image to the left of the 26 | author_name text. Will only work if author_name is present 27 | type: string 28 | author_link: 29 | title: A valid URL that will hyperlink the author_name text mentioned above. 30 | Will only work if author_name is present 31 | type: string 32 | author_name: 33 | title: Small text used to display the author's name 34 | type: string 35 | color: 36 | title: Can either be one of 'good', 'warning', 'danger', or any hex color 37 | code 38 | type: string 39 | fallback: 40 | title: A plain-text summary of the attachment. This text will be used in 41 | clients that don't show formatted text (eg. IRC, mobile notifications) 42 | and should not contain any markup. 43 | type: string 44 | fields: 45 | items: 46 | additionalProperties: false 47 | properties: 48 | short: 49 | title: Optional flag indicating whether the `value` is short enough 50 | to be displayed side-by-side with other values 51 | type: boolean 52 | title: 53 | title: Required Field Title 54 | type: string 55 | value: 56 | title: Text value of the field. May contain standard message markup 57 | and must be escaped as normal. May be multi-line 58 | type: string 59 | required: 60 | - title 61 | type: object 62 | minItems: 1 63 | title: Fields are displayed in a table on the message 64 | type: array 65 | footer: 66 | title: Footer text 67 | type: string 68 | footer_icon: 69 | format: uri 70 | title: Footer icon URL 71 | type: string 72 | image_url: 73 | format: uri 74 | title: Image URL 75 | type: string 76 | pretext: 77 | title: Optional text that should appear above the formatted data 78 | type: string 79 | text: 80 | title: Optional text that should appear within the attachment 81 | type: string 82 | thumb_url: 83 | format: uri 84 | title: Thumbnail URL 85 | type: string 86 | title: 87 | title: Attachment title 88 | type: string 89 | title_link: 90 | title: Attachment title URL 91 | type: string 92 | ts: 93 | format: timestamp 94 | title: Provided timestamp (epoch) 95 | type: 96 | - integer 97 | - string 98 | required: 99 | - fallback 100 | type: object 101 | type: array 102 | channel: 103 | title: override default channel or private message 104 | type: string 105 | icon_emoji: 106 | title: override bot icon with emoji name. 107 | type: string 108 | icon_url: 109 | format: uri 110 | title: override bot icon with image URL 111 | type: string 112 | message: 113 | title: This is the text that will be posted to the channel 114 | type: string 115 | unfurl_links: 116 | title: avoid automatic attachment creation from URLs 117 | type: boolean 118 | username: 119 | title: override the displayed bot name 120 | type: string 121 | webhook_url: 122 | format: uri 123 | title: the webhook URL to use. Register one at https://my.slack.com/services/new/incoming-webhook/ 124 | type: string 125 | required: 126 | - webhook_url 127 | - message 128 | type: object 129 | 130 | -------------------------------------------------------------------------------- /source/providers/statuspage.rst: -------------------------------------------------------------------------------- 1 | StatusPage 2 | ---------- 3 | Send `StatusPage.io `_ notifications 4 | 5 | Minimal example: 6 | 7 | .. code-block:: python 8 | 9 | >>> from notifiers import get_notifier 10 | >>> statuspage = get_notifier('statuspage') 11 | >>> statuspage.notify(message='Hi!', api_key='KEY', page_id='123ABC') 12 | 13 | You can view the components you use in the notification via the ``components`` resource: 14 | 15 | .. code-block:: python 16 | 17 | >>> statuspage.components(api_key='KEY', page_id='123ABC') 18 | [{'id': '...', 'page_id': '...', ...] 19 | 20 | Full schema: 21 | 22 | .. code-block:: yaml 23 | 24 | additionalProperties: false 25 | dependencies: 26 | backfill_date: 27 | - backfilled 28 | backfilled: 29 | - backfill_date 30 | scheduled_auto_completed: 31 | - scheduled_for 32 | scheduled_auto_in_progress: 33 | - scheduled_for 34 | scheduled_for: 35 | - scheduled_until 36 | scheduled_remind_prior: 37 | - scheduled_for 38 | scheduled_until: 39 | - scheduled_for 40 | properties: 41 | api_key: 42 | title: OAuth2 token 43 | type: string 44 | backfill_date: 45 | format: date 46 | title: Date of incident in YYYY-MM-DD format 47 | type: string 48 | backfilled: 49 | title: Create an historical incident 50 | type: boolean 51 | body: 52 | title: The initial message, created as the first incident update 53 | type: string 54 | component_ids: 55 | items: 56 | type: string 57 | title: List of components whose subscribers should be notified (only applicable 58 | for pages with component subscriptions enabled) 59 | type: array 60 | deliver_notifications: 61 | title: Control whether notifications should be delivered for the initial incident 62 | update 63 | type: boolean 64 | impact_override: 65 | enum: 66 | - none 67 | - minor 68 | - major 69 | - critical 70 | title: Override calculated impact value 71 | type: string 72 | message: 73 | title: The name of the incident 74 | type: string 75 | page_id: 76 | title: Page ID 77 | type: string 78 | scheduled_auto_completed: 79 | title: Automatically transition incident to 'Completed' at end 80 | type: boolean 81 | scheduled_auto_in_progress: 82 | title: Automatically transition incident to 'In Progress' at start 83 | type: boolean 84 | scheduled_for: 85 | format: iso8601 86 | title: Time the scheduled maintenance should begin 87 | type: string 88 | scheduled_remind_prior: 89 | title: Remind subscribers 60 minutes before scheduled start 90 | type: boolean 91 | scheduled_until: 92 | format: iso8601 93 | title: Time the scheduled maintenance should end 94 | type: string 95 | status: 96 | enum: 97 | - investigating 98 | - identified 99 | - monitoring 100 | - resolved 101 | - scheduled 102 | - in_progress 103 | - verifying 104 | - completed 105 | title: Status of the incident 106 | type: string 107 | wants_twitter_update: 108 | title: Post the new incident to twitter 109 | type: boolean 110 | required: 111 | - message 112 | - api_key 113 | - page_id 114 | type: object 115 | 116 | -------------------------------------------------------------------------------- /source/providers/telegram.rst: -------------------------------------------------------------------------------- 1 | Telegram 2 | -------- 3 | Send `Telegram `_ notifications. 4 | 5 | Minimal example: 6 | 7 | .. code-block:: python 8 | 9 | >>> from notifiers import get_notifier 10 | >>> telegram = get_notifier('telegram') 11 | >>> telegram.notify(message='Hi!', token='TOKEN', chat_id=1234) 12 | 13 | See `here ` for an example how to retrieve the ``chat_id`` for your bot. 14 | 15 | You can view the available updates you can access via the ``updates`` resource 16 | 17 | .. code-block:: python 18 | 19 | >>> telegram.updates(token="SECRET_TOKEN") 20 | {'id': '...', 'name': 'Foo/bar', ... } 21 | 22 | Full schema: 23 | 24 | .. code-block:: yaml 25 | 26 | additionalProperties: false 27 | properties: 28 | chat_id: 29 | oneOf: 30 | - type: string 31 | - type: integer 32 | title: Unique identifier for the target chat or username of the target channel 33 | (in the format @channelusername) 34 | disable_notification: 35 | title: Sends the message silently. Users will receive a notification with no sound. 36 | type: boolean 37 | disable_web_page_preview: 38 | title: Disables link previews for links in this message 39 | type: boolean 40 | message: 41 | title: Text of the message to be sent 42 | type: string 43 | parse_mode: 44 | enum: 45 | - markdown 46 | - html 47 | title: Send Markdown or HTML, if you want Telegram apps to show bold, italic, 48 | fixed-width text or inline URLs in your bot's message. 49 | type: string 50 | reply_to_message_id: 51 | title: If the message is a reply, ID of the original message 52 | type: integer 53 | token: 54 | title: Bot token 55 | type: string 56 | required: 57 | - message 58 | - chat_id 59 | - token 60 | type: object 61 | 62 | -------------------------------------------------------------------------------- /source/providers/twilio.rst: -------------------------------------------------------------------------------- 1 | Twilio 2 | ------ 3 | Send `Twilio `_ SMS 4 | 5 | .. code-block:: python 6 | 7 | >>> from notifiers import get_notifier 8 | >>> twilio = get_notifier('twilio') 9 | >>> twilio.notify(message='Hi!', to='+12345678', account_sid=1234, auth_token='TOKEN') 10 | 11 | 12 | Full schema: 13 | 14 | .. code-block:: yaml 15 | 16 | allOf: 17 | - anyOf: 18 | - anyOf: 19 | - required: 20 | - from 21 | - required: 22 | - from_ 23 | - required: 24 | - messaging_service_id 25 | error_anyOf: Either 'from' or 'messaging_service_id' are required 26 | - anyOf: 27 | - required: 28 | - message 29 | - required: 30 | - media_url 31 | error_anyOf: Either 'message' or 'media_url' are required 32 | - required: 33 | - to 34 | - account_sid 35 | - auth_token 36 | properties: 37 | account_sid: 38 | title: The unique id of the Account that sent this message. 39 | type: string 40 | application_sid: 41 | title: Twilio will POST MessageSid as well as MessageStatus=sent or MessageStatus=failed 42 | to the URL in the MessageStatusCallback property of this Application 43 | type: string 44 | auth_token: 45 | title: The user's auth token 46 | type: string 47 | from: 48 | title: Twilio phone number or the alphanumeric sender ID used 49 | type: string 50 | from_: 51 | duplicate: true 52 | title: Twilio phone number or the alphanumeric sender ID used 53 | type: string 54 | max_price: 55 | title: The total maximum price up to the fourth decimal (0.0001) in US dollars 56 | acceptable for the message to be delivered 57 | type: number 58 | media_url: 59 | format: uri 60 | title: The URL of the media you wish to send out with the message 61 | type: string 62 | message: 63 | maxLength: 1600 64 | title: The text body of the message. Up to 1,600 characters long. 65 | type: string 66 | messaging_service_id: 67 | title: The unique id of the Messaging Service used with the message 68 | type: string 69 | provide_feedback: 70 | title: Set this value to true if you are sending messages that have a trackable 71 | user action and you intend to confirm delivery of the message using the Message 72 | Feedback API 73 | type: boolean 74 | status_callback: 75 | format: uri 76 | title: A URL where Twilio will POST each time your message status changes 77 | type: string 78 | to: 79 | title: The recipient of the message, in E.164 format 80 | format: e164 81 | type: string 82 | validity_period: 83 | maximum: 14400 84 | minimum: 1 85 | title: The number of seconds that the message can remain in a Twilio queue 86 | type: integer 87 | type: object 88 | -------------------------------------------------------------------------------- /source/providers/victorops.rst: -------------------------------------------------------------------------------- 1 | VictorOps (REST) 2 | -------------------- 3 | 4 | Send `VictorOps `_ rest integration notifications. 5 | 6 | Minimal example: 7 | 8 | .. code-block:: python 9 | 10 | >>> from notifiers import get_notifier 11 | >>> victorops = get_notifier('victorops') 12 | >>> victorops.notify(rest_url='https://alert.victorops.com/integrations/generic/20104876/alert/f7dc2eeb-ms9k-43b8-kd89-0f00000f4ec2/$routing_key', 13 | message_type='CRITICAL', 14 | entity_id='foo testing', 15 | entity_display_name="bla test title text", 16 | message="bla message description") 17 | 18 | Full schema: 19 | 20 | .. code-block:: yaml 21 | 22 | additionalProperties: false 23 | properties: 24 | rest_url: 25 | type: string 26 | format: uri 27 | title: the REST URL to use with routing_key, create one in victorops integrations tab. 28 | 29 | message_type: 30 | type: string 31 | title: severity level can be: 32 | - critical or warning: Triggers an incident 33 | - acknowledgement: Acks an incident 34 | - info: Creates a timeline event but doesn't trigger an incident 35 | - recovery or ok: Resolves an incident 36 | 37 | entity_id: 38 | type: string 39 | title: Unique id for the incident for aggregation acking or resolving. 40 | 41 | entity_display_name: 42 | type: string 43 | title: Display Name in the UI and Notifications. 44 | 45 | message: 46 | type: string 47 | title: This is the description that will be posted in the incident. 48 | 49 | annotations: 50 | type: object 51 | format: 52 | vo_annotate.s.{custom_name}: annotation 53 | vo_annotate.u.{custom_name}: annotation 54 | vo_annotate.i.{custom_name}: annotation 55 | title: annotations can be of three types vo_annotate.u.{custom_name} vo_annotate.s.{custom_name} vo_annotate.i.{custom_name}. 56 | 57 | additional_keys: 58 | type: object 59 | format: 60 | key: value 61 | key: value 62 | key: value 63 | title: any additional keys that ca be passed in the body 64 | 65 | required: 66 | - rest_url 67 | - message_type 68 | - entity_id 69 | - entity_display_name 70 | - message 71 | type: object 72 | 73 | -------------------------------------------------------------------------------- /source/providers/zulip.rst: -------------------------------------------------------------------------------- 1 | Zulip 2 | ----- 3 | Send `Zulip `_ notifications 4 | 5 | .. code-block:: python 6 | 7 | >>> from notifiers import get_notifier 8 | >>> zulip = get_notifier('zulip') 9 | >>> zulip.notify(message='Hi!', to='foo', email='foo@bar.com', api_key='KEY', domain='foobar') 10 | 11 | Full schema: 12 | 13 | .. code-block:: yaml 14 | 15 | additionalProperties: false 16 | allOf: 17 | - required: 18 | - message 19 | - email 20 | - api_key 21 | - to 22 | - error_oneOf: Only one of 'domain' or 'server' is allowed 23 | oneOf: 24 | - required: 25 | - domain 26 | - required: 27 | - server 28 | properties: 29 | api_key: 30 | title: User API Key 31 | type: string 32 | domain: 33 | minLength: 1 34 | title: Zulip cloud domain 35 | type: string 36 | email: 37 | format: email 38 | title: User email 39 | type: string 40 | message: 41 | title: Message content 42 | type: string 43 | server: 44 | format: uri 45 | title: 'Zulip server URL. Example: https://myzulip.server.com' 46 | type: string 47 | subject: 48 | title: Title of the stream message. Required when using stream. 49 | type: string 50 | to: 51 | title: Target of the message 52 | type: string 53 | type: 54 | enum: 55 | - stream 56 | - private 57 | title: Type of message to send 58 | type: string 59 | type_: 60 | enum: 61 | - stream 62 | - private 63 | title: Type of message to send 64 | type: string 65 | type: object 66 | 67 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from datetime import datetime 4 | from functools import partial 5 | from unittest.mock import MagicMock 6 | 7 | import pytest 8 | from click.testing import CliRunner 9 | 10 | from notifiers.core import ( 11 | SUCCESS_STATUS, 12 | Provider, 13 | ProviderResource, 14 | Response, 15 | get_notifier, 16 | ) 17 | from notifiers.logging import NotificationHandler 18 | from notifiers.providers import _all_providers 19 | from notifiers.utils.helpers import text_to_bool 20 | from notifiers.utils.schema.helpers import list_to_commas, one_or_more 21 | 22 | log = logging.getLogger(__name__) 23 | 24 | 25 | class MockProxy: 26 | name = "mock_provider" 27 | 28 | 29 | class MockResource(MockProxy, ProviderResource): 30 | resource_name = "mock_resource" 31 | 32 | _required = {"required": ["key"]} 33 | _schema = { 34 | "type": "object", 35 | "properties": { 36 | "key": {"type": "string", "title": "required key"}, 37 | "another_key": {"type": "integer", "title": "non-required key"}, 38 | }, 39 | "additionalProperties": False, 40 | } 41 | 42 | def _get_resource(self, data: dict): 43 | return {"status": SUCCESS_STATUS} 44 | 45 | 46 | class MockProvider(MockProxy, Provider): 47 | """Mock Provider""" 48 | 49 | base_url = "https://api.mock.com" 50 | _required = {"required": ["required"]} 51 | _schema = { 52 | "type": "object", 53 | "properties": { 54 | "not_required": one_or_more({"type": "string", "title": "example for not required arg"}), 55 | "required": {"type": "string"}, 56 | "option_with_default": {"type": "string"}, 57 | "message": {"type": "string"}, 58 | }, 59 | "additionalProperties": False, 60 | } 61 | site_url = "https://www.mock.com" 62 | 63 | @property 64 | def defaults(self): 65 | return {"option_with_default": "foo"} 66 | 67 | def _send_notification(self, data: dict): 68 | return Response(status=SUCCESS_STATUS, provider=self.name, data=data) 69 | 70 | def _prepare_data(self, data: dict): 71 | if data.get("not_required"): 72 | data["not_required"] = list_to_commas(data["not_required"]) 73 | data["required"] = list_to_commas(data["required"]) 74 | return data 75 | 76 | @property 77 | def resources(self): 78 | return ["mock_rsrc"] 79 | 80 | @property 81 | def mock_rsrc(self): 82 | return MockResource() 83 | 84 | 85 | @pytest.fixture(scope="session") 86 | def mock_provider(): 87 | """Return a generic :class:`notifiers.core.Provider` class""" 88 | _all_providers.update({MockProvider.name: MockProvider}) 89 | return MockProvider() 90 | 91 | 92 | @pytest.fixture 93 | def bad_provider(): 94 | """Returns an unimplemented :class:`notifiers.core.Provider` class for testing""" 95 | 96 | class BadProvider(Provider): 97 | pass 98 | 99 | return BadProvider 100 | 101 | 102 | @pytest.fixture 103 | def bad_schema(): 104 | """Return a provider with an invalid JSON schema""" 105 | 106 | class BadSchema(Provider): 107 | _required = {"required": ["fpp"]} 108 | _schema = {"type": "banana"} 109 | 110 | name = "bad_schema" 111 | base_url = "" 112 | site_url = "" 113 | 114 | def _send_notification(self, data: dict): 115 | pass 116 | 117 | return BadSchema 118 | 119 | 120 | @pytest.fixture(scope="class") 121 | def provider(request): 122 | name = getattr(request.module, "provider", None) 123 | if not name: 124 | pytest.fail(f"Test class '{request.module}' has not 'provider' attribute set") 125 | p = get_notifier(name) 126 | if not p: 127 | pytest.fail(f"No notifier with name '{name}'") 128 | return p 129 | 130 | 131 | @pytest.fixture(scope="class") 132 | def resource(request, provider): 133 | name = getattr(request.cls, "resource", None) 134 | if not name: 135 | pytest.fail(f"Test class '{request.cls}' has not 'resource' attribute set") 136 | resource = getattr(provider, name, None) 137 | if not resource: 138 | pytest.fail(f"Provider {provider.name} does not have a resource named {name}") 139 | return resource 140 | 141 | 142 | @pytest.fixture 143 | def cli_runner(monkeypatch): 144 | from notifiers_cli.core import notifiers_cli, provider_group_factory 145 | 146 | monkeypatch.setenv("LC_ALL", "en_US.utf-8") 147 | monkeypatch.setenv("LANG", "en_US.utf-8") 148 | provider_group_factory() 149 | runner = CliRunner() 150 | return partial(runner.invoke, notifiers_cli, obj={}) 151 | 152 | 153 | @pytest.fixture 154 | def magic_mock_provider(monkeypatch): 155 | MockProvider.notify = MagicMock() 156 | MockProxy.name = "magic_mock" 157 | monkeypatch.setitem(_all_providers, MockProvider.name, MockProvider) 158 | return MockProvider() 159 | 160 | 161 | @pytest.fixture 162 | def handler(caplog): 163 | def return_handler(provider_name, logging_level, data=None, **kwargs): 164 | caplog.set_level(logging.INFO) 165 | hdlr = NotificationHandler(provider_name, data, **kwargs) 166 | hdlr.setLevel(logging_level) 167 | return hdlr 168 | 169 | return return_handler 170 | 171 | 172 | def pytest_runtest_setup(item): 173 | """Skips PRs if secure env vars are set and test is marked as online""" 174 | pull_request = text_to_bool(os.environ.get("TRAVIS_PULL_REQUEST")) 175 | secure_env_vars = text_to_bool(os.environ.get("TRAVIS_SECURE_ENV_VARS")) 176 | online = item.get_closest_marker("online") 177 | if online and pull_request and not secure_env_vars: 178 | pytest.skip("skipping online tests via PRs") 179 | 180 | 181 | @pytest.fixture 182 | def test_message(request): 183 | message = os.environ.get("TRAVIS_BUILD_WEB_URL") or "Local test" 184 | return f"{message}-{request.node.name}-{datetime.now().isoformat()}" 185 | -------------------------------------------------------------------------------- /tests/providers/test_dingtalk.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | provider = "dingtalk" 4 | 5 | 6 | class TestDingTalk: 7 | def test_dingtalk_metadata(self, provider): 8 | assert provider.metadata == { 9 | "base_url": "https://oapi.dingtalk.com/robot/send", 10 | "name": "dingtalk", 11 | "site_url": "https://open.dingtalk.com/document/", 12 | } 13 | 14 | @pytest.mark.online 15 | @pytest.mark.skip 16 | def test_sanity(self, provider, test_message): 17 | data = {"access_token": "token", "message": test_message} 18 | provider.notify(**data, raise_on_errors=True) 19 | -------------------------------------------------------------------------------- /tests/providers/test_gitter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from notifiers.exceptions import BadArguments, NotificationError, ResourceError 4 | 5 | provider = "gitter" 6 | 7 | 8 | class TestGitter: 9 | def test_metadata(self, provider): 10 | assert provider.metadata == { 11 | "base_url": "https://api.gitter.im/v1/rooms", 12 | "message_url": "/{room_id}/chatMessages", 13 | "name": "gitter", 14 | "site_url": "https://gitter.im", 15 | } 16 | 17 | @pytest.mark.parametrize( 18 | ("data", "message"), 19 | [ 20 | ({}, "message"), 21 | ({"message": "foo"}, "token"), 22 | ({"message": "foo", "token": "bar"}, "room_id"), 23 | ], 24 | ) 25 | def test_missing_required(self, provider, data, message): 26 | data["env_prefix"] = "test" 27 | with pytest.raises(BadArguments) as e: 28 | provider.notify(**data) 29 | assert f"'{message}' is a required property" in e.value.message 30 | 31 | @pytest.mark.skip(reason="Disabled account") 32 | @pytest.mark.online 33 | def test_bad_request(self, provider): 34 | data = {"token": "foo", "room_id": "baz", "message": "bar"} 35 | with pytest.raises(NotificationError) as e: 36 | provider.notify(**data).raise_on_errors() 37 | 38 | assert "Unauthorized" in e.value.message 39 | 40 | @pytest.mark.online 41 | @pytest.mark.skip(reason="Disabled account") 42 | def test_bad_room_id(self, provider): 43 | data = {"room_id": "baz", "message": "bar"} 44 | with pytest.raises(NotificationError) as e: 45 | provider.notify(**data).raise_on_errors() 46 | 47 | assert "Bad Request" in e.value.message 48 | 49 | @pytest.mark.online 50 | @pytest.mark.skip(reason="Disabled account") 51 | def test_sanity(self, provider, test_message): 52 | data = {"message": test_message} 53 | rsp = provider.notify(**data) 54 | rsp.raise_on_errors() 55 | 56 | def test_gitter_resources(self, provider): 57 | assert provider.resources 58 | for resource in provider.resources: 59 | assert getattr(provider, resource) 60 | assert "rooms" in provider.resources 61 | 62 | 63 | @pytest.mark.skip(reason="Disabled account") 64 | class TestGitterResources: 65 | resource = "rooms" 66 | 67 | def test_gitter_rooms_attribs(self, resource): 68 | assert resource.schema == { 69 | "type": "object", 70 | "properties": { 71 | "token": {"type": "string", "title": "access token"}, 72 | "filter": {"type": "string", "title": "Filter results"}, 73 | }, 74 | "required": ["token"], 75 | "additionalProperties": False, 76 | } 77 | assert resource.name == provider 78 | assert resource.required == {"required": ["token"]} 79 | 80 | def test_gitter_rooms_negative(self, resource): 81 | with pytest.raises(BadArguments): 82 | resource(env_prefix="foo") 83 | 84 | def test_gitter_rooms_negative_2(self, resource): 85 | with pytest.raises(ResourceError) as e: 86 | resource(token="foo") 87 | assert e.value.errors == ["Unauthorized"] 88 | assert e.value.response.status_code == 401 89 | 90 | @pytest.mark.online 91 | def test_gitter_rooms_positive(self, resource): 92 | rsp = resource() 93 | assert isinstance(rsp, list) 94 | 95 | @pytest.mark.online 96 | def test_gitter_rooms_positive_with_filter(self, resource): 97 | assert resource(filter="notifiers/testing") 98 | 99 | 100 | @pytest.mark.skip(reason="Disabled account") 101 | class TestGitterCLI: 102 | """Test Gitter specific CLI commands""" 103 | 104 | def test_gitter_rooms_negative(self, cli_runner): 105 | cmd = ["gitter", "rooms", "--token", "bad_token"] 106 | result = cli_runner(cmd) 107 | assert result.exit_code 108 | assert not result.output 109 | 110 | @pytest.mark.online 111 | def test_gitter_rooms_positive(self, cli_runner): 112 | cmd = ["gitter", "rooms"] 113 | result = cli_runner(cmd) 114 | assert not result.exit_code 115 | assert "notifiers/testing" in result.output 116 | 117 | @pytest.mark.online 118 | def test_gitter_rooms_with_query(self, cli_runner): 119 | cmd = ["gitter", "rooms", "--filter", "notifiers/testing"] 120 | result = cli_runner(cmd) 121 | assert not result.exit_code 122 | assert "notifiers/testing" in result.output 123 | -------------------------------------------------------------------------------- /tests/providers/test_gmail.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from notifiers.exceptions import BadArguments, NotificationError 4 | 5 | provider = "gmail" 6 | 7 | 8 | class TestGmail: 9 | """Gmail tests""" 10 | 11 | def test_gmail_metadata(self, provider): 12 | assert provider.metadata == { 13 | "base_url": "smtp.gmail.com", 14 | "name": "gmail", 15 | "site_url": "https://www.google.com/gmail/about/", 16 | } 17 | 18 | @pytest.mark.parametrize(("data", "message"), [({}, "message"), ({"message": "foo"}, "to")]) 19 | def test_gmail_missing_required(self, data, message, provider): 20 | data["env_prefix"] = "test" 21 | with pytest.raises(BadArguments) as e: 22 | provider.notify(**data) 23 | assert f"'{message}' is a required property" in e.value.message 24 | 25 | @pytest.mark.skip(reason="Disabled account") 26 | @pytest.mark.online 27 | def test_smtp_sanity(self, provider, test_message): 28 | """using Gmail SMTP""" 29 | data = { 30 | "message": f"{test_message}", 31 | "html": True, 32 | "ssl": True, 33 | "port": 465, 34 | } 35 | rsp = provider.notify(**data) 36 | rsp.raise_on_errors() 37 | 38 | def test_email_from_key(self, provider): 39 | rsp = provider.notify( 40 | to="foo@foo.com", 41 | from_="bla@foo.com", 42 | message="foo", 43 | host="goo", 44 | username="ding", 45 | password="dong", 46 | ) 47 | rsp_data = rsp.data 48 | assert not rsp_data.get("from_") 49 | assert rsp_data["from"] == "bla@foo.com" 50 | 51 | def test_multiple_to(self, provider): 52 | to = ["foo@foo.com", "bar@foo.com"] 53 | rsp = provider.notify(to=to, message="foo", host="goo", username="ding", password="dong") 54 | assert rsp.data["to"] == ",".join(to) 55 | 56 | def test_gmail_negative(self, provider): 57 | data = { 58 | "username": "foo", 59 | "password": "foo", 60 | "to": "foo@foo.com", 61 | "message": "bar", 62 | } 63 | rsp = provider.notify(**data) 64 | with pytest.raises(NotificationError) as e: 65 | rsp.raise_on_errors() 66 | 67 | assert "Username and Password not accepted" in e.value.errors[0] 68 | -------------------------------------------------------------------------------- /tests/providers/test_icloud.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from notifiers.exceptions import BadArguments, NotificationError 4 | 5 | provider = "icloud" 6 | 7 | 8 | class TestiCloud: 9 | """iCloud tests""" 10 | 11 | def test_icloud_metadata(self, provider): 12 | assert provider.metadata == { 13 | "base_url": "smtp.mail.me.com", 14 | "name": "icloud", 15 | "site_url": "https://www.icloud.com/mail", 16 | } 17 | 18 | @pytest.mark.parametrize(("data", "message"), [({}, "message"), ({"message": "foo"}, "to")]) 19 | def test_icloud_missing_required(self, data, message, provider): 20 | data["env_prefix"] = "test" 21 | with pytest.raises(BadArguments) as e: 22 | provider.notify(**data).raise_on_errors() 23 | 24 | assert f"'{message}' is a required property" in e.value.message 25 | 26 | @pytest.mark.online 27 | @pytest.mark.skip(reason="Disabled account") 28 | def test_smtp_sanity(self, provider, test_message): 29 | """using iCloud SMTP""" 30 | data = { 31 | "message": f"{test_message}", 32 | "html": True, 33 | "tls": True, 34 | "port": 587, 35 | } 36 | rsp = provider.notify(**data) 37 | rsp.raise_on_errors() 38 | 39 | def test_email_from_key(self, provider): 40 | rsp = provider.notify( 41 | to="foo@foo.com", 42 | from_="bla@foo.com", 43 | message="foo", 44 | host="goo", 45 | username="ding", 46 | password="dong", 47 | ) 48 | rsp_data = rsp.data 49 | assert not rsp_data.get("from_") 50 | assert rsp_data["from"] == "bla@foo.com" 51 | 52 | def test_multiple_to(self, provider): 53 | to = ["foo@foo.com", "bar@foo.com"] 54 | rsp = provider.notify(to=to, message="foo", host="goo", username="ding", password="dong") 55 | assert rsp.data["to"] == ",".join(to) 56 | 57 | def test_icloud_negative(self, provider): 58 | data = { 59 | "username": "foo", 60 | "password": "foo", 61 | "from": "bla@foo.com", 62 | "to": "foo@foo.com", 63 | "message": "bar", 64 | } 65 | rsp = provider.notify(**data) 66 | with pytest.raises(NotificationError) as e: 67 | rsp.raise_on_errors() 68 | 69 | assert "5.7.8 Error: authentication failed" in e.value.errors[0] 70 | -------------------------------------------------------------------------------- /tests/providers/test_join.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from notifiers.exceptions import BadArguments, NotificationError, ResourceError 4 | 5 | provider = "join" 6 | 7 | 8 | class TestJoin: 9 | def test_metadata(self, provider): 10 | assert provider.metadata == { 11 | "base_url": "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1", 12 | "name": "join", 13 | "site_url": "https://joaoapps.com/join/api/", 14 | } 15 | 16 | @pytest.mark.parametrize(("data", "message"), [({}, "apikey"), ({"apikey": "foo"}, "message")]) 17 | def test_missing_required(self, data, message, provider): 18 | data["env_prefix"] = "test" 19 | with pytest.raises(BadArguments) as e: 20 | provider.notify(**data) 21 | assert f"'{message}' is a required property" in e.value.message 22 | 23 | def test_defaults(self, provider): 24 | assert provider.defaults == {"deviceId": "group.all"} 25 | 26 | @pytest.mark.skip("tests fail due to no device connected") 27 | @pytest.mark.online 28 | def test_sanity(self, provider): 29 | data = {"message": "foo"} 30 | rsp = provider.notify(**data) 31 | rsp.raise_on_errors() 32 | 33 | def test_negative(self, provider): 34 | data = {"message": "foo", "apikey": "bar"} 35 | rsp = provider.notify(**data) 36 | with pytest.raises(NotificationError) as e: 37 | rsp.raise_on_errors() 38 | 39 | assert e.value.errors == ["User Not Authenticated"] 40 | 41 | 42 | class TestJoinDevices: 43 | resource = "devices" 44 | 45 | def test_join_devices_attribs(self, resource): 46 | assert resource.schema == { 47 | "type": "object", 48 | "properties": {"apikey": {"type": "string", "title": "user API key"}}, 49 | "additionalProperties": False, 50 | "required": ["apikey"], 51 | } 52 | 53 | def test_join_devices_negative(self, resource): 54 | with pytest.raises(BadArguments): 55 | resource(env_prefix="foo") 56 | 57 | def test_join_devices_negative_online(self, resource): 58 | with pytest.raises(ResourceError) as e: 59 | resource(apikey="foo") 60 | assert e.value.errors == ["Not Found"] 61 | assert e.value.response.status_code == 404 62 | 63 | 64 | class TestJoinCLI: 65 | """Test Join specific CLI""" 66 | 67 | def test_join_devices_negative(self, cli_runner): 68 | cmd = ["join", "devices", "--apikey", "bad_token"] 69 | result = cli_runner(cmd) 70 | assert result.exit_code 71 | assert not result.output 72 | 73 | @pytest.mark.skip("tests fail due to no device connected") 74 | @pytest.mark.online 75 | def test_join_updates_positive(self, cli_runner): 76 | cmd = ["join", "devices"] 77 | result = cli_runner(cmd) 78 | assert not result.exit_code 79 | replies = ["You have no devices associated with this apikey", "Device name: "] 80 | assert any(reply in result.output for reply in replies) 81 | -------------------------------------------------------------------------------- /tests/providers/test_mailgun.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | from email import utils 4 | 5 | import pytest 6 | 7 | from notifiers.core import FAILURE_STATUS 8 | from notifiers.exceptions import BadArguments 9 | 10 | provider = "mailgun" 11 | 12 | 13 | @pytest.mark.skip(reason="Disabled account") 14 | class TestMailgun: 15 | def test_mailgun_metadata(self, provider): 16 | assert provider.metadata == { 17 | "base_url": "https://api.mailgun.net/v3/{domain}/messages", 18 | "name": "mailgun", 19 | "site_url": "https://documentation.mailgun.com/", 20 | } 21 | 22 | @pytest.mark.parametrize( 23 | ("data", "message"), 24 | [ 25 | ({}, "to"), 26 | ({"to": "foo"}, "domain"), 27 | ({"to": "foo", "domain": "bla"}, "api_key"), 28 | ({"to": "foo", "domain": "bla", "api_key": "bla"}, "from"), 29 | ( 30 | {"to": "foo", "domain": "bla", "api_key": "bla", "from": "bbb"}, 31 | "message", 32 | ), 33 | ], 34 | ) 35 | def test_mailgun_missing_required(self, data, message, provider): 36 | data["env_prefix"] = "test" 37 | with pytest.raises(BadArguments, match=f"'{message}' is a required property"): 38 | provider.notify(**data) 39 | 40 | @pytest.mark.online 41 | def test_mailgun_sanity(self, provider, test_message): 42 | provider.notify(message=test_message, raise_on_errors=True) 43 | 44 | @pytest.mark.online 45 | def test_mailgun_all_options(self, provider, tmpdir, test_message): 46 | dir_ = tmpdir.mkdir("sub") 47 | file_1 = dir_.join("hello.txt") 48 | file_1.write("content") 49 | 50 | file_2 = dir_.join("world.txt") 51 | file_2.write("content") 52 | 53 | now = datetime.datetime.now() + datetime.timedelta(minutes=3) 54 | rfc_2822 = utils.formatdate(time.mktime(now.timetuple())) 55 | data = { 56 | "message": test_message, 57 | "html": f"{now}", 58 | "subject": f"{now}", 59 | "attachment": [file_1.strpath, file_2.strpath], 60 | "inline": [file_1.strpath, file_2.strpath], 61 | "tag": ["foo", "bar"], 62 | "dkim": True, 63 | "deliverytime": rfc_2822, 64 | "testmode": False, 65 | "tracking": True, 66 | "tracking_clicks": "htmlonly", 67 | "tracking_opens": True, 68 | "require_tls": False, 69 | "skip_verification": True, 70 | "headers": {"foo": "bar"}, 71 | "data": {"foo": {"bar": "bla"}}, 72 | } 73 | provider.notify(**data, raise_on_errors=True) 74 | 75 | def test_mailgun_error_response(self, provider): 76 | data = { 77 | "api_key": "FOO", 78 | "message": "bla", 79 | "to": "foo@foo.com", 80 | "domain": "foo", 81 | "from": "foo@foo.com", 82 | } 83 | rsp = provider.notify(**data) 84 | assert rsp.status == FAILURE_STATUS 85 | assert "Forbidden" in rsp.errors 86 | -------------------------------------------------------------------------------- /tests/providers/test_notify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | provider = "notify" 4 | 5 | 6 | class TestNotify: 7 | """ 8 | Notify notifier tests 9 | """ 10 | 11 | @pytest.mark.online 12 | def test_notify_sanity(self, provider, test_message): 13 | """Successful notify notification""" 14 | data = { 15 | "message": test_message, 16 | "title": "test", 17 | "base_url": "https://notify-demo.deno.dev", 18 | "tags": ["test"], 19 | "token": "mypassword", 20 | } 21 | 22 | rsp = provider.notify(**data) 23 | rsp.raise_on_errors() 24 | -------------------------------------------------------------------------------- /tests/providers/test_pagerduty.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from notifiers.exceptions import BadArguments 6 | 7 | provider = "pagerduty" 8 | 9 | 10 | class TestPagerDuty: 11 | def test_pagerduty_metadata(self, provider): 12 | assert provider.metadata == { 13 | "base_url": "https://events.pagerduty.com/v2/enqueue", 14 | "name": "pagerduty", 15 | "site_url": "https://v2.developer.pagerduty.com/", 16 | } 17 | 18 | @pytest.mark.parametrize( 19 | ("data", "message"), 20 | [ 21 | ({}, "routing_key"), 22 | ({"routing_key": "foo"}, "event_action"), 23 | ({"routing_key": "foo", "event_action": "trigger"}, "source"), 24 | ( 25 | {"routing_key": "foo", "event_action": "trigger", "source": "foo"}, 26 | "severity", 27 | ), 28 | ( 29 | { 30 | "routing_key": "foo", 31 | "event_action": "trigger", 32 | "source": "foo", 33 | "severity": "info", 34 | }, 35 | "message", 36 | ), 37 | ], 38 | ) 39 | def test_pagerduty_missing_required(self, data, message, provider): 40 | data["env_prefix"] = "test" 41 | with pytest.raises(BadArguments) as e: 42 | provider.notify(**data) 43 | assert f"'{message}' is a required property" in e.value.message 44 | 45 | @pytest.mark.online 46 | def test_pagerduty_sanity(self, provider, test_message): 47 | data = { 48 | "message": test_message, 49 | "event_action": "trigger", 50 | "source": "foo", 51 | "severity": "info", 52 | } 53 | rsp = provider.notify(**data, raise_on_errors=True) 54 | raw_rsp = rsp.response.json() 55 | del raw_rsp["dedup_key"] 56 | assert raw_rsp == {"status": "success", "message": "Event processed"} 57 | 58 | @pytest.mark.online 59 | def test_pagerduty_all_options(self, provider, test_message): 60 | images = [ 61 | { 62 | "src": "https://software.opensuse.org/package/thumbnail/python-Pillow.png", 63 | "href": "https://github.com/liiight/notifiers", 64 | "alt": "Notifiers", 65 | } 66 | ] 67 | links = [ 68 | { 69 | "href": "https://github.com/notifiers/notifiers", 70 | "text": "Python Notifiers", 71 | } 72 | ] 73 | data = { 74 | "message": test_message, 75 | "event_action": "trigger", 76 | "source": "bar", 77 | "severity": "info", 78 | "timestamp": datetime.datetime.now().isoformat(), 79 | "component": "baz", 80 | "group": "bla", 81 | "class": "buzu", 82 | "custom_details": {"foo": "bar", "boo": "yikes"}, 83 | "images": images, 84 | "links": links, 85 | } 86 | rsp = provider.notify(**data, raise_on_errors=True) 87 | raw_rsp = rsp.response.json() 88 | del raw_rsp["dedup_key"] 89 | assert raw_rsp == {"status": "success", "message": "Event processed"} 90 | -------------------------------------------------------------------------------- /tests/providers/test_popcornnotify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from notifiers.core import FAILURE_STATUS 4 | from notifiers.exceptions import BadArguments, NotificationError 5 | 6 | provider = "popcornnotify" 7 | 8 | 9 | class TestPopcornNotify: 10 | def test_popcornnotify_metadata(self, provider): 11 | assert provider.metadata == { 12 | "base_url": "https://popcornnotify.com/notify", 13 | "name": "popcornnotify", 14 | "site_url": "https://popcornnotify.com/", 15 | } 16 | 17 | @pytest.mark.parametrize( 18 | ("data", "message"), 19 | [ 20 | ({}, "message"), 21 | ({"message": "foo"}, "api_key"), 22 | ({"message": "foo", "api_key": "foo"}, "recipients"), 23 | ], 24 | ) 25 | def test_popcornnotify_missing_required(self, data, message, provider): 26 | data["env_prefix"] = "test" 27 | with pytest.raises(BadArguments) as e: 28 | provider.notify(**data) 29 | assert f"'{message}' is a required property" in e.value.message 30 | 31 | @pytest.mark.online 32 | @pytest.mark.skip("Seems like service is down?") 33 | def test_popcornnotify_sanity(self, provider, test_message): 34 | data = {"message": test_message} 35 | provider.notify(**data, raise_on_errors=True) 36 | 37 | def test_popcornnotify_error(self, provider): 38 | data = {"message": "foo", "api_key": "foo", "recipients": "foo@foo.com"} 39 | rsp = provider.notify(**data) 40 | assert rsp.status == FAILURE_STATUS 41 | error = "Please provide a valid API key" 42 | assert error in rsp.errors 43 | with pytest.raises(NotificationError, match=error): 44 | rsp.raise_on_errors() 45 | -------------------------------------------------------------------------------- /tests/providers/test_pushbullet.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from notifiers.exceptions import BadArguments 6 | 7 | provider = "pushbullet" 8 | 9 | 10 | @pytest.mark.skip(reason="Re-enable once account is activated again") 11 | class TestPushbullet: 12 | def test_metadata(self, provider): 13 | assert provider.metadata == { 14 | "base_url": "https://api.pushbullet.com/v2/pushes", 15 | "name": "pushbullet", 16 | "site_url": "https://www.pushbullet.com", 17 | } 18 | 19 | @pytest.mark.parametrize(("data", "message"), [({}, "message"), ({"message": "foo"}, "token")]) 20 | def test_missing_required(self, data, message, provider): 21 | data["env_prefix"] = "test" 22 | with pytest.raises(BadArguments) as e: 23 | provider.notify(**data) 24 | assert f"'{message}' is a required property" in e.value.message 25 | 26 | @pytest.mark.online 27 | def test_sanity(self, provider, test_message): 28 | data = {"message": test_message} 29 | rsp = provider.notify(**data) 30 | rsp.raise_on_errors() 31 | 32 | @pytest.mark.online 33 | def test_all_options(self, provider, test_message): 34 | data = { 35 | "message": test_message, 36 | "type": "link", 37 | "url": "https://google.com", 38 | "title": "❤", 39 | # todo add the rest 40 | } 41 | rsp = provider.notify(**data) 42 | rsp.raise_on_errors() 43 | 44 | @pytest.mark.online 45 | def test_pushbullet_devices(self, provider): 46 | assert provider.devices() 47 | 48 | 49 | @pytest.mark.skip("Provider resources CLI command are not ready yet") 50 | class TestPushbulletCLI: 51 | """Test Pushbullet specific CLI""" 52 | 53 | def test_pushbullet_devices_negative(self, cli_runner): 54 | cmd = ["pushbullet", "devices", "--token", "bad_token"] 55 | result = cli_runner(cmd) 56 | assert result.exit_code 57 | assert not result.output 58 | 59 | @pytest.mark.online 60 | def test_pushbullet_devices_positive(self, cli_runner): 61 | token = os.environ.get("NOTIFIERS_PUSHBULLET_TOKEN") 62 | assert token 63 | 64 | cmd = f"pushbullet devices --token {token}".split() 65 | result = cli_runner(cmd) 66 | assert not result.exit_code 67 | replies = ["You have no devices associated with this token", "Nickname: "] 68 | assert any(reply in result.output for reply in replies) 69 | -------------------------------------------------------------------------------- /tests/providers/test_pushover.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from notifiers.exceptions import BadArguments, NotificationError 4 | 5 | provider = "pushover" 6 | 7 | 8 | class TestPushover: 9 | """Pushover notifier tests 10 | 11 | Note: These tests assume correct environs set for NOTIFIERS_PUSHOVER_TOKEN and NOTIFIERS_PUSHOVER_USER 12 | """ 13 | 14 | def test_pushover_metadata(self, provider): 15 | assert provider.metadata == { 16 | "base_url": "https://api.pushover.net/1/", 17 | "site_url": "https://pushover.net/", 18 | "name": "pushover", 19 | "message_url": "messages.json", 20 | } 21 | 22 | @pytest.mark.parametrize( 23 | ("data", "message"), 24 | [ 25 | ({}, "user"), 26 | ({"user": "foo"}, "message"), 27 | ({"user": "foo", "message": "bla"}, "token"), 28 | ], 29 | ) 30 | def test_missing_required(self, data, message, provider): 31 | data["env_prefix"] = "test" 32 | with pytest.raises(BadArguments) as e: 33 | provider.notify(**data) 34 | assert f"'{message}' is a required property" in e.value.message 35 | 36 | @pytest.mark.parametrize(("data", "message"), [({}, "expire"), ({"expire": 30}, "retry")]) 37 | @pytest.mark.online 38 | def test_pushover_priority_2_restrictions(self, data, message, provider, test_message): 39 | """Pushover specific API restrictions when using priority 2""" 40 | base_data = {"message": test_message, "priority": 2} 41 | final_data = {**base_data, **data} 42 | rsp = provider.notify(**final_data) 43 | with pytest.raises(NotificationError) as e: 44 | rsp.raise_on_errors() 45 | assert message in e.value.message 46 | 47 | @pytest.mark.online 48 | def test_sanity(self, provider): 49 | """Successful pushover notification""" 50 | data = {"message": "foo"} 51 | rsp = provider.notify(**data) 52 | rsp.raise_on_errors() 53 | 54 | @pytest.mark.online 55 | def test_all_options(self, provider, test_message): 56 | """Use all available pushover options""" 57 | data = { 58 | "message": test_message, 59 | "title": "title", 60 | "priority": 2, 61 | "url": "http://foo.com", 62 | "url_title": "url title", 63 | "sound": "bike", 64 | "timestamp": "0", 65 | "retry": 30, 66 | "expire": 30, 67 | "callback": "http://callback.com", 68 | "html": True, 69 | } 70 | rsp = provider.notify(**data) 71 | rsp.raise_on_errors() 72 | 73 | def test_attachment_negative(self, provider): 74 | data = { 75 | "token": "foo", 76 | "user": "bar", 77 | "message": "baz", 78 | "attachment": "/foo/bar.jpg", 79 | } 80 | with pytest.raises(BadArguments): 81 | provider.notify(**data) 82 | 83 | @pytest.mark.online 84 | def test_attachment_positive(self, provider, tmpdir): 85 | p = tmpdir.mkdir("test").join("image.jpg") 86 | p.write("im binary") 87 | data = {"attachment": p.strpath, "message": "foo"} 88 | rsp = provider.notify(**data) 89 | rsp.raise_on_errors() 90 | 91 | 92 | class TestPushoverSoundsResource: 93 | resource = "sounds" 94 | 95 | def test_pushover_sounds_attribs(self, resource): 96 | assert resource.schema == { 97 | "type": "object", 98 | "properties": {"token": {"type": "string", "title": "your application's API token"}}, 99 | "required": ["token"], 100 | } 101 | 102 | assert resource.name == provider 103 | 104 | def test_pushover_sounds_negative(self, resource): 105 | with pytest.raises(BadArguments): 106 | resource(env_prefix="foo") 107 | 108 | @pytest.mark.online 109 | def test_pushover_sounds_positive(self, resource): 110 | assert isinstance(resource(), list) 111 | 112 | 113 | class TestPushoverLimitsResource: 114 | resource = "limits" 115 | 116 | def test_pushover_limits_attribs(self, resource): 117 | assert resource.schema == { 118 | "type": "object", 119 | "properties": {"token": {"type": "string", "title": "your application's API token"}}, 120 | "required": ["token"], 121 | } 122 | 123 | assert resource.name == provider 124 | 125 | def test_pushover_limits_negative(self, resource): 126 | with pytest.raises(BadArguments): 127 | resource(env_prefix="foo") 128 | 129 | @pytest.mark.online 130 | def test_pushover_limits_positive(self, resource): 131 | assert isinstance(resource(), dict) 132 | assert all(key in resource() for key in ["limit", "remaining", "reset"]) 133 | 134 | 135 | class TestPushoverCLI: 136 | def test_pushover_sounds_negative(self, cli_runner): 137 | cmd = ["pushover", "sounds", "--token", "bad_token"] 138 | result = cli_runner(cmd) 139 | assert result.exit_code 140 | assert not result.output 141 | 142 | @pytest.mark.online 143 | def test_pushover_sounds_positive(self, cli_runner): 144 | cmd = ["pushover", "sounds"] 145 | result = cli_runner(cmd) 146 | assert not result.exit_code 147 | assert "piano" in result.output 148 | 149 | def test_pushover_limits(self, cli_runner): 150 | cmd = ["pushover", "limits", "--token", "bad_token"] 151 | result = cli_runner(cmd) 152 | assert result.exit_code 153 | assert not result.output 154 | 155 | @pytest.mark.online 156 | def test_pushover_limits_positive(self, cli_runner): 157 | cmd = ["pushover", "limits"] 158 | result = cli_runner(cmd) 159 | assert not result.exit_code 160 | assert all(key in result.output for key in ["limit", "remaining", "reset"]) 161 | -------------------------------------------------------------------------------- /tests/providers/test_simplepush.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from notifiers.exceptions import BadArguments 4 | 5 | provider = "simplepush" 6 | 7 | 8 | class TestSimplePush: 9 | """SimplePush notifier tests 10 | 11 | Note: These tests assume correct environs set for NOTIFIERS_SIMPLEPUSH_KEY 12 | """ 13 | 14 | def test_simplepush_metadata(self, provider): 15 | assert provider.metadata == { 16 | "base_url": "https://api.simplepush.io/send", 17 | "site_url": "https://simplepush.io/", 18 | "name": "simplepush", 19 | } 20 | 21 | @pytest.mark.parametrize(("data", "message"), [({}, "key"), ({"key": "foo"}, "message")]) 22 | def test_simplepush_missing_required(self, data, message, provider): 23 | data["env_prefix"] = "test" 24 | with pytest.raises(BadArguments) as e: 25 | provider.notify(**data) 26 | assert f"'{message}' is a required property" in e.value.message 27 | 28 | @pytest.mark.online 29 | def test_simplepush_sanity(self, provider, test_message): 30 | """Successful simplepush notification""" 31 | data = {"message": test_message} 32 | rsp = provider.notify(**data) 33 | rsp.raise_on_errors() 34 | -------------------------------------------------------------------------------- /tests/providers/test_slack.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | provider = "slack" 4 | 5 | 6 | class TestSlack: 7 | """ 8 | Slack web hook tests 9 | 10 | Online test rely on setting the env variable NOTIFIERS_SLACK_WEBHOOK_URL 11 | """ 12 | 13 | def test_slack_metadata(self, provider): 14 | assert provider.metadata == { 15 | "base_url": "https://hooks.slack.com/services/", 16 | "name": "slack", 17 | "site_url": "https://api.slack.com/incoming-webhooks", 18 | } 19 | 20 | @pytest.mark.online 21 | def test_sanity(self, provider, test_message): 22 | data = {"message": test_message} 23 | rsp = provider.notify(**data) 24 | rsp.raise_on_errors() 25 | 26 | @pytest.mark.online 27 | def test_all_options(self, provider): 28 | data = { 29 | "message": "http://foo.com", 30 | "icon_emoji": "poop", 31 | "username": "test", 32 | "channel": "test", 33 | "attachments": [ 34 | { 35 | "title": "attachment 1", 36 | "title_link": "https://github.com/liiight/notifiers", 37 | "image_url": "https://cdn.worldvectorlogo.com/logos/slack.svg", 38 | "thumb_url": "http://timelinethumbnailcreator.com/img/icon-brush-256.png", 39 | "author_name": "notifiers", 40 | "author_link": "https://github.com/liiight/notifiers", 41 | "fallback": "fallback text", 42 | "text": "attach this", 43 | "footer": "footer 1", 44 | "pretext": "pre-attach this", 45 | "color": "good", 46 | "fields": [ 47 | { 48 | "title": "test_field1", 49 | "value": "test_value1", 50 | "short": False, 51 | }, 52 | {"title": "test_field2", "value": "test_value2", "short": True}, 53 | ], 54 | }, 55 | { 56 | "title": "attachment 2", 57 | "title_link": "https://github.com/liiight/notifiers", 58 | "image_url": "https://cdn.worldvectorlogo.com/logos/slack.svg", 59 | "thumb_url": "http://timelinethumbnailcreator.com/img/icon-brush-256.png", 60 | "author_name": "notifiers", 61 | "author_link": "https://github.com/liiight/notifiers", 62 | "fallback": "fallback text", 63 | "text": "attach this", 64 | "footer": "footer 1", 65 | "pretext": "pre-attach this", 66 | "color": "danger", 67 | "fields": [ 68 | { 69 | "title": "test_field1", 70 | "value": "test_value1", 71 | "short": False, 72 | }, 73 | {"title": "test_field2", "value": "test_value2", "short": True}, 74 | ], 75 | }, 76 | ], 77 | } 78 | rsp = provider.notify(**data) 79 | rsp.raise_on_errors() 80 | -------------------------------------------------------------------------------- /tests/providers/test_smtp.py: -------------------------------------------------------------------------------- 1 | from email.message import EmailMessage 2 | 3 | import pytest 4 | 5 | from notifiers.exceptions import BadArguments, NotificationError 6 | 7 | provider = "email" 8 | 9 | 10 | class TestSMTP: 11 | """SMTP tests""" 12 | 13 | def test_smtp_metadata(self, provider): 14 | assert provider.metadata == { 15 | "base_url": None, 16 | "name": "email", 17 | "site_url": "https://en.wikipedia.org/wiki/Email", 18 | } 19 | 20 | @pytest.mark.parametrize(("data", "message"), [({}, "message"), ({"message": "foo"}, "to")]) 21 | def test_smtp_missing_required(self, data, message, provider): 22 | data["env_prefix"] = "test" 23 | with pytest.raises(BadArguments) as e: 24 | provider.notify(**data) 25 | assert f"'{message}' is a required property" in e.value.message 26 | 27 | def test_smtp_no_host(self, provider): 28 | data = { 29 | "to": "foo@foo.com", 30 | "message": "bar", 31 | "host": "nohost", 32 | "username": "ding", 33 | "password": "dong", 34 | } 35 | rsp = provider.notify(**data) 36 | with pytest.raises(NotificationError) as e: 37 | rsp.raise_on_errors() 38 | 39 | possible_errors = "Errno 111", "Errno 61", "Errno 8", "Errno -2", "Errno -3" 40 | assert any(error in e.value.message for error in possible_errors), f"Error not in expected errors; {e.value.message}" 41 | assert any(error in rsp_error for rsp_error in rsp.errors for error in possible_errors), f"Error not in expected errors; {rsp.errors}" 42 | 43 | def test_email_from_key(self, provider): 44 | rsp = provider.notify( 45 | to="foo@foo.co ", 46 | from_="bla@foo.com", 47 | message="foo", 48 | host="nohost", 49 | username="ding", 50 | password="dong", 51 | ) 52 | rsp_data = rsp.data 53 | assert not rsp_data.get("from_") 54 | assert rsp_data["from"] == "bla@foo.com" 55 | 56 | def test_multiple_to(self, provider): 57 | to = ["foo@foo.com", "bar@foo.com"] 58 | rsp = provider.notify(to=to, message="foo", host="nohost", username="ding", password="dong") 59 | assert rsp.data["to"] == ",".join(to) 60 | 61 | def test_attachment(self, provider, tmpdir): 62 | dir_ = tmpdir.mkdir("sub") 63 | file_1 = dir_.join("foo.txt") 64 | file_1.write("foo") 65 | file_2 = dir_.join("bar.txt") 66 | file_2.write("foo") 67 | file_3 = dir_.join("baz.txt") 68 | file_3.write("foo") 69 | attachments = [str(file_1), str(file_2), str(file_3)] 70 | rsp = provider.notify( 71 | to=["foo@foo.com"], 72 | message="bar", 73 | attachments=attachments, 74 | host="nohost", 75 | username="ding", 76 | password="dong", 77 | ) 78 | assert rsp.data["attachments"] == attachments 79 | 80 | def test_attachment_mimetypes(self, provider, tmpdir): 81 | dir_ = tmpdir.mkdir("sub") 82 | file_1 = dir_.join("foo.txt") 83 | file_1.write("foo") 84 | file_2 = dir_.join("bar.jpg") 85 | file_2.write("foo") 86 | file_3 = dir_.join("baz.pdf") 87 | file_3.write("foo") 88 | attachments = [str(file_1), str(file_2), str(file_3)] 89 | email = EmailMessage() 90 | provider._add_attachments(attachments=attachments, email=email) 91 | attach1, attach2, attach3 = email.iter_attachments() 92 | assert attach1.get_content_type() == "text/plain" 93 | assert attach2.get_content_type() == "image/jpeg" 94 | assert attach3.get_content_type() == "application/pdf" 95 | 96 | @pytest.mark.online 97 | @pytest.mark.skip(reason="Disabled account") 98 | def test_smtp_sanity(self, provider, test_message): 99 | """using Gmail SMTP""" 100 | data = { 101 | "message": f"{test_message}", 102 | "host": "smtp.gmail.com", 103 | "port": 465, 104 | "ssl": True, 105 | "html": True, 106 | } 107 | rsp = provider.notify(**data) 108 | rsp.raise_on_errors() 109 | -------------------------------------------------------------------------------- /tests/providers/test_telegram.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from retry import retry 5 | 6 | from notifiers.exceptions import BadArguments, NotificationError 7 | 8 | provider = "telegram" 9 | 10 | 11 | class TestTelegram: 12 | """Telegram related tests""" 13 | 14 | def test_metadata(self, provider): 15 | assert provider.metadata == { 16 | "base_url": "https://api.telegram.org/bot{token}", 17 | "name": "telegram", 18 | "site_url": "https://core.telegram.org/", 19 | } 20 | 21 | @pytest.mark.parametrize( 22 | ("data", "message"), 23 | [ 24 | ({}, "message"), 25 | ({"message": "foo"}, "chat_id"), 26 | ({"message": "foo", "chat_id": 1}, "token"), 27 | ], 28 | ) 29 | def test_missing_required(self, data, message, provider): 30 | data["env_prefix"] = "test" 31 | with pytest.raises(BadArguments) as e: 32 | provider.notify(**data) 33 | assert f"'{message}' is a required property" in e.value.message 34 | 35 | def test_bad_token(self, provider): 36 | data = {"token": "foo", "chat_id": 1, "message": "foo"} 37 | with pytest.raises(NotificationError) as e: 38 | provider.notify(**data).raise_on_errors() 39 | assert "Not Found" in e.value.message 40 | 41 | @pytest.mark.online 42 | def test_missing_chat_id(self, provider): 43 | data = {"chat_id": 1, "message": "foo"} 44 | with pytest.raises(NotificationError) as e: 45 | provider.notify(**data).raise_on_errors() 46 | assert "chat not found" in e.value.message 47 | 48 | @pytest.mark.online 49 | def test_sanity(self, provider, test_message): 50 | rsp = provider.notify(message=test_message) 51 | rsp.raise_on_errors() 52 | 53 | @pytest.mark.online 54 | def test_all_options(self, provider, test_message): 55 | data = { 56 | "parse_mode": "markdown", 57 | "disable_web_page_preview": True, 58 | "disable_notification": True, 59 | "message": test_message, 60 | } 61 | rsp = provider.notify(**data) 62 | rsp.raise_on_errors() 63 | 64 | 65 | class TestTelegramResources: 66 | resource = "updates" 67 | 68 | def test_telegram_updates_attribs(self, resource): 69 | assert resource.schema == { 70 | "additionalProperties": False, 71 | "properties": {"token": {"title": "Bot token", "type": "string"}}, 72 | "required": ["token"], 73 | "type": "object", 74 | } 75 | assert resource.name == provider 76 | assert resource.required == {"required": ["token"]} 77 | 78 | def test_telegram_updates_negative(self, resource): 79 | with pytest.raises(BadArguments): 80 | resource(env_prefix="foo") 81 | 82 | @pytest.mark.online 83 | def test_telegram_updates_positive(self, resource): 84 | rsp = resource() 85 | assert isinstance(rsp, list) 86 | 87 | 88 | class TestTelegramCLI: 89 | """Test telegram specific CLI""" 90 | 91 | def test_telegram_updates_negative(self, cli_runner): 92 | cmd = ["telegram", "updates", "--token", "bad_token"] 93 | result = cli_runner(cmd) 94 | assert result.exit_code 95 | assert not result.output 96 | 97 | @pytest.mark.online 98 | @retry(AssertionError, tries=3, delay=10) 99 | def test_telegram_updates_positive(self, cli_runner): 100 | cmd = ["telegram", "updates"] 101 | result = cli_runner(cmd) 102 | assert not result.exit_code 103 | reply = json.loads(result.output) 104 | assert reply == [] or reply[0]["message"]["chat"]["id"] 105 | -------------------------------------------------------------------------------- /tests/providers/test_twilio.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | provider = "twilio" 4 | 5 | 6 | class TestTwilio: 7 | def test_twilio_metadata(self, provider): 8 | assert provider.metadata == { 9 | "base_url": "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json", 10 | "name": "twilio", 11 | "site_url": "https://www.twilio.com/", 12 | } 13 | 14 | @pytest.mark.online 15 | def test_sanity(self, provider, test_message): 16 | data = {"message": test_message} 17 | provider.notify(**data, raise_on_errors=True) 18 | -------------------------------------------------------------------------------- /tests/providers/test_victorops.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | provider = "victorops" 4 | 5 | 6 | class TestVicrotops: 7 | """ 8 | Victorops rest alert tests 9 | Online test rely on setting the env variable VICTOROPS_REST_URL 10 | """ 11 | 12 | @pytest.mark.skip("Skipping until obtaining a permanent key") 13 | @pytest.mark.online 14 | def test_all_options(self, provider): 15 | data = { 16 | "message_type": "info", 17 | "entity_id": "BA tesing", 18 | "entity_display_name": "message test header", 19 | "message": "text in body", 20 | "annotations": { 21 | "vo_annotate.i.Graph": "https://shorturl.at/dAQ28", 22 | "vo_annotate.s.Note": "'You can't have everything. Where would you put it?' Steven Wright", 23 | "vo_annotate.u.Runbook": "https://giphy.com/gifs/win-xNBcChLQt7s9a/fullscreen", 24 | }, 25 | "additional_keys": { 26 | "foo": "this is a custom fields", 27 | "monitoring_tool": "this is an official victorops fields", 28 | }, 29 | } 30 | rsp = provider.notify(**data) 31 | assert rsp.ok 32 | assert rsp.status == "Success" 33 | assert rsp.errors is None 34 | -------------------------------------------------------------------------------- /tests/providers/test_zulip.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from notifiers.exceptions import BadArguments, NotifierException 6 | 7 | provider = "zulip" 8 | 9 | 10 | class TestZulip: 11 | def test_metadata(self, provider): 12 | assert provider.metadata == { 13 | "base_url": "https://{domain}.zulipchat.com", 14 | "site_url": "https://zulipchat.com/api/", 15 | "name": "zulip", 16 | } 17 | 18 | @pytest.mark.parametrize( 19 | ("data", "message"), 20 | [ 21 | ( 22 | {"email": "foo", "api_key": "bar", "message": "boo", "to": "bla"}, 23 | "domain", 24 | ), 25 | ( 26 | { 27 | "email": "foo", 28 | "api_key": "bar", 29 | "message": "boo", 30 | "to": "bla", 31 | "domain": "bla", 32 | "server": "fop", 33 | }, 34 | "Only one of 'domain' or 'server' is allowed", 35 | ), 36 | ], 37 | ) 38 | def test_missing_required(self, data, message, provider): 39 | data["env_prefix"] = "test" 40 | with pytest.raises(BadArguments) as e: 41 | provider.notify(**data) 42 | assert message in e.value.message 43 | 44 | @pytest.mark.online 45 | def test_sanity(self, provider, test_message): 46 | data = { 47 | "to": "general", 48 | "message": test_message, 49 | "domain": "notifiers", 50 | "subject": "test", 51 | } 52 | rsp = provider.notify(**data) 53 | rsp.raise_on_errors() 54 | 55 | @pytest.mark.online 56 | def test_private_message(self, provider): 57 | data = { 58 | "message": str(datetime.datetime.now()), 59 | "domain": "notifiers", 60 | "type": "private", 61 | } 62 | rsp = provider.notify(**data) 63 | rsp.raise_on_errors() 64 | 65 | def test_zulip_type_key(self, provider): 66 | rsp = provider.notify( 67 | email="foo@foo.com", 68 | api_key="bar", 69 | to="baz", 70 | domain="bla", 71 | type_="private", 72 | message="foo", 73 | subject="foo", 74 | ) 75 | rsp_data = rsp.data 76 | assert not rsp_data.get("type_") 77 | assert rsp_data["type"] == "private" 78 | 79 | def test_zulip_missing_subject(self, provider): 80 | with pytest.raises(NotifierException) as e: 81 | provider.notify( 82 | email="foo@foo.com", 83 | api_key="bar", 84 | to="baz@foo.com", 85 | domain="bla", 86 | type_="stream", 87 | message="foo", 88 | ) 89 | assert "'subject' is required when 'type' is 'stream'" in e.value.message 90 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | import notifiers 6 | 7 | mock_name = "mock_provider" 8 | 9 | 10 | @pytest.mark.usefixtures("mock_provider") 11 | class TestCLI: 12 | """CLI tests""" 13 | 14 | def test_bad_notify(self, cli_runner): 15 | """Test invalid notification usage""" 16 | cmd = f"{mock_name} notify".split() 17 | result = cli_runner(cmd) 18 | assert result.exit_code 19 | assert result.exception 20 | 21 | def test_notify_sanity(self, cli_runner): 22 | """Test valid notification usage""" 23 | cmd = f"{mock_name} notify --required bar foo".split() 24 | result = cli_runner(cmd) 25 | assert not result.exit_code, f"Exit code is {result.exit_code}. Output: {result.output}" 26 | assert "Succesfully sent a notification" in result.output 27 | 28 | def test_providers(self, cli_runner): 29 | """Test providers command""" 30 | result = cli_runner(["providers"]) 31 | assert not result.exit_code 32 | assert "mock" in result.output 33 | 34 | def test_metadata(self, cli_runner): 35 | """Test metadata command""" 36 | cmd = f"{mock_name} metadata".split() 37 | result = cli_runner(cmd) 38 | assert not result.exit_code 39 | assert '"base_url": "https://api.mock.com"' in result.output 40 | assert '"site_url": "https://www.mock.com"' in result.output 41 | assert '"name": "mock_provider"' in result.output 42 | 43 | def test_required(self, cli_runner): 44 | """Test required command""" 45 | cmd = f"{mock_name} required".split() 46 | result = cli_runner(cmd) 47 | assert not result.exit_code 48 | assert "required" in result.output 49 | 50 | def test_help(self, cli_runner): 51 | """Test help command""" 52 | cmd = f"{mock_name} notify --help".split() 53 | result = cli_runner(cmd) 54 | assert not result.exit_code 55 | assert "--required" in result.output 56 | assert "--not-required" in result.output 57 | assert "MESSAGE" in result.output 58 | 59 | def test_no_defaults(self, cli_runner): 60 | """Test defaults command""" 61 | cmd = ["pushover", "defaults"] 62 | result = cli_runner(cmd) 63 | assert not result.exit_code 64 | assert "{}" in result.output 65 | 66 | def test_defaults(self, cli_runner): 67 | """Test defaults command""" 68 | cmd = f"{mock_name} defaults".split() 69 | result = cli_runner(cmd) 70 | assert not result.exit_code 71 | assert '"option_with_default": "foo"' in result.output 72 | 73 | def test_resources(self, cli_runner): 74 | cmd = f"{mock_name} resources".split() 75 | result = cli_runner(cmd) 76 | assert not result.exit_code 77 | assert "mock_rsrc" in result.output 78 | 79 | def test_piping_input(self, cli_runner): 80 | """Test piping in message""" 81 | cmd = f"{mock_name} notify --required foo".split() 82 | result = cli_runner(cmd, input="bar") 83 | assert not result.exit_code 84 | assert "Succesfully sent a notification" in result.output 85 | 86 | @pytest.mark.parametrize( 87 | ("prefix", "command"), 88 | [ 89 | (None, f"{mock_name} notify".split()), 90 | ("FOO_", f"--env-prefix FOO_ {mock_name} notify".split()), 91 | ], 92 | ) 93 | def test_environ(self, prefix, command, monkeypatch, cli_runner): 94 | """Test provider environ usage""" 95 | prefix = prefix if prefix else "NOTIFIERS_" 96 | monkeypatch.setenv(f"{prefix}MOCK_PROVIDER_REQUIRED", "foo") 97 | monkeypatch.setenv(f"{prefix}MOCK_PROVIDER_MESSAGE", "foo") 98 | result = cli_runner(command) 99 | assert not result.exit_code 100 | assert "Succesfully sent a notification" in result.output 101 | 102 | def test_version_command(self, cli_runner): 103 | result = cli_runner(["--version"]) 104 | assert not result.exit_code 105 | version_re = re.search(r"(\d+\.\d+\.\d+)", result.output) 106 | assert version_re 107 | assert version_re.group(1) == notifiers.__version__ 108 | 109 | def test_multiple_option(self, cli_runner): 110 | cmd = f"{mock_name} notify --required foo --not-required baz --not-required piz bar" 111 | result = cli_runner(cmd.split()) 112 | assert not result.exit_code 113 | assert "Succesfully sent a notification" in result.output 114 | -------------------------------------------------------------------------------- /tests/test_json_schema.py: -------------------------------------------------------------------------------- 1 | import hypothesis.strategies as st 2 | import pytest 3 | from hypothesis import given 4 | from jsonschema import ValidationError, validate 5 | 6 | from notifiers.utils.schema.formats import format_checker 7 | from notifiers.utils.schema.helpers import list_to_commas, one_or_more 8 | 9 | 10 | class TestFormats: 11 | @pytest.mark.parametrize( 12 | ("formatter", "value"), 13 | [ 14 | ("iso8601", "2018-07-15T07:39:59+00:00"), 15 | ("iso8601", "2018-07-15T07:39:59Z"), 16 | ("iso8601", "20180715T073959Z"), 17 | ("rfc2822", "Thu, 25 Dec 1975 14:15:16 -0500"), 18 | ("ascii", "foo"), 19 | ("port", "44444"), 20 | ("port", 44_444), 21 | ("timestamp", 1531644024), 22 | ("timestamp", "1531644024"), 23 | ("e164", "+14155552671"), 24 | ("e164", "+442071838750"), 25 | ("e164", "+551155256325"), 26 | ], 27 | ) 28 | def test_format_positive(self, formatter, value): 29 | validate(value, {"format": formatter}, format_checker=format_checker) 30 | 31 | def test_valid_file_format(self, tmpdir): 32 | file_1 = tmpdir.mkdir("foo").join("file_1") 33 | file_1.write("bar") 34 | 35 | validate(str(file_1), {"format": "valid_file"}, format_checker=format_checker) 36 | 37 | @pytest.mark.parametrize( 38 | ("formatter", "value"), 39 | [ 40 | ("iso8601", "2018-14-15T07:39:59+00:00"), 41 | ("iso8601", "2018-07-15T07:39:59Z~"), 42 | ("iso8601", "20180715T0739545639Z"), 43 | ("rfc2822", "Thu 25 Dec14:15:16 -0500"), 44 | ("ascii", "פו"), 45 | ("port", "70000"), 46 | ("port", 70_000), 47 | ("timestamp", "15565-5631644024"), 48 | ("timestamp", "155655631644024"), 49 | ("e164", "-14155552671"), 50 | ("e164", "+44207183875063673465"), 51 | ("e164", "+551155256325zdfgsd"), 52 | ], 53 | ) 54 | def test_format_negative(self, formatter, value): 55 | with pytest.raises(ValidationError): 56 | validate(value, {"format": formatter}, format_checker=format_checker) 57 | 58 | 59 | class TestSchemaUtils: 60 | @pytest.mark.parametrize( 61 | ("input_schema", "unique_items", "min", "max", "data"), 62 | [ 63 | ({"type": "string"}, True, 1, 1, "foo"), 64 | ({"type": "string"}, True, 1, 2, ["foo", "bar"]), 65 | ({"type": "integer"}, True, 1, 2, 1), 66 | ({"type": "integer"}, True, 1, 2, [1, 2]), 67 | ], 68 | ) 69 | def test_one_or_more_positive(self, input_schema, unique_items, min, max, data): 70 | expected_schema = one_or_more(input_schema, unique_items, min, max) 71 | validate(data, expected_schema) 72 | 73 | @pytest.mark.parametrize( 74 | ("input_schema", "unique_items", "min", "max", "data"), 75 | [ 76 | ({"type": "string"}, True, 1, 1, 1), 77 | ({"type": "string"}, True, 1, 1, ["foo", "bar"]), 78 | ({"type": "integer"}, False, 3, None, [1, 1]), 79 | ({"type": "integer"}, True, 1, 1, [1, 2]), 80 | ], 81 | ) 82 | def test_one_or_more_negative(self, input_schema, unique_items, min, max, data): 83 | expected_schema = one_or_more(input_schema, unique_items, min, max) 84 | with pytest.raises(ValidationError): 85 | validate(data, expected_schema) 86 | 87 | @given(st.lists(st.text())) 88 | def test_list_to_commas(self, input_data): 89 | assert list_to_commas(input_data) == ",".join(input_data) 90 | -------------------------------------------------------------------------------- /tests/test_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from notifiers.exceptions import NoSuchNotifierError 6 | 7 | log = logging.getLogger("test_logger") 8 | 9 | 10 | class TestLogger: 11 | def test_with_error(self, mock_provider, handler, capsys): 12 | hdlr = handler(mock_provider.name, logging.INFO) 13 | log.addHandler(hdlr) 14 | 15 | log.info("test") 16 | assert "--- Logging error ---" in capsys.readouterr().err 17 | 18 | def test_missing_provider(self, handler): 19 | with pytest.raises(NoSuchNotifierError): 20 | handler("foo", logging.INFO) 21 | 22 | def test_valid_logging(self, magic_mock_provider, handler): 23 | hdlr = handler(magic_mock_provider.name, logging.INFO) 24 | log.addHandler(hdlr) 25 | assert repr(hdlr) == "" 26 | 27 | log.info("test") 28 | magic_mock_provider.notify.assert_called() 29 | 30 | def test_lower_level_log(self, magic_mock_provider, handler): 31 | hdlr = handler(magic_mock_provider.name, logging.INFO) 32 | log.addHandler(hdlr) 33 | 34 | log.debug("test") 35 | magic_mock_provider.notify.assert_not_called() 36 | 37 | def test_with_data(self, magic_mock_provider, handler): 38 | data = {"foo": "bar"} 39 | hdlr = handler(magic_mock_provider.name, logging.INFO, data) 40 | log.addHandler(hdlr) 41 | 42 | log.info("test") 43 | magic_mock_provider.notify.assert_called_with(foo="bar", message="test", raise_on_errors=True) 44 | 45 | def test_with_fallback(self, magic_mock_provider, handler): 46 | data = {"env_prefix": "foo"} 47 | hdlr = handler("pushover", logging.INFO, data, fallback=magic_mock_provider.name) 48 | log.addHandler(hdlr) 49 | log.info("test") 50 | 51 | magic_mock_provider.notify.assert_called_with(message="Could not log msg to provider 'pushover'!\nError with sent data: 'user' is a required property") 52 | 53 | def test_with_fallback_with_defaults(self, magic_mock_provider, handler): 54 | fallback_defaults = {"foo": "bar"} 55 | data = {"env_prefix": "foo"} 56 | hdlr = handler( 57 | "pushover", 58 | logging.INFO, 59 | data, 60 | fallback=magic_mock_provider.name, 61 | fallback_defaults=fallback_defaults, 62 | ) 63 | log.addHandler(hdlr) 64 | log.info("test") 65 | 66 | magic_mock_provider.notify.assert_called_with( 67 | foo="bar", 68 | message="Could not log msg to provider 'pushover'!\nError with sent data: 'user' is a required property", 69 | ) 70 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from notifiers.utils.helpers import ( 4 | dict_from_environs, 5 | merge_dicts, 6 | snake_to_camel_case, 7 | text_to_bool, 8 | valid_file, 9 | ) 10 | from notifiers.utils.requests import file_list_for_request 11 | 12 | 13 | class TestHelpers: 14 | @pytest.mark.parametrize( 15 | ("text", "result"), 16 | [ 17 | ("y", True), 18 | ("yes", True), 19 | ("true", True), 20 | ("on", True), 21 | ("no", False), 22 | ("off", False), 23 | ("false", False), 24 | ("0", False), 25 | ("foo", False), 26 | ("bla", False), 27 | ], 28 | ) 29 | def test_text_to_bool(self, text, result): 30 | assert text_to_bool(text) is result 31 | 32 | @pytest.mark.parametrize( 33 | ("target_dict", "merge_dict", "result"), 34 | [ 35 | ({"a": "foo"}, {"b": "bar"}, {"a": "foo", "b": "bar"}), 36 | ({"a": "foo"}, {"a": "bar"}, {"a": "foo"}), 37 | ], 38 | ) 39 | def test_merge_dict(self, target_dict, merge_dict, result): 40 | assert merge_dicts(target_dict, merge_dict) == result 41 | 42 | @pytest.mark.parametrize( 43 | ("prefix", "name", "args", "result"), 44 | [("foo", "bar", ["key1", "key2"], {"key1": "baz", "key2": "baz"})], 45 | ) 46 | def test_dict_from_environs(self, prefix, name, args, result, monkeypatch): 47 | for arg in args: 48 | environ = f"{prefix}{name}_{arg}".upper() 49 | monkeypatch.setenv(environ, "baz") 50 | assert dict_from_environs(prefix, name, args) == result 51 | 52 | @pytest.mark.parametrize( 53 | ("snake_value", "cc_value"), 54 | [ 55 | ("foo_bar", "FooBar"), 56 | ("foo", "Foo"), 57 | ("long_ass_var_name", "LongAssVarName"), 58 | ], 59 | ) 60 | def test_snake_to_camel_case(self, snake_value, cc_value): 61 | assert snake_to_camel_case(snake_value) == cc_value 62 | 63 | def test_valid_file(self, tmpdir): 64 | dir_ = str(tmpdir) 65 | 66 | file = tmpdir.join("foo.txt") 67 | file.write("foo") 68 | file = str(file) 69 | 70 | no_file = "foo" 71 | 72 | assert valid_file(file) 73 | assert not valid_file(dir_) 74 | assert not valid_file(no_file) 75 | 76 | def test_file_list_for_request(self, tmpdir): 77 | file_1 = tmpdir.join("file_1") 78 | file_2 = tmpdir.join("file_2") 79 | 80 | file_1.write("foo") 81 | file_2.write("foo") 82 | 83 | file_list = file_list_for_request([file_1, file_2], "foo") 84 | assert len(file_list) == 2 85 | assert all(len(member[1]) == 2 for member in file_list) 86 | 87 | file_list_2 = file_list_for_request([file_1, file_2], "foo", "foo_mimetype") 88 | assert len(file_list_2) == 2 89 | assert all(len(member[1]) == 3 for member in file_list_2) 90 | --------------------------------------------------------------------------------