pax_global_header00006660000000000000000000000064152416117520014516gustar00rootroot0000000000000052 comment=cf17bc9ecd2686c62b548b4577b6e06839331a60 caronc-apprise-182f859/000077500000000000000000000000001524161175200147145ustar00rootroot00000000000000caronc-apprise-182f859/.codecov.yml000066400000000000000000000005221524161175200171360ustar00rootroot00000000000000codecov: require_ci_to_pass: false comment: layout: "diff, flags, files" behavior: default require_changes: false # if true: only post the comment if coverage changes coverage: status: project: default: target: auto threshold: 1% patch: default: target: auto threshold: 1% caronc-apprise-182f859/.env000066400000000000000000000000641524161175200155050ustar00rootroot00000000000000LANG=C.UTF-8 PYTHONPATH=. PYTHONDONTWRITEBYTECODE=1 caronc-apprise-182f859/.github/000077500000000000000000000000001524161175200162545ustar00rootroot00000000000000caronc-apprise-182f859/.github/FUNDING.yml000066400000000000000000000001331524161175200200660ustar00rootroot00000000000000github: caronc custom: - 'https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E' caronc-apprise-182f859/.github/ISSUE_TEMPLATE/000077500000000000000000000000001524161175200204375ustar00rootroot00000000000000caronc-apprise-182f859/.github/ISSUE_TEMPLATE/1_bug_report.md000066400000000000000000000017601524161175200233550ustar00rootroot00000000000000--- name: πŸ› Bug Report about: Report errors and problems in Apprise title: '' labels: 'bug' assignees: '' --- ## Notification Service(s) Impacted ## What happened ## Apprise URL(s) involved (redact secrets) ## Steps to reproduce 1. 2. 3. ## Environment - Apprise version: - Python version: - OS and distribution: - Install method: - If using Docker: image/tag: ## Logs (redact secrets) ```text paste logs here ``` caronc-apprise-182f859/.github/ISSUE_TEMPLATE/2_enhancement_request.md000066400000000000000000000014051524161175200252370ustar00rootroot00000000000000--- name: πŸ’‘ Enhancement Request about: Suggest an improvement to Apprise title: '' labels: 'enhancement' assignees: '' --- ## The idea ## Use-case ## Proposed change ## Compatibility impact - Would this be a breaking change? Yes / No - If yes, describe what breaks and any migration path. ## Alternatives considered ## Documentation impact - Does appriseit.com require updates for this change? Yes / No - If yes, please also open (or link) a documentation ticket/PR in apprise-docs. ## Additional context caronc-apprise-182f859/.github/ISSUE_TEMPLATE/3_new-notification-request.md000066400000000000000000000023471524161175200261540ustar00rootroot00000000000000--- name: πŸ“£ New Notification Request about: Request a new notification service integration title: '' labels: ['enhancement', 'new-notification'] assignees: '' --- ## What is the name of the service? ## Proposed Apprise schema (service id) ## Proposed Appriseit service slug ## Provide details that help development - Homepage: - Official API docs: - Authentication method: - Rate limits (if known): - Message limits (if known): - Attachments supported: Yes / No / Unknown ## Example payload or curl snippet (optional) ```text paste example here ``` ## Anything else? ## ☝️ Documentation note If this integration is accepted, it must also include an apprise-docs update so the service page exists on appriseit.com. If you can contribute docs, open a ticket or PR in: https://github.com/caronc/apprise-docs caronc-apprise-182f859/.github/ISSUE_TEMPLATE/4_question.md000066400000000000000000000006631524161175200230600ustar00rootroot00000000000000--- name: ❓ Support Question about: Ask a question about Apprise (prefer Discussions) title: '' labels: 'question' assignees: '' --- ## Please use Discussions for support questions https://github.com/caronc/apprise/discussions If you are filing an issue anyway, include: ## Question ## Apprise version and environment - Apprise version: - Python version: - OS and distribution: - Install method: caronc-apprise-182f859/.github/ISSUE_TEMPLATE/config.yaml000066400000000000000000000007201524161175200225670ustar00rootroot00000000000000blank_issues_enabled: false contact_links: - name: Documentation (Apprise Docs) url: https://appriseit.com about: Documentation - name: Documentation Source (Apprise Docs) url: https://github.com/caronc/apprise-docs about: Documentation updates, fixes, and translation work belong here. - name: Support and Questions url: https://github.com/caronc/apprise/discussions about: Please use Discussions for questions and general support. caronc-apprise-182f859/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000046041524161175200220610ustar00rootroot00000000000000## Description **Related issue (if applicable):** # ## Checklist * [ ] Documentation ticket created (if applicable): [apprise-docs/##](https://github.com/caronc/apprise-docs/pull/) * [ ] The change is tested and works locally. * [ ] No commented-out code in this PR. * [ ] No lint errors (use `tox -e lint` and optionally `tox -e format`). * [ ] Test coverage added or updated (use `tox -e qa`). ## Testing Anyone can help test as follows: ```bash # Create a virtual environment python3 -m venv apprise # Change into our new directory cd apprise # Activate our virtual environment source bin/activate # Install the branch pip install git+https://github.com/caronc/apprise.git@ # If you have cloned the branch and have tox available to you: tox -e apprise -- -t "Test Title" -b "Test Message" \ ``` caronc-apprise-182f859/.github/badges/000077500000000000000000000000001524161175200175015ustar00rootroot00000000000000caronc-apprise-182f859/.github/badges/loc.svg000066400000000000000000000016351524161175200210040ustar00rootroot00000000000000 Lines of Code Lines of Code 60,942 60,942 caronc-apprise-182f859/.github/workflows/000077500000000000000000000000001524161175200203115ustar00rootroot00000000000000caronc-apprise-182f859/.github/workflows/codeql-analysis.yml000066400000000000000000000016521524161175200241300ustar00rootroot00000000000000name: "CodeQL" on: push: branches: [ master ] pull_request: branches: [ master ] schedule: - cron: '42 15 * * 5' # Cancel in-progress jobs when pushing to the same branch. concurrency: cancel-in-progress: true group: ${{ github.workflow }}-${{ github.ref }} jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] steps: - name: Checkout repository uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Autobuild uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 caronc-apprise-182f859/.github/workflows/lint.yml000066400000000000000000000007161524161175200220060ustar00rootroot00000000000000# .github/workflows/lint.yml name: Run Lint Checks on: push: paths: - '**.py' pull_request: paths: - '**.py' jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version: '3.12' - name: Install tox run: python -m pip install tox - name: Run Ruff lint check run: tox -e lint caronc-apprise-182f859/.github/workflows/loc-badge.yml000066400000000000000000000022351524161175200226530ustar00rootroot00000000000000# LoC = Lines of Code name: LoC Badge on: # Manual only workflow_dispatch: permissions: contents: write pull-requests: write jobs: loc-badge: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 - name: Generate LoC badge (Apprise library only) uses: alexispurslane/GHA-LoC-Badge@v2.0.0 id: badge with: debug: true # Run from the root of the repo so the pattern paths are exact directory: ./ badge: .github/badges/loc.svg patterns: "apprise/**/*.py" ignore: "__pycache__" - name: Print the output run: | echo "Scanned: ${{ steps.badge.outputs.counted_files }}"; echo "Line Count: ${{ steps.badge.outputs.total_lines }}"; - name: Create PR (if changed) uses: peter-evans/create-pull-request@v8 with: commit-message: "Update Lines of Code (LoC) badge" title: "Update LoC badge" body: "Automated update of the Lines of Code badge." branch: "automation/loc-badge" delete-branch: true add-paths: ".github/badges/loc.svg" caronc-apprise-182f859/.github/workflows/pkgbuild.yml000066400000000000000000000056401524161175200226420ustar00rootroot00000000000000# # Verify on CI/GHA that RPM package building works. # name: RPM Packaging on: push: branches: [main, master, 'release/**'] pull_request: branches: [main, master] workflow_dispatch: jobs: build: name: Build RPMs (${{ matrix.dist }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: dist: [el9, el10] container: image: ghcr.io/caronc/apprise-rpmbuild:${{ matrix.dist }} options: --user root steps: - name: Checkout source uses: actions/checkout@v7 - name: Build RPMs run: ./bin/build-rpm.sh env: APPRISE_DIR: ${{ github.workspace }} # Drop RPMs into dist/ inside the workspace DIST_DIR: ${{ github.workspace }}/dist/${{ matrix.dist }} - name: Show RPMs found for upload run: | echo "Listing dist/${{ matrix.dist }}/**/*.rpm:" find dist/${{ matrix.dist }} -type f -name '*.rpm' - name: Upload RPM Artifacts uses: actions/upload-artifact@v7 with: name: built-rpms-${{ matrix.dist }} path: ./dist/${{ matrix.dist }} if-no-files-found: error retention-days: 5 - name: Upload rpmlint config files uses: actions/upload-artifact@v7 with: # Upload the specific files needed for verification name: rpmlint-config-${{ matrix.dist }} path: ./packaging/redhat/python-apprise.rpmlintrc.${{ matrix.dist }} retention-days: 5 verify: name: Verify RPMs (${{ matrix.dist }}) needs: build runs-on: ubuntu-latest strategy: fail-fast: false matrix: dist: [el9, el10] container: image: ghcr.io/caronc/apprise-rpmbuild:${{ matrix.dist }} options: --user root steps: - name: Download built RPMs uses: actions/download-artifact@v8 with: name: built-rpms-${{ matrix.dist }} path: ./dist - name: Download rpmlint config files uses: actions/download-artifact@v8 with: name: rpmlint-config-${{ matrix.dist }} # Download files directly into the correct directory path: ./packaging/redhat - name: Lint RPMs run: | set -e RC_FILE="./packaging/redhat/python-apprise.rpmlintrc.${{ matrix.dist }}" if rpmlint --help 2>&1 | grep -q -- '--rpmlintrc'; then echo "Using rpmlint --rpmlintrc with $RC_FILE" rpmlint --rpmlintrc "$RC_FILE" ./dist/**/*.rpm else echo "Using rpmlint v1.x on older distribution" rpmlint -f "$RC_FILE" ./dist/**/*.rpm fi - name: Install and verify RPMs run: | echo "Installing RPMs from: ./dist/" find ./dist -name '*.rpm' dnf install -y ./dist/**/*.rpm apprise --version - name: Check Installed Files run: rpm -qlp ./dist/**/*.rpm caronc-apprise-182f859/.github/workflows/tests.yml000066400000000000000000000071721524161175200222050ustar00rootroot00000000000000name: Run Tests on: # Run tests on push to main, master, or any release/ branch push: branches: [main, master, 'release/**'] # Always test on pull requests targeting main/master pull_request: branches: [main, master] # Allow manual triggering via GitHub UI workflow_dispatch: jobs: test: name: Python ${{ matrix.python-version }} – ${{ matrix.tox_env }} on ${{ matrix.os }} runs-on: ${{ matrix.os || 'ubuntu-latest' }} strategy: fail-fast: false # Let all jobs run, even if one fails matrix: include: - python-version: "3.9" tox_env: qa - python-version: "3.10" tox_env: qa - python-version: "3.11" tox_env: qa - python-version: "3.12" tox_env: qa # Pre-release testing (won't fail entire workflow if this fails) - python-version: "3.13-dev" tox_env: qa continue-on-error: true - python-version: "3.14-dev" tox_env: qa continue-on-error: true - python-version: "3.15-dev" tox_env: qa continue-on-error: true # Platform validation only (one version) - os: windows-latest python-version: "3.12" tox_env: qa - os: macos-latest python-version: "3.12" tox_env: qa # Minimal test run on latest Python only # this verifies Apprise still works when extra libraries are not available - python-version: "3.12" tox_env: minimal steps: - uses: actions/checkout@v7 # Install tox for isolated environment and plugin test orchestration - name: Install tox run: python -m pip install tox # Run tox with the specified environment (qa, minimal, etc.) - name: Run tox for ${{ matrix.tox_env }} run: tox -e ${{ matrix.tox_env }} - name: Upload coverage report if: always() uses: actions/upload-artifact@v7 with: name: coverage-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.tox_env }} path: .coverage include-hidden-files: true codecov: name: Upload merged coverage to Codecov runs-on: ubuntu-latest needs: test # Waits for all matrix jobs to complete if: always() # Even if a test fails, still attempt to upload what we have steps: - uses: actions/checkout@v7 - name: Download all coverage reports uses: actions/download-artifact@v8 with: path: coverage-artifacts - name: Combine and generate coverage run: | pip install coverage # Create a consistent temp dir mkdir -p coverage-inputs # Copy and rename each coverage file to .coverage.job_name i=0 for f in $(find coverage-artifacts -name .coverage); do cp "$f" "coverage-inputs/.coverage.$i" i=$((i+1)) done # Confirm files staged ls -alh coverage-inputs # Combine them all coverage combine coverage-inputs coverage report coverage xml -o coverage.xml # Upload merged coverage results to Codecov for visualization - name: Upload to Codecov uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml verbose: false # Used for debugging only fail_ci_if_error: false # Avoid failing job if Codecov is down env: CODECOV_PR: ${{ github.event.pull_request.number }} CODECOV_SHA: ${{ github.sha }} caronc-apprise-182f859/.gitignore000066400000000000000000000015121524161175200167030ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # left-over-files from conflicts/merges *.orig # C extensions *.so # vi swap files .*.sw? # Distribution / packaging .Python env/ .venv* build/ BUILD/ BUILDROOT/ SOURCES/ SRPMS/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib64/ parts/ sdist/ *.egg-info/ .installed.cfg *.egg .local # Generated from Docker Instance .bash_history .python_history # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ #Ipython Notebook .ipynb_checkpoints #PyCharm .idea #PyDev (Eclipse) .project .pydevproject .settings # Others .DS_Store caronc-apprise-182f859/.gitleaks.toml000066400000000000000000000010311524161175200174650ustar00rootroot00000000000000# Gitleaks configuration for the Apprise project. # Reference: https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml [allowlist] description = "Test fixture key files are intentional test data, not real credentials." paths = [ # PGP test fixtures under tests/var/pgp/ are synthetic keys generated # for unit testing only. They are not real private keys and carry no # security risk. '''tests/var/pgp/valid-prv\.asc''', '''tests/var/pgp/valid-pub\.asc''', '''tests/var/pgp/corrupt-pub\.asc''', ] caronc-apprise-182f859/.vscode/000077500000000000000000000000001524161175200162555ustar00rootroot00000000000000caronc-apprise-182f859/.vscode/extensions.json000066400000000000000000000002771524161175200213550ustar00rootroot00000000000000{ "recommendations": [ // Ruff – linter and formatter for Python (replaces flake8, black, isort) "charliermarsh.ruff", // Python language support "ms-python.python" ] } caronc-apprise-182f859/.vscode/settings.json000066400000000000000000000020071524161175200210070ustar00rootroot00000000000000{ // Use the Ruff extension (charliermarsh.ruff) as the sole formatter and linter // for Python files. Install it from the VS Code marketplace or via // ext install charliermarsh.ruff "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true, "editor.codeActionsOnSave": { // Apply all auto-fixable lint violations on save (isort, pyupgrade, etc.) "source.fixAll.ruff": "explicit", // Keep imports sorted on save "source.organizeImports.ruff": "explicit" } }, // Ruff reads its configuration from pyproject.toml automatically, so no // separate ruff.configuration key is needed. Leave the extension defaults. // Testing "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, "python.testing.pytestArgs": ["tests"], "python.testing.cwd": "${workspaceFolder}", // Python path "python.envFile": "${workspaceFolder}/.env", "terminal.integrated.env.linux": { "PYTHONPATH": "${workspaceFolder}" } } caronc-apprise-182f859/ACKNOWLEDGEMENTS.md000066400000000000000000000021531524161175200175710ustar00rootroot00000000000000# Contributions to the Apprise Project ## Creator & Maintainer * Chris Caron ## Contributors The following users have contributed to this project and their deserved recognition has been identified here. If you have contributed and wish to be acknowledged for it, the syntax is as follows: ``` * [Your name or handle] <[email or website]> * [Month Year] - [Brief summary of your contribution] ``` The contributors have been listed in chronological order: * Wim de With * Dec 2018 - Added Matrix Support * Hitesh Sondhi * Mar 2019 - Added Flock Support * Andreas Motl * Mar 2020 - Fix XMPP Support * Oct 2022 - Drop support for Python 2 * Oct 2022 - Add support for Python 3.11 * Oct 2022 - Improve efficiency of NotifyEmail * Joey Espinosa <@particledecay> * Apr 3rd 2022 - Added Ntfy Support * Kate Ward * 6th Feb 2024 - Add Revolt Support * Han Wang * Apr 2024 - Refactored test cases * Toni Wells <@isometimescode> * May 2024 - Fixed token length with apprise:// caronc-apprise-182f859/CONTRIBUTING.md000066400000000000000000000055601524161175200171530ustar00rootroot00000000000000# 🀝 Contributing to Apprise Thank you for your interest in contributing to Apprise! We welcome bug reports, feature requests, documentation improvements, and new notification plugins. Please follow the guidelines below to help us review and merge your contributions smoothly. --- ## βœ… Quick Checklist Before You Submit - βœ”οΈ Your code passes all lint and style checks: ```bash tox -e lint ``` If it reports issues, auto-fix them with: ```bash tox -e format ``` - βœ”οΈ Your changes are covered by tests: ```bash tox -e qa ``` - βœ”οΈ You followed the plugin template (if adding a new plugin). - βœ”οΈ You included inline docstrings and respected the BSD 2-Clause license. - βœ”οΈ Your commit message is descriptive. --- ## πŸ“¦ Local Development Setup To get started with development: ### 🧰 System Requirements - Python >= 3.9 - `pip` - `git` - Optional: `VS Code` with the Python extension ### πŸš€ One-Time Setup ```bash git clone https://github.com/caronc/apprise.git cd apprise # Install all runtime + dev dependencies pip install '.[dev]' ``` (Optional, but recommended if actively developing): ```bash pip install -e '.[dev]' ``` --- ## πŸ§ͺ Running Tests ```bash pytest # Run all tests pytest tests/foo.py # Run a specific test file ``` Run with coverage: ```bash pytest --cov=apprise --cov-report=term ``` --- ## 🧹 Linting & Formatting Use `tox` to run through the same toolchain as CI: ```bash tox -e lint # Check for lint violations and style issues (read-only) tox -e format # Auto-fix lint violations and apply consistent code style ``` If you prefer to call ruff directly: ```bash ruff check . # Check lint violations ruff check . --fix # Auto-fix lint violations ruff format --check . # Check code style ruff format . # Apply code style ``` --- ## 🧰 Optional: Using VS Code 1. Open the repo: `code .` 2. Press `Ctrl+Shift+P -> Python: Select Interpreter` 3. Choose the same interpreter you used for `pip install .[dev]` 4. Press `Ctrl+Shift+P -> Python: Discover Tests` `.vscode/settings.json` is pre-configured with: - pytest as the test runner - ruff for linting - PYTHONPATH set to project root No `.venv` is required unless you choose to use one. --- ## πŸ“Œ How to Contribute 1. **Fork the repository** and create a new branch. 2. Make your changes. 3. Run the checks listed above. 4. Submit a pull request (PR) to the `main` branch. GitHub Actions will run tests and lint checks on your PR automatically. --- ## πŸ§ͺ Need Help with Testing or Plugins? See [DEVELOPMENT.md](./DEVELOPMENT.md) for: - Full setup instructions - Tox environment descriptions - RPM testing - Plugin development guidance --- ## πŸ™ Thank You Your contributions make Apprise better for everyone β€” thank you! πŸ“ See [ACKNOWLEDGEMENTS.md](./ACKNOWLEDGEMENTS.md) for a list of contributors. caronc-apprise-182f859/LICENSE000066400000000000000000000024771524161175200157330ustar00rootroot00000000000000BSD 2-Clause License Copyright (c) 2026, Chris Caron All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. caronc-apprise-182f859/MANIFEST.in000066400000000000000000000010531524161175200164510ustar00rootroot00000000000000include LICENSE include README.md include CONTRIBUTING.md include ACKNOWLEDGEMENTS.md include SECURITY.md include pyproject.toml include tox.ini include babel.cfg include requirements.txt include win-requirements.txt include dev-requirements.txt include all-plugin-requirements.txt include apprise/py.typed recursive-include tests * recursive-include packaging * recursive-include apprise/i18n *.pot recursive-include apprise/i18n *.po recursive-include apprise/i18n */LC_MESSAGES/*.po global-exclude *.pyc global-exclude *.pyo global-exclude __pycache__ caronc-apprise-182f859/README.md000066400000000000000000001633351524161175200162060ustar00rootroot00000000000000![Apprise Logo](https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png)
**apΒ·prise** / *verb*
To inform or tell (someone). To make one aware of something.
*Apprise* allows you to send a notification to *almost* all of the most popular *notification* services available to us today such as: Telegram, Discord, Slack, Amazon SNS, Gotify, etc. * One notification library to rule them all. * A common and intuitive notification syntax. * Supports the handling of images and attachments (_to the notification services that will accept them_). * It's incredibly lightweight. * Amazing response times because all messages sent asynchronously. Developers who wish to provide a notification service no longer need to research each and every one out there. They no longer need to try to adapt to the new ones that comeout thereafter. They just need to include this one library and then they can immediately gain access to almost all of the notifications services available to us today. System Administrators and DevOps who wish to send a notification now no longer need to find the right tool for the job. Everything is already wrapped and supported within the `apprise` command line tool (CLI) that ships with this product. [![Paypal](https://img.shields.io/badge/paypal-donate-green.svg)](https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E) [![Follow](https://img.shields.io/twitter/follow/l2gnux)](https://twitter.com/l2gnux/)
[![Discord](https://img.shields.io/discord/558793703356104724.svg?colorB=7289DA&label=Discord&logo=Discord&logoColor=7289DA&style=flat-square)](https://discord.gg/EGg4rhmpC2) [![Python](https://img.shields.io/pypi/pyversions/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/) [![Build Status](https://github.com/caronc/apprise/actions/workflows/tests.yml/badge.svg)](https://github.com/caronc/apprise/actions/workflows/tests.yml) [![Lines of Code](https://raw.githubusercontent.com/caronc/apprise/master/.github/badges/loc.svg)](https://github.com/caronc/apprise/actions/workflows/loc-badge.yml) [![CodeCov Status](https://codecov.io/github/caronc/apprise/branch/master/graph/badge.svg)](https://codecov.io/github/caronc/apprise) [![PyPi Downloads](https://img.shields.io/pepy/dt/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/) Visit the [Official Documentation](https://appriseit.com/getting-started/) site for more information on Apprise. # Table of Contents * [Supported Notifications](#supported-notifications) * [Productivity Based Notifications](#productivity-based-notifications) * [SMS Notifications](#sms-notifications) * [Desktop Notifications](#desktop-notifications) * [Email Notifications](#email-notifications) * [Custom Notifications](#custom-notifications) * [Installation](#installation) * [Command Line Usage](#command-line-usage) * [Configuration Files](#cli-configuration-files) * [File Attachments](#cli-file-attachments) * [Loading Custom Notifications/Hooks](#cli-loading-custom-notificationshooks) * [Environment Variables](#cli-environment-variables) * [Developer API Usage](#developer-api-usage) * [Configuration Files](#api-configuration-files) * [File Attachments](#api-file-attachments) * [Loading Custom Notifications/Hooks](#api-loading-custom-notificationshooks) * [Persistent Storage](#persistent-storage) * [More Supported Links and Documentation](#want-to-learn-more) # Supported Notifications The section identifies all of the services supported by this library. [Check out the wiki for more information on the supported modules here](https://appriseit.com/). ## Productivity Based Notifications The table below identifies the services this tool supports and some example service URLs you need to use in order to take advantage of it. Click on any of the services listed below to get more details on how you can configure Apprise to access them. If you're having trouble constructing your own URL; try our [Apprise URL Builder](https://appriseit.com/tools/url-builder/) out. | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Apprise API](https://appriseit.com/services/apprise_api/) | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token | [AWS SES](https://appriseit.com/services/ses/) | ses:// | (TCP) 443 | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName
ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN | [Bark](https://appriseit.com/services/bark/) | bark:// | (TCP) 80 or 443 | bark://hostname
bark://hostname/device_key
bark://hostname/device_key1/device_key2/device_keyN
barks://hostname
barks://hostname/device_key
barks://hostname/device_key1/device_key2/device_keyN | [Blink(1)](https://appriseit.com/services/blink1/) | blink1:// | USB | blink1://
blink1://serial/ | [BlueSky](https://appriseit.com/services/bluesky/) | bluesky:// | (TCP) 443 | bluesky://Handle:AppPw
bluesky://Handle:AppPw/TargetHandle
bluesky://Handle:AppPw/TargetHandle1/TargetHandle2/TargetHandleN | [Brevo](https://appriseit.com/services/brevo/) | brevo:// | (TCP) 443 | brevo://APIToken:FromEmail/
brevo://APIToken:FromEmail/ToEmail
brevo://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Chanify](https://appriseit.com/services/chanify/) | chantify:// | (TCP) 443 | chantify://token | [Amazon Chime](https://appriseit.com/services/chime/) | chime:// | (TCP) 443 | chime://WebhookID/Token | [Discord](https://appriseit.com/services/discord/) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token | [Dot.](https://appriseit.com/services/dot/) | dot:// | (TCP) 443 | dot://apikey@device_id/text/
dot://apikey@device_id/image/
**Note**: `device_id` is the Quote/0 hardware serial | [Emby](https://appriseit.com/services/emby/) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname | [Enigma2](https://appriseit.com/services/enigma2/) | enigma2:// or enigma2s:// | (TCP) 80 or 443 | enigma2://hostname | [Evolution API](https://appriseit.com/services/evolution/) | evolution:// or evolutions:// | (TCP) 80 or 443 | evolution://apikey@hostname/instance/ToPhoneNo
evolution://apikey@hostname:port/instance/ToPhoneNo
evolution://apikey@hostname/instance/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [FCM](https://appriseit.com/services/fcm/) | fcm:// | (TCP) 443 | fcm://project@apikey/DEVICE_ID
fcm://project@apikey/#TOPIC
fcm://project@apikey/DEVICE_ID1/#topic1/#topic2/DEVICE_ID2/ | [Feishu](https://appriseit.com/services/feishu/) | feishu:// | (TCP) 443 | feishu://token | [Flock](https://appriseit.com/services/flock/) | flock:// | (TCP) 443 | flock://token
flock://botname@token
flock://app_token/u:userid
flock://app_token/g:channel_id
flock://app_token/u:userid/g:channel_id | [Flowtriq](https://appriseit.com/services/flowtriq/) | flowtriq:// or flowtriqs:// | (TCP) 80 or 443 | flowtriq://apikey@hostname/webhook/path
flowtriqs://apikey@hostname/webhook/path | [Google Chat](https://appriseit.com/services/googlechat/) | gchat:// | (TCP) 443 | gchat://workspace/key/token | [Gotify](https://appriseit.com/services/gotify/) | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token
gotifys://hostname/token?priority=high | [GroupMe](https://appriseit.com/services/groupme/) | groupme:// | (TCP) 443 | groupme://bot_id
groupme://bot_id/access_token | [Growl](https://appriseit.com/services/growl/) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port
**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1 | [Guilded](https://appriseit.com/services/guilded/) | guilded:// | (TCP) 443 | guilded://webhook_id/webhook_token
guilded://avatar@webhook_id/webhook_token | [Home Assistant](https://appriseit.com/services/homeassistant/) | hassio:// or hassios:// | (TCP) 8123 or 443 | hassio://hostname/accesstoken
hassio://user@hostname/accesstoken
hassio://user:password@hostname:port/accesstoken
hassio://hostname/optional/path/accesstoken | [HumHub](https://appriseit.com/services/humhub/) | humhub:// or humhubs:// | (TCP) 80 or 443 | humhubs://token@hostname/container_id
humhubs://user:password@hostname/container_id
humhubs://token@hostname/id1/id2/id3 | [IFTTT](https://appriseit.com/services/ifttt/) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1 | [IRC](https://appriseit.com/services/irc/) | irc:// or ircs:// | (TCP) 6667 or 6697 | ircs://user:pass@irc.server/@user
ircs://user:pass@irc.server/#channel?join=true&mode=nickserv
ircs://user:pass@znc.server/@user1/@user2/@user3/#channel1 | [Jellyfin](https://appriseit.com/services/jellyfin/) | jellyfin:// or jellyfins:// | (TCP) 8096 | jellyfin://user@hostname/
jellyfins://user:password@hostname | [Jira](https://appriseit.com/services/jira/) | jira:// | (TCP) 443 | jira://APIKey
jira://APIKey/@UserID
jira://APIKey/#Team
jira://APIKey/\*Schedule
jira://APIKey/^Escalation | [Join](https://appriseit.com/services/join/) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ | [KODI](https://appriseit.com/services/kodi/) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Kook](https://appriseit.com/services/kook/) | kook:// | (TCP) 443 | kook://token/channel_id
kook://token/channel1/channel2
kook://token/@user_id
kook://webhook_key?mode=webhook | [Kumulos](https://appriseit.com/services/kumulos/) | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey | [LaMetric Time](https://appriseit.com/services/lametric/) | lametric:// | (TCP) 443 | lametric://apikey@device_ipaddr
lametric://apikey@hostname:port
lametric://client_id@client_secret | [Lark](https://appriseit.com/services/lark/) | lark:// | (TCP) 443 | lark://BotToken | [Line](https://appriseit.com/services/line/) | line:// | (TCP) 443 | line://Token@User
line://Token/User1/User2/UserN | [Mailgun](https://appriseit.com/services/mailgun/) | mailgun:// | (TCP) 443 | mailgun://user@hostname/apikey
mailgun://user@hostname/apikey/email
mailgun://user@hostname/apikey/email1/email2/emailN
mailgun://user@hostname/apikey/?name="From%20User" | [MailerSend](https://appriseit.com/services/mailersend/) | mailersend:// | (TCP) 443 | mailersend://APIToken:FromEmail/
mailersend://APIToken:FromEmail/ToEmail
mailersend://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Mastodon](https://appriseit.com/services/mastodon/) | mastodon:// or mastodons://| (TCP) 80 or 443 | mastodon://access_key@hostname
mastodon://access_key@hostname/@user
mastodon://access_key@hostname/@user1/@user2/@userN | [Matrix](https://appriseit.com/services/matrix/) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://hostname
matrix://user@hostname
matrixs://user:pass@hostname:port/#room_alias
matrixs://user:pass@hostname:port/!room_id
matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2
matrixs://token@hostname:port/?webhook=matrix
matrix://user:token@hostname/?webhook=slack&format=markdown | [Mattermost](https://appriseit.com/services/mattermost/) | mmost:// or mmosts:// | (TCP) 8065 | mmost://hostname/authkey
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
| [Microsoft Power Automate / Workflows (MSTeams)](https://appriseit.com/services/workflows/) | workflows:// | (TCP) 443 | workflows://WorkflowID/Signature/ | [Misskey](https://appriseit.com/services/misskey/) | misskey:// or misskeys://| (TCP) 80 or 443 | misskey://access_token@hostname | [MQTT](https://appriseit.com/services/mqtt/) | mqtt:// or mqtts:// | (TCP) 1883 or 8883 | mqtt://hostname/topic
mqtt://user@hostname/topic
mqtts://user:pass@hostname:9883/topic | [Nextcloud](https://appriseit.com/services/nextcloud/) | ncloud:// or nclouds:// | (TCP) 80 or 443 | ncloud://adminuser:pass@host/User
nclouds://adminuser:pass@host/User1/User2/UserN | [NextcloudTalk](https://appriseit.com/services/nextcloudtalk/) | nctalk:// or nctalks:// | (TCP) 80 or 443 | nctalk://user:pass@host/RoomId
nctalks://user:pass@host/RoomId1/RoomId2/RoomIdN | [Notica](https://appriseit.com/services/notica/) | notica:// | (TCP) 443 | notica://Token/ | [Notifiarr](https://appriseit.com/services/notifiarr/) | notifiarr:// | (TCP) 443 | notifiarr://apikey/#channel
notifiarr://apikey/#channel1/#channel2/#channeln | [Notifico](https://appriseit.com/services/notifico/) | notifico:// or notificos:// | (TCP) 80 or 443 | notifico://ProjectID/MessageHook/
notifico://host/ProjectID/MessageHook/
notificos://host/ProjectID/MessageHook/ | [ntfy](https://appriseit.com/services/ntfy/) | ntfy:// | (TCP) 80 or 443 | ntfy://topic/
ntfys://topic/ | [Octopush](https://appriseit.com/services/octopush/) | octopush:// | (TCP) 443 | octopush://APILogin/APIKey/TargetPhoneNo
octopush://Sender:APILogin/APIKey/TargetPhoneNo
octopush://Sender:APILogin/APIKey/TargetPhoneNo1/TargetPhoneNo2/TargetPhoneNoN | [Office 365](https://appriseit.com/services/office365/) | o365:// | (TCP) 443 | o365://TenantID:AccountEmail/ClientID/ClientSecret
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail1/TargetEmail2/TargetEmailN | [OneSignal](https://appriseit.com/services/onesignal/) | onesignal:// | (TCP) 443 | onesignal://AppID@APIKey/PlayerID
onesignal://TemplateID:AppID@APIKey/UserID
onesignal://AppID@APIKey/#IncludeSegment
onesignal://AppID@APIKey/Email | [Opsgenie](https://appriseit.com/services/opsgenie/) | opsgenie:// | (TCP) 443 | opsgenie://APIKey
opsgenie://APIKey/UserID
opsgenie://APIKey/#Team
opsgenie://APIKey/\*Schedule
opsgenie://APIKey/^Escalation | [PagerDuty](https://appriseit.com/services/pagerduty/) | pagerduty:// | (TCP) 443 | pagerduty://IntegrationKey@ApiKey
pagerduty://IntegrationKey@ApiKey/Source/Component | [PagerTree](https://appriseit.com/services/pagertree/) | pagertree:// | (TCP) 443 | pagertree://integration_id | [ParsePlatform](https://appriseit.com/services/parseplatform/) | parsep:// or parseps:// | (TCP) 80 or 443 | parsep://AppID:MasterKey@Hostname
parseps://AppID:MasterKey@Hostname | [Pingram](https://appriseit.com/services/pingram/) | pingram:// | (TCP) 443 | pingram://ApiKey/Target
pingram://ApiKey/Target1/Target2/TargetN
pingram://MessageType@ApiKey/Target | [Pinglet](https://appriseit.com/services/pinglet/) | pinglet:// or pinglets:// | (TCP) 80 or 443 | pinglets://apikey@hostname/namespace/topic
pinglets://apikey@hostname:port/namespace/topic | [Postmark](https://appriseit.com/services/postmark/) | postmark:// | (TCP) 443 | postmark://APIToken:FromEmail/
postmark://APIToken:FromEmail/ToEmail
postmark://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Prowl](https://appriseit.com/services/prowl/) | prowl:// | (TCP) 443 | prowl://apikey
prowl://apikey/providerkey | [PushBullet](https://appriseit.com/services/pushbullet/) | pbul:// | (TCP) 443 | pbul://accesstoken
pbul://accesstoken/#channel
pbul://accesstoken/A_DEVICE_ID
pbul://accesstoken/email@address.com
pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE | [Pushjet](https://appriseit.com/services/pushjet/) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://hostname/secret
pjet://hostname:port/secret
pjets://secret@hostname/secret
pjets://hostname:port/secret | [Push (Techulus)](https://appriseit.com/services/techulus/) | push:// | (TCP) 443 | push://apikey/ | [Pushed](https://appriseit.com/services/pushed/) | pushed:// | (TCP) 443 | pushed://appkey/appsecret/
pushed://appkey/appsecret/#ChannelAlias
pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN
pushed://appkey/appsecret/@UserPushedID
pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN | [PushMe](https://appriseit.com/services/pushme/) | pushme:// | (TCP) 443 | pushme://Token/ | [Pushover](https://appriseit.com/services/pushover/) | pover:// | (TCP) 443 | pover://user@token
pover://user@token/DEVICE
pover://user@token/DEVICE1/DEVICE2/DEVICEN
**Note**: you must specify both your user_id and token | [Pushplus](https://appriseit.com/services/pushplus/) | pushplus:// | (TCP) 443 | pushplus://Token | [PushSafer](https://appriseit.com/services/pushsafer/) | psafer:// or psafers:// | (TCP) 80 or 443 | psafer://privatekey
psafers://privatekey/DEVICE
psafer://privatekey/DEVICE1/DEVICE2/DEVICEN | [PushWard](https://appriseit.com/services/pushward/) | pushward:// | (TCP) 443 | pushward://hlk_apikey
pushward://hlk_apikey?level=critical&volume=0.8 | [Pushy](https://appriseit.com/services/pushy/) | pushy:// | (TCP) 443 | pushy://apikey/DEVICE
pushy://apikey/DEVICE1/DEVICE2/DEVICEN
pushy://apikey/TOPIC
pushy://apikey/TOPIC1/TOPIC2/TOPICN | [PushDeer](https://appriseit.com/services/pushdeer/) | pushdeer:// or pushdeers:// | (TCP) 80 or 443 | pushdeer://pushKey
pushdeer://hostname/pushKey
pushdeer://hostname:port/pushKey | [QQ Push](https://appriseit.com/services/qq/) | qq:// | (TCP) 443 | qq://Token | [Reddit](https://appriseit.com/services/reddit/) | reddit:// | (TCP) 443 | reddit://user:password@app_id/app_secret/subreddit
reddit://user:password@app_id/app_secret/sub1/sub2/subN | [Resend](https://appriseit.com/services/resend/) | resend:// | (TCP) 443 | resend://APIToken:FromEmail/
resend://APIToken:FromEmail/ToEmail
resend://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Revolt](https://appriseit.com/services/revolt/) | revolt:// | (TCP) 443 | revolt://bottoken/ChannelID
revolt://bottoken/ChannelID1/ChannelID2/ChannelIDN | | [Rocket.Chat](https://appriseit.com/services/rocketchat/) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel
rockets://user:password@hostname:443/#Channel1/#Channel1/RoomID
rocket://user:password@hostname/#Channel
rocket://webhook@hostname
rockets://webhook@hostname/@User/#Channel | [RSyslog](https://appriseit.com/services/rsyslog/) | rsyslog:// | (UDP) 514 | rsyslog://hostname
rsyslog://hostname/Facility | [Ryver](https://appriseit.com/services/ryver/) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token | [SendGrid](https://appriseit.com/services/sendgrid/) | sendgrid:// | (TCP) 443 | sendgrid://APIToken:FromEmail/
sendgrid://APIToken:FromEmail/ToEmail
sendgrid://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [SendPulse](https://appriseit.com/services/sendpulse/) | sendpulse:// | (TCP) 443 | sendpulse://user@host/ClientId/ClientSecret
sendpulse://user@host/ClientId/clientSecret/ToEmail
sendpulse://user@host/ClientId/ClientSecret/ToEmail1/ToEmail2/ToEmailN/ | [ServerChan](https://appriseit.com/services/serverchan/) | schan:// | (TCP) 443 | schan://sendkey/ | [Session Open Group Server](https://appriseit.com/services/sogs/) | session:// or sessions:// | (TCP) 80 or 443 | sessions://public_key:seed@hostname/room
sessions://public_key:seed@hostname/room1/room2
sessions://public_key:seed@hostname:port/room
session://public_key:seed@hostname/room | [Signal API](https://appriseit.com/services/signal/) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SIGNL4](https://appriseit.com/services/signl4/) | signl4:// | (TCP) 80 or 443 | signl4://hostname | [SimplePush](https://appriseit.com/services/simplepush/) | spush:// | (TCP) 443 | spush://apikey
spush://salt:password@apikey
spush://apikey?event=Apprise | [Slack](https://appriseit.com/services/slack/) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN | [SMTP2Go](https://appriseit.com/services/smtp2go/) | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey
smtp2go://user@hostname/apikey/email
smtp2go://user@hostname/apikey/email1/email2/emailN
smtp2go://user@hostname/apikey/?name="From%20User" | [SparkPost](https://appriseit.com/services/sparkpost/) | sparkpost:// | (TCP) 443 | sparkpost://user@hostname/apikey
sparkpost://user@hostname/apikey/email
sparkpost://user@hostname/apikey/email1/email2/emailN
sparkpost://user@hostname/apikey/?name="From%20User" | [Spike.sh](https://appriseit.com/services/spike/) | spike:// | (TCP) 443 | spike://Token | [Splunk](https://appriseit.com/services/splunk/) | splunk:// or victorops:/ | (TCP) 443 | splunk://route_key@apikey
splunk://route_key@apikey/entity_id | [Spug Push](https://appriseit.com/services/spugpush/) | spugpush:// | (TCP) 443 | spugpush://Token | [Stackfield](https://appriseit.com/services/stackfield/) | stackfield:// | (TCP) 443 | stackfield://WebhookToken | [Streamlabs](https://appriseit.com/services/streamlabs/) | strmlabs:// | (TCP) 443 | strmlabs://AccessToken/
strmlabs://AccessToken/?name=name&identifier=identifier&amount=0¤cy=USD | [Synology Chat](https://appriseit.com/services/synology_chat/) | synology:// or synologys:// | (TCP) 80 or 443 | synology://hostname/token
synology://hostname:port/token | [Syslog](https://appriseit.com/services/syslog/) | syslog:// | n/a | syslog://
syslog://Facility | [Telegram](https://appriseit.com/services/telegram/) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Twitter](https://appriseit.com/services/twitter/) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret
twitter://user@CKey/CSecret/AKey/ASecret
twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2
twitter://CKey/CSecret/AKey/ASecret?mode=tweet | [Twist](https://appriseit.com/services/twist/) | twist:// | (TCP) 443 | twist://pasword:login
twist://password:login/#channel
twist://password:login/#team:channel
twist://password:login/#team:channel1/channel2/#team3:channel | [Vapid (WebPush)](https://appriseit.com/services/vapid/) | vapid:// | (TCP) 443 | vapid://subscriber/target
vapid://subscriber/target?subfile=path&keyfile=path | [Viber](https://appriseit.com/services/viber/) | viber:// | (TCP) 443 | viber://token/target | [Webex Teams (Cisco)](https://appriseit.com/services/wxteams/) | wxteams:// | (TCP) 443 | wxteams://Token | [WeChat (WeCom)](https://appriseit.com/services/wechat/) | wechat:// | (TCP) 443 | wechat://CorpID:AppSecret@AgentID/@all
wechat://CorpID:AppSecret@AgentID/UserID | [WeCom Bot](https://appriseit.com/services/wecombot/) | wecombot:// | (TCP) 443 | wecombot://BotKey | [WhatsApp](https://appriseit.com/services/whatsapp/) | whatsapp:// | (TCP) 443 | whatsapp://AccessToken@FromPhoneID/ToPhoneNo
whatsapp://Template:AccessToken@FromPhoneID/ToPhoneNo | [WxPusher](https://appriseit.com/services/wxpusher/) | wxpusher:// | (TCP) 443 | wxpusher://AppToken@UserID1/UserID2/UserIDN
wxpusher://AppToken@Topic1/Topic2/Topic3
wxpusher://AppToken@UserID1/Topic1/ | [XBMC](https://appriseit.com/services/xbmc/) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port | [XMPP](https://appriseit.com/services/xmpp/) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://user:pass@hostname
xmpps://user:pass@hostname/jid
xmpps://user:pass@hostname/jid1/jid2@example.ca | [Zoom](https://appriseit.com/services/zoom/) | zoom:// | (TCP) 443 | zoom://WebhookID/Token | [Zulip Chat](https://appriseit.com/services/zulip/) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token
zulip://botname@Organization/Token/Stream
zulip://botname@Organization/Token/Email ## SMS Notifications SMS Notifications for the most part do not have a both a `title` and `body`. They consist of a single `body` which is usually no more then 160 characters in length. When using Apprise, the `title` and `body` are therefore combined into a single message prior to their transmission. | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [46elks](https://appriseit.com/services/46elks/) | 46elks:// | (TCP) 443 | 46elks://user:password@FromPhoneNo
46elks://user:password@FromPhoneNo/ToPhoneNo
46elks://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [800.com](https://appriseit.com/services/eight00com/) | eight00com:// | (TCP) 443 | eight00com://Token@FromPhoneNo
eight00com://Token@FromPhoneNo/ToPhoneNo
eight00com://Token@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Africas Talking](https://appriseit.com/services/africas_talking/) | atalk:// | (TCP) 443 | atalk://AppUser@ApiKey/ToPhoneNo
atalk://AppUser@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Automated Packet Reporting System (APRS)](https://appriseit.com/services/aprs/) | aprs:// | (TCP) 10152 | aprs://user:pass@callsign
aprs://user:pass@callsign1/callsign2/callsignN | [AWS SNS](https://appriseit.com/services/sns/) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN | [BulkSMS](https://appriseit.com/services/bulksms/) | bulksms:// | (TCP) 443 | bulksms://user:password@ToPhoneNo
bulksms://User:Password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [BulkVS](https://appriseit.com/services/bulkvs/) | bulkvs:// | (TCP) 443 | bulkvs://user:password@FromPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Burst SMS](https://appriseit.com/services/burstsms/) | burstsms:// | (TCP) 443 | burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Clickatell](https://appriseit.com/services/clickatell/) | clickatell:// | (TCP) 443 | clickatell://ApiKey/ToPhoneNo
clickatell://FromPhoneNo@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [ClickSend](https://appriseit.com/services/clicksend/) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo
clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DAPNET](https://appriseit.com/services/dapnet/) | dapnet:// | (TCP) 80 | dapnet://user:pass@callsign
dapnet://user:pass@callsign1/callsign2/callsignN | [D7 Networks](https://appriseit.com/services/d7networks/) | d7sms:// | (TCP) 443 | d7sms://token@PhoneNo
d7sms://token@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DingTalk](https://appriseit.com/services/dingtalk/) | dingtalk:// | (TCP) 443 | dingtalk://token/
dingtalk://token/ToPhoneNo
dingtalk://token/ToPhoneNo1/ToPhoneNo2/ToPhoneNo1/ | [Exotel](https://appriseit.com/services/exotel/) | exotel:// | (TCP) 443 | exotel://sid:token@FromPhoneNo
exotel://sid:token@FromPhoneNo/ToPhoneNo
exotel://sid:token@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [Free-Mobile](https://appriseit.com/services/freemobile/) | freemobile:// | (TCP) 443 | freemobile://user@password/ | [httpSMS](https://appriseit.com/services/httpsms/) | httpsms:// | (TCP) 443 | httpsms://ApiKey@FromPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Kavenegar](https://appriseit.com/services/kavenegar/) | kavenegar:// | (TCP) 443 | kavenegar://ApiKey/ToPhoneNo
kavenegar://FromPhoneNo@ApiKey/ToPhoneNo
kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [MessageBird](https://appriseit.com/services/messagebird/) | msgbird:// | (TCP) 443 | msgbird://ApiKey/FromPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [MSG91](https://appriseit.com/services/msg91/) | msg91:// | (TCP) 443 | msg91://TemplateID@AuthKey/ToPhoneNo
msg91://TemplateID@AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Plivo](https://appriseit.com/services/plivo/) | plivo:// | (TCP) 443 | plivo://AuthID@Token@FromPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [RingCentral](https://appriseit.com/services/ringcentral/) | ringc:// | (TCP) 443 | ringc://SourcePhoneNo:Password@ClientID/ClientSecret
ringc://SourcePhoneNo:JWTToken@ClientID/ClientSecret/ToPhoneNo
ringc://SourcePhoneNo:JWTToken@ClientID/ClientSecret/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SerwerSMS](https://appriseit.com/services/serwersms/) | serwersms:// | (TCP) 443 | serwersms://user:password@SenderName/+ToPhoneNo
serwersms://user:password@SenderName/+ToPhoneNo1/+ToPhoneNo2
serwersms://user:password@SenderName/%23GroupID | [Seven](https://appriseit.com/services/seven/) | seven:// | (TCP) 443 | seven://ApiKey/FromPhoneNo
seven://ApiKey/FromPhoneNo/ToPhoneNo
seven://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SociΓ©tΓ© FranΓ§aise du RadiotΓ©lΓ©phone (SFR)](https://appriseit.com/services/sfr/) | sfr:// | (TCP) 443 | sfr://user:password>@spaceId/ToPhoneNo
sfr://user:password>@spaceId/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Signal API](https://appriseit.com/services/signal/) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://appriseit.com/services/sinch/) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SMPP](https://appriseit.com/services/smpp/) | smpp:// or smpps:// | (TCP) 443 | smpp://user:password@hostname:port/FromPhoneNo/ToPhoneNo
smpps://user:password@hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [SMSEagle](https://appriseit.com/services/smseagle/) | smseagle:// or smseagles:// | (TCP) 80 or 443 | smseagles://hostname:port/ToPhoneNo
smseagles://hostname:port/@ToContact
smseagles://hostname:port/#ToGroup
smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/ | [SMS Manager](https://appriseit.com/services/sms_manager/) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo
smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SMSC](https://appriseit.com/services/smsc/) | smsc:// | (TCP) 443 | smsc://login:password@ToPhoneNo
smsc://login:password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Threema Gateway](https://appriseit.com/services/threema/) | threema:// | (TCP) 443 | threema://GatewayID@secret/ToPhoneNo
threema://GatewayID@secret/ToEmail
threema://GatewayID@secret/ToThreemaID/
threema://GatewayID@secret/ToEmail/ToThreemaID/ToPhoneNo/... | [Trigv](https://trigv.com/) | trigv:// or trigvs:// | (TCP) 80 or 443 | trigvs://APIKey
trigvs://APIKey/channel
trigv://APIKey@hostname/channel | [Notifyre](https://appriseit.com/services/notifyre/) | notifyre:// | (TCP) 443 | notifyre://ApiKey/ToPhoneNo
notifyre://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
notifyre://ApiKey/ToFaxNo?mode=fax
notifyre://ApiKey/ToFaxNo?mode=fax&from=+FromNo | [Twilio](https://appriseit.com/services/twilio/) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?method=call
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN?method=call | [Voipms](https://appriseit.com/services/voipms/) | voipms:// | (TCP) 443 | voipms://password:email/FromPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Vonage](https://appriseit.com/services/vonage/) (formerly Nexmo) | vonage:// | (TCP) 443 | vonage://ApiKey:ApiSecret@FromPhoneNo
vonage://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
vonage://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ ## Desktop Notifications | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Linux DBus Notifications](https://appriseit.com/services/dbus/) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde:// | [Linux Gnome Notifications](https://appriseit.com/services/gnome/) | gnome:// | n/a | gnome:// | [MacOS X Notifications](https://appriseit.com/services/macosx/) | macosx:// | n/a | macosx:// | [Windows Notifications](https://appriseit.com/services/windows/) | windows:// | n/a | windows:// ## Email Notifications | Service ID | Default Port | Example Syntax | | ---------- | ------------ | -------------- | | [mailto://](https://appriseit.com/services/email/) | (TCP) 25 | mailto://userid:pass@domain.com
mailto://domain.com?user=userid&pass=password
mailto://domain.com:2525?user=userid&pass=password
mailto://user@gmail.com&pass=password
mailto://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com
mailto://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply | [mailtos://](https://appriseit.com/services/email/) | (TCP) 587 | mailtos://userid:pass@domain.com
mailtos://domain.com?user=userid&pass=password
mailtos://domain.com:465?user=userid&pass=password
mailtos://user@hotmail.com&pass=password
mailtos://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com
mailtos://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply Apprise has some email services built right into it (such as yahoo, fastmail, hotmail, gmail, etc) that greatly simplify the mailto:// service. See more details [here](https://appriseit.com/services/email/). ## Custom Notifications | Post Method | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Form](https://appriseit.com/services/form/) | form:// or forms:// | (TCP) 80 or 443 | form://hostname
form://user@hostname
form://user:password@hostname:port
form://hostname/a/path/to/post/to | [JSON](https://appriseit.com/services/json/) | json:// or jsons:// | (TCP) 80 or 443 | json://hostname
json://user@hostname
json://user:password@hostname:port
json://hostname/a/path/to/post/to | [XML](https://appriseit.com/services/xml/) | xml:// or xmls:// | (TCP) 80 or 443 | xml://hostname
xml://user@hostname
xml://user:password@hostname:port
xml://hostname/a/path/to/post/to # Installation The easiest way is to install Apprise from PyPI: ```bash pip install apprise ``` Apprise is also packaged as an RPM and available through [EPEL](https://docs.fedoraproject.org/en-US/epel/) supporting CentOS, Redhat, Rocky, Oracle Linux, etc. ```bash # Follow instructions on https://docs.fedoraproject.org/en-US/epel # to get your system connected up to EPEL and then: # Redhat/CentOS 7.x users yum install apprise # Redhat/Rocky Linux 8.x+ and/or Fedora Users dnf install apprise ``` You can also check out the [Graphical version of Apprise](https://github.com/caronc/apprise-api) to centralize your configuration and notifications through a manageable webpage. # Command Line Usage A small command line interface (CLI) tool is also provided with this package called *apprise*. If you know the server urls you wish to notify, you can simply provide them all on the command line and send your notifications that way: ```bash # Send a notification to as many servers as you want # as you can easily chain one after another (the -vv provides some # additional verbosity to help let you know what is going on): apprise -vv -t 'my title' -b 'my notification body' \ 'mailto://myemail:mypass@gmail.com' \ 'pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b' # If you don't specify a --body (-b) then stdin is used allowing # you to use the tool as part of your every day administration: cat /proc/cpuinfo | apprise -vv -t 'cpu info' \ 'mailto://myemail:mypass@gmail.com' # The title field is totally optional uptime | apprise -vv \ 'discord:///4174216298/JHMHI8qBe7bk2ZwO5U711o3dV_js' ``` ## CLI Configuration Files No one wants to put their credentials out for everyone to see on the command line. No problem, *apprise* also supports configuration files. It can handle both a specific YAML format or a very simple TEXT format. You can also pull these configuration files via an HTTP query too! Read more about the expected structure of the configuration files [here](https://appriseit.com/config/). ```bash # By default if no url or configuration is specified apprise will attempt to load # configuration files (if present) from: # ~/.apprise # ~/.apprise.yaml # ~/.config/apprise.conf # ~/.config/apprise.yaml # /etc/apprise.conf # /etc/apprise.yaml # Also subdirectory handling allows you to leverage plugins # ~/.apprise/apprise # ~/.apprise/apprise.yaml # ~/.config/apprise/apprise.conf # ~/.config/apprise/apprise.yaml # /etc/apprise/apprise.yaml # /etc/apprise/apprise.conf # Windows users can store their default configuration files here: # %APPDATA%/Apprise/apprise.conf # %APPDATA%/Apprise/apprise.yaml # %LOCALAPPDATA%/Apprise/apprise.conf # %LOCALAPPDATA%/Apprise/apprise.yaml # %ALLUSERSPROFILE%\Apprise\apprise.conf # %ALLUSERSPROFILE%\Apprise\apprise.yaml # %PROGRAMFILES%\Apprise\apprise.conf # %PROGRAMFILES%\Apprise\apprise.yaml # %COMMONPROGRAMFILES%\Apprise\apprise.conf # %COMMONPROGRAMFILES%\Apprise\apprise.yaml # The configuration files specified above can also be identified with a `.yml` # extension or even just entirely removing the `.conf` extension altogether. # If you loaded one of those files, your command line gets really easy: apprise -vv -t 'my title' -b 'my notification body' # If you want to deviate from the default paths or specify more than one, # just specify them using the --config switch: apprise -vv -t 'my title' -b 'my notification body' \ --config=/path/to/my/config.yml # Got lots of configuration locations? No problem, you can specify them all: # Apprise can even fetch the configuration from over a network! apprise -vv -t 'my title' -b 'my notification body' \ --config=/path/to/my/config.yml \ --config=https://localhost/my/apprise/config ``` ## CLI Tagging Support Apprise allows you to tag your services in your configuration to organize them (e.g., `family`, `devops`, `critical`). You can then filter which services to notify using the `--tag` (`-g`) switch. It is important to understand how Apprise handles multiple tags: * **OR Logic (Union)**: To notify services that have *either* Tag A **OR** Tag B, specify the `-g` switch multiple times. * **AND Logic (Intersection)**: To notify services that have *both* Tag A **AND** Tag B, separate the tags with a comma. ```bash # OR Logic: Notify any service tagged 'devops' OR 'admin' apprise -vv -t "Union Test" \ --config=~/apprise.yml \ -g devops -g admin # AND Logic: Notify only services tagged with BOTH 'devops' AND 'critical' apprise -vv -t "Intersection Test" \ --config=~/apprise.yml \ -g devops,critical ``` Tags also support an optional **priority prefix** and **retry suffix**. In your configuration file you assign a priority to a tag like `1:alerts` or `5:alerts`. A lower number means higher urgency. * **Escalation (no prefix)**: `-g alerts` dispatches priority-1 entries first. If they all succeed, Apprise returns early and never runs the priority-5 fallbacks. A failure in the lower-priority group triggers the next group as an escalation chain. * **Exclusive (with prefix)**: `-g "2:alerts"` notifies *only* services whose `alerts` tag has priority 2. No other priority levels are triggered. * **Per-call retry**: `-g "alerts:3"` retries each matched service up to 3 times on failure (overrides the service's own retry setting for this call only). * **Combined**: `-g "2:alerts:3"` -- exclusive priority-2 filter with up to 3 retries. ```bash # Escalation: priority-1 first; skip priority-5 if all succeed apprise -vv -t "Alert" --config=~/apprise.yml -g alerts # Exclusive: only priority-2 alert services apprise -vv -t "Alert" --config=~/apprise.yml -g "2:alerts" # With retry override: retry each matched service up to 3 times apprise -vv -t "Alert" --config=~/apprise.yml -g "alerts:3" ``` ## CLI File Attachments Apprise also supports file attachments too! Specify as many attachments to a notification as you want. ```bash # Send a funny image you found on the internet to a colleague: apprise -vv --title 'Agile Joke' \ --body 'Did you see this one yet?' \ --attach https://i.redd.it/my2t4d2fx0u31.jpg \ 'mailto://myemail:mypass@gmail.com' # Easily send an update from a critical server to your dev team apprise -vv --title 'system crash' \ --body 'I do not think Jim fixed the bug; see attached...' \ --attach /var/log/myprogram.log \ --attach /var/debug/core.2345 \ --tag devteam ``` ## CLI Loading Custom Notifications/Hooks To create your own custom `schema://` hook so that you can trigger your own custom code, simply include the `@notify` decorator to wrap your function. ```python from apprise.decorators import notify # # The below assumes you want to catch foobar:// calls: # @notify(on="foobar", name="My Custom Foobar Plugin") def my_custom_notification_wrapper(body, title, notify_type, *args, **kwargs): """My custom notification function that triggers on all foobar:// calls """ # Write all of your code here... as an example... print("{}: {} - {}".format(notify_type.upper(), title, body)) # Returning True/False is a way to relay your status back to Apprise. # Returning nothing (None by default) is always interpreted as a Success ``` Once you've defined your custom hook, you just need to tell Apprise where it is at runtime. ```bash # By default if no plugin path is specified apprise will attempt to load # all plugin files (if present) from the following directory paths: # ~/.apprise/plugins # ~/.config/apprise/plugins # /var/lib/apprise/plugins # Windows users can store their default plugin files in these directories: # %APPDATA%/Apprise/plugins # %LOCALAPPDATA%/Apprise/plugins # %ALLUSERSPROFILE%\Apprise\plugins # %PROGRAMFILES%\Apprise\plugins # %COMMONPROGRAMFILES%\Apprise\plugins # If you placed your plugin file within one of the directories already defined # above, then your call simply needs to look like: apprise -vv --title 'custom override' \ --body 'the body of my message' \ foobar:\\ # However you can override the path like so apprise -vv --title 'custom override' \ --body 'the body of my message' \ --plugin-path /path/to/my/plugin.py \ foobar:\\ ``` You can read more about creating your own custom notifications and/or hooks [here](https://appriseit.com/library/extending/decorator/). ## CLI Environment Variables Those using the Command Line Interface (CLI) can also leverage environment variables to pre-set the default settings: | Variable | Description | |------------------------ | ----------------- | | `APPRISE_URLS` | Specify the default URLs to notify IF none are otherwise specified on the command line explicitly. If the `--config` (`-c`) is specified, then this will overrides any reference to this variable. Use white space and/or a comma (`,`) to delimit multiple entries. | `APPRISE_CONFIG_PATH` | Explicitly specify the config search path to use (overriding the default). The path(s) defined here must point to the absolute filename to open/reference. Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. | `APPRISE_PLUGIN_PATH` | Explicitly specify the custom plugin search path to use (overriding the default). Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. | `APPRISE_STORAGE_PATH` | Explicitly specify the persistent storage path to use (overriding the default). # Developer API Usage To send a notification from within your python application, just do the following: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add all of the notification services by their server url. # A sample email notification: apobj.add('mailto://myuserid:mypass@gmail.com') # A sample pushbullet notification apobj.add('pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b') # Then notify these services any time you desire. The below would # notify all of the services loaded into our Apprise object. apobj.notify( body='what a great notification service!', title='my notification title', ) ``` ## API Configuration Files Developers need access to configuration files too. The good news is that their use just involves declaring another object (called *AppriseConfig*) that the *Apprise* object can ingest. You can also freely mix and match config and notification entries as often as you wish! You can read more about the expected structure of the configuration files [here](https://appriseit.com/getting-started/configuration/). ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Create an Config instance config = apprise.AppriseConfig() # Add a configuration source: config.add('/path/to/my/config.yml') # Add another... config.add('https://myserver:8080/path/to/config') # Make sure to add our config into our apprise object apobj.add(config) # You can mix and match; add an entry directly if you want too # In this entry we associate the 'admin' tag with our notification apobj.add('mailto://myuser:mypass@hotmail.com', tag='admin') # Then notify these services any time you desire. The below would # notify all of the services that have not been bound to any specific # tag. apobj.notify( body='what a great notification service!', title='my notification title', ) # Tagging allows you to specifically target only specific notification # services you've loaded: apobj.notify( body='send a notification to our admin group', title='Attention Admins', # notify any services tagged with the 'admin' tag tag='admin', ) # If you want to notify absolutely everything (regardless of whether # it's been tagged or not), just use the reserved tag of 'all': apobj.notify( body='send a notification to our admin group', title='Attention Admins', # notify absolutely everything loaded, regardless on whether # it has a tag associated with it or not: tag='all', ) ``` ## API File Attachments Attachments are very easy to send using the Apprise API: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add at least one service you want to notify apobj.add('mailto://myuser:mypass@hotmail.com') # Then send your attachment. apobj.notify( title='A great photo of our family', body='The flash caused Jane to close her eyes! hah! :)', attach='/local/path/to/my/DSC_003.jpg', ) # Send a web based attachment too! In the below example, we connect to a home # security camera and send a live image to an email. By default remote web # content is cached, but for a security camera we might want to call notify # again later in our code, so we want our last image retrieved to expire(in # this case after 3 seconds). apobj.notify( title='Latest security image', attach='http://admin:password@hikvision-cam01/ISAPI/Streaming/channels/101/picture?cache=3' ) ``` To send more than one attachment, just use a list, set, or tuple instead: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add at least one service you want to notify apobj.add('mailto://myuser:mypass@hotmail.com') # Now add all of the entries we're interested in: attach = ( # ?name= allows us to rename the actual jpeg as found on the site # to be another name when sent to our receipient(s) 'https://i.redd.it/my2t4d2fx0u31.jpg?name=FlyingToMars.jpg', # Now add another: '/path/to/funny/joke.gif', ) # Send your multiple attachments with a single notify call: apobj.notify( title='Some good jokes.', body='Hey guys, check out these!', attach=attach, ) ``` ## API Loading Custom Notifications/Hooks By default, no custom plugins are loaded at all for those building from within the Apprise API. It's at the developers discretion to load custom modules. But should you choose to do so, it's as easy as including the path reference in the `AppriseAsset()` object prior to the initialization of your `Apprise()` instance. For example: ```python from apprise import Apprise from apprise import AppriseAsset # Prepare your Asset object so that you can enable the custom plugins to # be loaded for your instance of Apprise... asset = AppriseAsset(plugin_paths="/path/to/scan") # OR You can also generate scan more then one file too: asset = AppriseAsset( plugin_paths=[ # Iterate over all python libraries found in the root of the # specified path. This is NOT a recursive (directory) scan; only # the first level is parsed. HOWEVER, if a directory containing # an __init__.py is found, it will be included in the load. "/dir/containing/many/python/libraries", # An absolute path to a plugin.py to exclusively load "/path/to/plugin.py", # if you point to a directory that has an __init__.py file found in # it, then only that file is loaded (it's similar to point to a # absolute .py file. Hence, there is no (level 1) scanning at all # within the directory specified. "/path/to/dir/library" ] ) # Now that we've got our asset, we just work with our Apprise object as we # normally do aobj = Apprise(asset=asset) # If our new custom `foobar://` library was loaded (presuming we prepared # one like in the examples above). then you would be able to safely add it # into Apprise at this point aobj.add('foobar://') # Send our notification out through our foobar:// aobj.notify("test") ``` You can read more about creating your own custom notifications and/or hooks [here](https://appriseit.com/library/extending/decorator/). # Persistent Storage Persistent storage allows Apprise to cache re-occurring actions optionaly to disk. This can greatly reduce the overhead used to send a notification. There are 3 Persistent Storage operational states Apprise can operate using: 1. `auto`: Flush gathered cache information to the filesystem on demand. This option is incredibly light weight. This is the default behavior for all CLI usage. * Developers who choose to use this operational mode can also force cached information manually if they choose. * The CLI will use this operational mode by default. 2. `flush`: Flushes any cache information to the filesystem during every transaction. 3. `memory`: Effectively disable Persistent Storage. Any caching of data required by each plugin used is done in memory. Apprise effectively operates as it always did before peristent storage was available. This setting ensures no content is ever written to disk. * By default this is the mode Apprise will operate under for those developing with it unless they configure it to otherwise operate as `auto` or `flush`. This is done through the `AppriseAsset()` object and is explained further on in this documentation. ## CLI Persistent Storage Commands You can provide the keyword `storage` on your CLI call to see the persistent storage options available to you. ```bash # List all of the occupied space used by Apprise's Persistent Storage: apprise storage list # list is the default option, so the following does the same thing: apprise storage # You can prune all of your storage older then 30 days # and not accessed for this period like so: apprise storage prune # You can do a hard reset (and wipe all persistent storage) with: apprise storage clean ``` You can also filter your results by adding tags and/or URL Identifiers. When you get a listing (`apprise storage list`), you may see: ``` # example output of 'apprise storage list': 1. f7077a65 0.00B unused - matrixs://abcdef:****@synapse.example12.com/%23general?image=no&mode=off&version=3&msgtype... tags: team 2. 0e873a46 81.10B active - tgram://W...U//?image=False&detect=yes&silent=no&preview=no&content=before&mdv=v1&format=m... tags: personal 3. abcd123 12.00B stale ``` The (persistent storage) cache states are: - `unused`: This plugin has not commited anything to disk for reuse/cache purposes - `active`: This plugin has written content to disk. Or at the very least, it has prepared a persistent storage location it can write into. - `stale`: The system detected a location where a URL may have possibly written to in the past, but there is nothing linking to it using the URLs provided. It is likely wasting space or is no longer of any use. You can use this information to filter your results by specifying _URL ID_ (UID) values after your command. For example: ```bash # The below commands continue with the example already identified above # the following would match abcd123 (even though just ab was provided) # The output would only list the 'stale' entry above apprise storage list ab # knowing our filter is safe, we could remove it # the below command would not obstruct our other to URLs and would only # remove our stale one: apprise storage clean ab # Entries can be filtered by tag as well: apprise storage list --tag=team # You can match on multiple URL ID's as well: # The followin would actually match the URL ID's of 1. and .2 above apprise storage list f 0 ``` When using the CLI, Persistent storage is set to the operational mode of `auto` by default, you can change this by providing `--storage-mode=` (`-SM`) during your calls. If you want to ensure it's always set to a value of your choice. For more information on persistent storage, [visit here](https://appriseit.com/cli/persistent-storage/). ## API Persistent Storage Commands For developers, persistent storage is set in the operational mode of `memory` by default. It's at the developers discretion to enable it (by switching it to either `auto` or `flush`). Should you choose to do so: it's as easy as including the information in the `AppriseAsset()` object prior to the initialization of your `Apprise()` instance. For example: ```python from apprise import Apprise from apprise import AppriseAsset from apprise import PersistentStoreMode # Prepare a location the persistent storage can write it's cached content to. # By setting this path, this immediately assumes you wish to operate the # persistent storage in the operational 'auto' mode asset = AppriseAsset(storage_path="/path/to/save/data") # If you want to be more explicit and set more options, then you may do the # following asset = AppriseAsset( # Set our storage path directory (minimum requirement to enable it) storage_path="/path/to/save/data", # Set the mode... the options are: # 1. PersistentStoreMode.MEMORY # - disable persistent storage from writing to disk # 2. PersistentStoreMode.AUTO # - write to disk on demand # 3. PersistentStoreMode.FLUSH # - write to disk always and often storage_mode=PersistentStoreMode.FLUSH # The URL IDs are by default 8 characters in length. You can increase and # decrease it's value here. The value must be > 2. The default value is 8 # if not otherwise specified storage_idlen=8, ) # Now that we've got our asset, we just work with our Apprise object as we # normally do aobj = Apprise(asset=asset) ``` For more information on persistent storage, [visit here](https://appriseit.com/library/persistent-storage/). # Want To Learn More? If you're interested in reading more about this and other methods on how to customize your own notifications, please check out the following links: * πŸ“£ [Using the CLI](https://appriseit.com/cli/) * πŸ› οΈ [Development API](https://appriseit.com/library/) * βš™οΈ [Configuration File Help](https://appriseit.com/getting-started/configuration/) * ⚑ [Create Your Own Custom Notifications](https://appriseit.com/library/extending/decorator/) * 🌎 [Apprise API/Web Interface](https://github.com/caronc/apprise-api/) * πŸ“– [Apprise Documentation Source](https://github.com/caronc/apprise-docs/) * πŸ”§ [Troubleshooting](https://appriseit.com/qa/) * πŸŽ‰ [Showcase](https://appriseit.com/contributing/showcase/) Want to help make Apprise better? * πŸ’‘ [Contribute to the Apprise Code Base](https://appriseit.com/contributing/) * ❀️ [Sponsorship and Donations](https://appriseit.com/contributing/sponsors/) caronc-apprise-182f859/SECURITY.md000066400000000000000000000005731524161175200165120ustar00rootroot00000000000000# Security Policy ## Supported Versions | Version | Supported | | ------- | ------------------ | | 0.9.x | :white_check_mark: | | < 0.9.x | :x: | ## Reporting a Vulnerability If you find a vunerability, please notify me at lead2gold@gmail.com. If the vunerability is severe then please just open a ticket at https://github.com/caronc/apprise/issues caronc-apprise-182f859/all-plugin-requirements.txt000066400000000000000000000011001524161175200222320ustar00rootroot00000000000000# # Note: This file is being kept for backwards compatibility with # legacy systems that point here. All future changes should # occur in pyproject.toml. Contents of this file can be found # in [project.optional-dependencies].all-plugins # Provides fcm:// and spush:// cryptography # Provides growl:// support gntp # Provides mqtt:// support paho-mqtt >= 2.1.0 # Pretty Good Privacy (PGP) Provides mailto:// and deltachat:// support PGPy # Provides blink1:// support hidapi # Provides smpp:// support smpplib # For xmpp:// support slixmpp >= 1.16.0 caronc-apprise-182f859/apprise/000077500000000000000000000000001524161175200163575ustar00rootroot00000000000000caronc-apprise-182f859/apprise/__init__.py000066400000000000000000000074741524161175200205040ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. __title__ = "Apprise" __description__: str = ( "Push Notifications that work with just about every platform!" ) __version__ = "1.13.0" __author__ = "Chris Caron" __email__ = "lead2gold@gmail.com" __license__ = "BSD 2-Clause" __copyright__ = "Copyright (c) 2026, Chris Caron " __status__ = "Production" from . import decorators, exception from .apprise import Apprise from .apprise_attachment import AppriseAttachment from .apprise_config import AppriseConfig from .asset import AppriseAsset from .attachment.base import AttachBase from .common import ( CONFIG_FORMATS, CONTENT_INCLUDE_MODES, CONTENT_LOCATIONS, NOTIFY_FORMATS, NOTIFY_IMAGE_SIZES, NOTIFY_TYPES, OVERFLOW_MODES, PERSISTENT_STORE_MODES, PERSISTENT_STORE_STATES, ConfigFormat, ContentIncludeMode, ContentLocation, NotifyFormat, NotifyImageSize, NotifyType, OverflowMode, PersistentStoreMode, ) from .config.base import ConfigBase from .locale import AppriseLocale # Inherit our logging with our additional entries added to it from .logger import LOGGER_NAME, LogCapture, logger, logging from .manager_attachment import AttachmentManager from .manager_config import ConfigurationManager from .manager_plugins import NotificationManager from .persistent_store import PersistentStore from .plugins.base import NotifyBase from .tag import AppriseTag from .url import PrivacyMode, URLBase # Set default logging handler to avoid "No handler found" warnings. logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__ = [ "CONFIG_FORMATS", "CONTENT_INCLUDE_MODES", "CONTENT_LOCATIONS", "LOGGER_NAME", "NOTIFY_FORMATS", "NOTIFY_IMAGE_SIZES", "NOTIFY_TYPES", "OVERFLOW_MODES", "PERSISTENT_STORE_MODES", "PERSISTENT_STORE_STATES", # Core "Apprise", "AppriseAsset", "AppriseAttachment", "AppriseConfig", "AppriseLocale", "AppriseTag", "AttachBase", "AttachmentManager", "ConfigBase", "ConfigFormat", "ConfigurationManager", "ContentIncludeMode", "ContentLocation", "LogCapture", # Managers "NotificationManager", "NotifyBase", "NotifyFormat", "NotifyImageSize", # Reference "NotifyType", "OverflowMode", "PersistentStore", "PersistentStoreMode", "PrivacyMode", "URLBase", # Decorator "decorators", # Exceptions "exception", # Logging "logger", "logging", ] caronc-apprise-182f859/apprise/apprise.py000066400000000000000000002020751524161175200204020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations import asyncio from collections.abc import Iterator import concurrent.futures as cf from itertools import chain import json import os import time from typing import Any, Optional, Union from . import __version__, common, plugins from .apprise_attachment import AppriseAttachment from .apprise_config import AppriseConfig from .asset import AppriseAsset from .common import ContentLocation from .config.base import ConfigBase from .conversion import convert_between from .emojis import apply_emojis from .locale import AppriseLocale from .logger import logger from .manager_plugins import NotificationManager from .plugins.base import NotifyBase from .tag import AppriseTag from .utils.cwe312 import cwe312_url from .utils.json import AppriseJSONEncoder from .utils.logic import is_exclusive_match from .utils.parse import parse_list, parse_urls # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() class Apprise: """Our Notification Manager.""" def __init__( self, servers: Optional[ Union[ str, dict, NotifyBase, AppriseConfig, ConfigBase, list[Union[str, dict, NotifyBase, AppriseConfig, ConfigBase]], ] ] = None, asset: Optional[AppriseAsset] = None, location: Optional[ContentLocation] = None, debug: bool = False, ) -> None: """Loads a set of server urls while applying the Asset() module to each if specified. If no asset is provided, then the default asset is used. Optionally specify a global ContentLocation for a more strict means of handling Attachments. """ # Initialize a server list of URLs self.servers = [] # Assigns an central asset object that will be later passed into each # notification plugin. Assets contain information such as the local # directory images can be found in. It can also identify remote # URL paths that contain the images you want to present to the end # user. If no asset is specified, then the default one is used. self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if servers: self.add(servers) # Initialize our locale object self.locale = AppriseLocale() # Set our debug flag self.debug = debug # Store our hosting location for optional strict rule handling # of Attachments. Setting this to None removes any attachment # restrictions. self.location = location @staticmethod def instantiate( url: Union[str, dict], asset: Optional[AppriseAsset] = None, tag: Optional[Union[str, list[str]]] = None, suppress_exceptions: bool = True, ) -> Optional[NotifyBase]: """Returns the instance of a instantiated plugin based on the provided Server URL. If the url fails to be parsed, then None is returned. The specified url can be either a string (the URL itself) or a dictionary containing all of the components needed to istantiate the notification service. If identifying a dictionary, at the bare minimum, one must specify the schema. An example of a url dictionary object might look like: { schema: 'mailto', host: 'google.com', user: 'myuser', password: 'mypassword', } Alternatively the string is much easier to specify: mailto://user:mypassword@google.com The dictionary works well for people who are calling details() to extract the components they need to build the URL manually. """ # Initialize our result set results = None # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() if isinstance(url, str): # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results is None: # Failed to parse the server URL; detailed logging handled # inside url_to_dict - nothing to report here. return None elif isinstance(url, dict): # We already have our result set results = url if results.get("schema") not in N_MGR: # schema is a mandatory dictionary item as it is the only way # we can index into our loaded plugins logger.error('Dictionary does not include a "schema" entry.') logger.trace( "Invalid dictionary unpacked as:{}{}".format( os.linesep, os.linesep.join( [f'{k}="{v}"' for k, v in results.items()] ), ) ) return None logger.trace( "Dictionary unpacked as:{}{}".format( os.linesep, os.linesep.join( [f'{k}="{v}"' for k, v in results.items()] ), ) ) # Otherwise we handle the invalid input specified else: logger.error( "An invalid URL type (%s) was specified for instantiation", type(url), ) return None if not N_MGR[results["schema"]].enabled: # # First Plugin Enable Check (Pre Initialization) # # Plugin has been disabled at a global level logger.error( "%s:// is disabled on this system.", results["schema"] ) return None # Build a list of tags to associate with the newly added notifications results["tag"] = set(parse_list(tag)) # Set our Asset Object results["asset"] = asset if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL logger.debug( "Loaded {} URL: {}".format( N_MGR[results["schema"]].service_name, plugin.url(privacy=asset.secure_logging), ) ) except Exception: # CWE-312 (Secure Logging) Handling loggable_url = ( url if not asset.secure_logging else cwe312_url(url) ) # the arguments are invalid or can not be used. logger.error( "Could not load {} URL: {}".format( N_MGR[results["schema"]].service_name, loggable_url ) ) return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch plugin = N_MGR[results["schema"]](**results) if not plugin.enabled: # # Second Plugin Enable Check (Post Initialization) # # Service/Plugin is disabled (on a more local level). This is a # case where the plugin was initially enabled but then after the # __init__() was called under the hood something pre-determined # that it could no longer be used. # The only downside to doing it this way is services are # initialized prior to returning the details() if 3rd party tools # are polling what is available. These services that become # disabled thereafter are shown initially that they can be used. logger.error( "%s:// has become disabled on this system.", results["schema"] ) return None return plugin def add( self, servers: Union[ str, dict, NotifyBase, AppriseConfig, ConfigBase, list[Union[str, dict, NotifyBase, AppriseConfig, ConfigBase]], ], asset: Optional[AppriseAsset] = None, tag: Optional[Union[str, list[str]]] = None, ) -> bool: """Adds one or more server URLs into our list. You can override the global asset if you wish by including it with the server(s) that you add. The tag allows you to associate 1 or more tag values to the server(s) being added. tagging a service allows you to exclusively access them when calling the notify() function. """ # Initialize our return status return_status = True if asset is None: # prepare default asset asset = self.asset if isinstance(servers, str): # build our server list servers = parse_urls(servers) if len(servers) == 0: return False elif isinstance(servers, dict): # no problem, we support kwargs, convert it to a list servers = [servers] elif isinstance(servers, (ConfigBase, NotifyBase, AppriseConfig)): # Go ahead and just add our plugin into our list self.servers.append(servers) return True elif not isinstance(servers, (tuple, set, list)): logger.error( f"An invalid notification (type={type(servers)}) was" " specified." ) return False for server in servers: if isinstance(server, (ConfigBase, NotifyBase, AppriseConfig)): # Go ahead and just add our plugin into our list self.servers.append(server) continue elif not isinstance(server, (str, dict)): logger.error( f"An invalid notification (type={type(server)}) was" " specified." ) return_status = False continue # Instantiate ourselves an object, this function throws or # returns None if it fails instance = Apprise.instantiate(server, asset=asset, tag=tag) if not isinstance(instance, NotifyBase): # No logging is required as instantiate() handles failure # and/or success reasons for us return_status = False continue # Add our initialized plugin to our server listings self.servers.append(instance) # Return our status return return_status def clear(self) -> None: """Empties our server list.""" self.servers[:] = [] def find( self, tag: Any = common.MATCH_ALL_TAG, match_always: bool = True, ) -> Iterator[NotifyBase]: """Returns a list of all servers matching against the tag specified.""" # Build our tag setup # - top level entries are treated as an 'or' # - second level (or more) entries are treated as 'and' # # examples: # tag="tagA, tagB" = tagA or tagB # tag=['tagA', 'tagB'] = tagA or tagB # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB # tag=[('tagB', 'tagC')] = tagB and tagC # A match_always flag allows us to pick up on our 'any' keyword # and notify these services under all circumstances match_always = common.MATCH_ALWAYS_TAG if match_always else None # Iterate over our loaded plugins for entry in self.servers: if isinstance(entry, (ConfigBase, AppriseConfig)): # load our servers servers = entry.servers() else: servers = [ entry, ] for server in servers: # Apply our tag matching based on our defined logic if is_exclusive_match( logic=tag, data=server.tags, match_all=common.MATCH_ALL_TAG, match_always=match_always, ): yield server return @staticmethod def _extract_filter_retry(tag): """Return the retry override embedded in a filter tag, or None. A filter like "3:endpoint:2" or "endpoint:2" carries ":2" as the call-level retry count. When present it overrides each matched server's configured retry for this single notify() call. """ if tag is None or tag == common.MATCH_ALL_TAG: return None for entry in ( [tag] if isinstance(tag, (str, AppriseTag)) else list(tag) ): if isinstance(entry, (list, tuple, set)): for tok in parse_list(entry): ft = AppriseTag.parse(tok) if ft.retry is not None: return ft.retry else: ft = AppriseTag.parse(str(entry)) if ft.retry is not None: return ft.retry return None @staticmethod def _filter_has_explicit_priority(tag): """Return True if any token in *tag* carries an explicit priority prefix. When True, notify() dispatches matched servers as a flat batch (no escalation) because the caller selected an exact priority level. When False, matched servers are grouped by their own tag priorities and dispatched in ascending order with early-True exit. """ if tag is None or tag == common.MATCH_ALL_TAG: return False for entry in ( [tag] if isinstance(tag, (str, AppriseTag)) else list(tag) ): if isinstance(entry, (list, tuple, set)): for tok in parse_list(entry): if AppriseTag.parse(tok).has_priority: return True else: for tok in parse_list(str(entry)): if AppriseTag.parse(tok).has_priority: return True return False @staticmethod def _server_priority_for_tag_name(server, tag_name): """Return the dispatch priority stored on *server* for *tag_name*. Looks up the AppriseTag in server.tags whose bare name equals *tag_name* and returns its priority. Returns 0 when the tag is absent or stored as a plain string (no explicit priority). """ for stag in server.tags: if str(stag) == tag_name: return stag.priority if isinstance(stag, AppriseTag) else 0 return 0 @staticmethod def _match_service_retry(server, tag): """Return the call-time retry override for *server* given *tag*. Iterates the OR tokens in *tag* in order. The first token that both carries a retry suffix AND matches *server* determines the override. Returns None when no such token exists. Matching follows the same rules as _token_matches_data: - no priority prefix -> name-only match - explicit priority -> name + priority-exact match """ if tag is None or tag == common.MATCH_ALL_TAG: return None for entry in ( [tag] if isinstance(tag, (str, AppriseTag)) else list(tag) ): tokens = ( parse_list(entry) if isinstance(entry, (list, tuple, set)) else parse_list(str(entry)) ) for tok in tokens: ft = AppriseTag.parse(tok) if ft.retry is None: continue tag_name = str(ft) if not ft.has_priority: if tag_name in server.tags: return ft.retry else: for stag in server.tags: if isinstance(stag, AppriseTag): if ( str(stag) == tag_name and stag.priority == ft.priority ): return ft.retry else: if str(stag).lower() == tag_name: return ft.retry return None @staticmethod def _inject_per_service_retries(all_calls, tag): """Return *all_calls* with per-service _retry_override injected. For each (server, kwargs) pair, finds the first filter token in *tag* that matches the service and carries a retry suffix. When found, injects that value as _retry_override so that all dispatch paths (sequential, threadpool, asyncio) pick it up automatically. Services with no matching retry token are left unchanged. """ result = [] for server, kwargs in all_calls: retry = Apprise._match_service_retry(server, tag) if retry is not None: kwargs = dict(kwargs, _retry_override=retry) result.append((server, kwargs)) return result @staticmethod def _build_tag_chains(all_calls, tag): """Group *all_calls* into per-OR-token escalation chains. Returns {chain_key: {priority: [(server, kwargs)]}}. Each service is assigned to the chain of the first OR token whose bare tag name appears in the service's own tags. The chain key is that bare tag name. Services that don't match any token by name fall into a catch-all chain keyed as "". When *tag* is MATCH_ALL_TAG or None, a single chain "" is built using the existing _server_priority_for_filter logic. """ if tag is None or tag == common.MATCH_ALL_TAG: chain: dict[int, list] = {} for server, kwargs in all_calls: p = Apprise._server_priority_for_filter(server, tag) chain.setdefault(p, []).append((server, kwargs)) return {"": chain} # Flatten OR tokens; AND groups are kept as a single opaque entry # that falls through to the catch-all chain. # # The CLI wraps each --tag value in a list via parse_list(), so a # single --tag flag produces a single-element inner list such as # [["alerts:3"]]. A single-element list is always a plain OR token # (there is nothing to AND against), so we treat it the same as a # bare string. A multi-element inner list is a genuine AND condition # (the server must carry every tag in the group) and falls through to # the catch-all chain instead of getting its own independent chain. or_tag_names: list[str] = [] for entry in ( [tag] if isinstance(tag, (str, AppriseTag)) else list(tag) ): if isinstance(entry, (list, tuple, set)): flat = parse_list(*entry) if entry else [] if len(flat) == 1: # single OR token wrapped in a list (CLI convention) or_tag_names.append(str(AppriseTag.parse(flat[0]))) else: or_tag_names.append("") # AND group placeholder else: for tok in parse_list(str(entry)): or_tag_names.append(str(AppriseTag.parse(tok))) chains: dict[str, dict[int, list]] = {} for server, kwargs in all_calls: for chain_key in or_tag_names: if chain_key and chain_key in server.tags: p = Apprise._server_priority_for_tag_name( server, chain_key ) chains.setdefault(chain_key, {}).setdefault(p, []).append( (server, kwargs) ) break else: # fallback: use global priority p = Apprise._server_priority_for_filter(server, tag) chains.setdefault("", {}).setdefault(p, []).append( (server, kwargs) ) return chains @staticmethod def _server_priority_for_filter(server, tag): """Return the effective dispatch priority for *server* given *tag*. The priority comes from the AppriseTag stored on the server whose name matches one of the tag-filter names. When multiple server tags match, the minimum (highest-precedence) priority is returned. Returns 0 when no matching priority tag is found. """ if tag is None or tag == common.MATCH_ALL_TAG: return 0 # Flatten the filter to a set of bare lowercase tag names. filter_names = set() for entry in ( [tag] if isinstance(tag, (str, AppriseTag)) else list(tag) ): if isinstance(entry, (list, tuple, set)): for t in parse_list(entry): filter_names.add(str(AppriseTag.parse(t))) else: for t in parse_list(str(entry)): filter_names.add(str(AppriseTag.parse(t))) priorities = [ stag.priority if isinstance(stag, AppriseTag) else 0 for stag in server.tags if str(stag) in filter_names ] return min(priorities) if priorities else 0 def notify( self, body: Union[str, bytes], title: Union[str, bytes] = "", notify_type: Union[str, common.NotifyType] = common.NotifyType.INFO, body_format: Optional[str] = None, tag: Any = common.MATCH_ALL_TAG, match_always: bool = True, attach: Any = None, interpret_escapes: Optional[bool] = None, ) -> Optional[bool]: """Send a notification to all the plugins previously loaded. If the body_format specified is NotifyFormat.MARKDOWN, it will be converted to HTML if the Notification type expects this. if the tag is specified (either a string or a set/list/tuple of strings), then only the notifications flagged with that tagged value are notified. By default, all added services are notified (tag=MATCH_ALL_TAG) This function returns True if all notifications were successfully sent, False if even just one of them fails, and None if no notifications were sent at all as a result of tag filtering and/or simply having empty configuration files that were read. A filter tag may carry an optional priority prefix and/or retry suffix: "endpoint" -> match all entries; escalate by priority "3:endpoint" -> match ONLY priority-3 entries; flat dispatch "endpoint:2" -> match all entries; retry each up to 2 times "3:endpoint:2" -> exclusive priority-3 match with 2 retries When no priority prefix is given, matched services are grouped by their configured tag priority and dispatched in ascending order (lowest number = highest urgency). If every service in the lowest priority group succeeds, Apprise returns True immediately without running higher-numbered priority groups (escalation chain). When an explicit priority prefix IS given (e.g. "3:endpoint"), only services whose matching tag carries that exact priority are notified, and all matched services are dispatched as a single flat batch. Attach can contain a list of attachment URLs. attach can also be represented by an AttachBase() (or list of) object(s). This identifies the products you wish to notify Set interpret_escapes to True if you want to pre-escape a string such as turning a \n into an actual new line, etc. """ try: all_calls = list( self._create_notify_gen( body, title, notify_type=notify_type, body_format=body_format, tag=tag, match_always=match_always, attach=attach, interpret_escapes=interpret_escapes, ) ) except TypeError: return False if not all_calls: return None # Inject the per-service call-time retry override. Each matched # service gets the retry from the first filter token that both matches # it and carries a retry suffix (e.g. "devops:3" applies retry=3 only # to devops-tagged services, not to management-tagged services). all_calls = Apprise._inject_per_service_retries(all_calls, tag) if Apprise._filter_has_explicit_priority(tag): # Tag filter carries an explicit priority prefix (e.g. "2:alerts"). # Skip the escalation chain: dispatch all matched services as a # single flat batch regardless of their individual tag priorities. sequential = [ (s, k) for s, k in all_calls if not s.asset.async_mode ] parallel = [(s, k) for s, k in all_calls if s.asset.async_mode] seq_ok = ( Apprise._notify_sequential(*sequential) if sequential else True ) par_ok = ( Apprise._notify_parallel_threadpool(*parallel) if parallel else True ) return seq_ok and par_ok # No explicit priority in the filter -- use per-tag escalation chains. # # Each distinct OR token forms an independent chain. Within a chain, # services are grouped by their configured tag priority. The lowest- # numbered group (highest urgency) runs first. If every service in # that group succeeds the chain is done; any failure escalates to the # next priority group. notify() returns True only when every chain # finds a fully-successful group. # # When multiple chains are active in the same round their current # priority-group batches run concurrently via a thread pool so that # one chain's services cannot delay another chain. chains = Apprise._build_tag_chains(all_calls, tag) def _run_batch(batch): """Dispatch one priority-group batch; True = all services ok.""" seq = [(s, k) for s, k in batch if not s.asset.async_mode] par = [(s, k) for s, k in batch if s.asset.async_mode] seq_ok = Apprise._notify_sequential(*seq) if seq else True par_ok = Apprise._notify_parallel_threadpool(*par) if par else True return seq_ok and par_ok # Per-chain state: priorities (sorted), groups dict, current index, # and a flag marking whether a successful group has been found. chain_states = { key: { "priorities": sorted(groups), "groups": groups, "idx": 0, "succeeded": False, } for key, groups in chains.items() } while True: # When abort_on_chain_failure is enabled, stop as soon as any # chain has exhausted all its priority groups without success. # With the default (False) all chains run to completion even if # one has already failed, so every defined URL gets an attempt. if self.asset.abort_on_chain_failure and any( not st["succeeded"] and st["idx"] >= len(st["priorities"]) for st in chain_states.values() ): return False # Collect chains that still need to try their next priority group. active = [ (key, st) for key, st in chain_states.items() if not st["succeeded"] and st["idx"] < len(st["priorities"]) ] if not active: break # every chain has either succeeded or been exhausted if len(active) == 1: # Single active chain: dispatch directly, no thread overhead. _, st = active[0] batch = st["groups"][st["priorities"][st["idx"]]] if _run_batch(batch): st["succeeded"] = True else: st["idx"] += 1 # escalate to next priority else: # Multiple active chains: run their current-priority batches # concurrently so independent chains don't block each other. with cf.ThreadPoolExecutor() as executor: future_map = { executor.submit( _run_batch, st["groups"][st["priorities"][st["idx"]]], ): (key, st) for key, st in active } for future in cf.as_completed(future_map): _, st = future_map[future] try: ok = future.result() except Exception: logger.exception( "Unhandled Notification Exception" ) ok = False if ok: st["succeeded"] = True else: st["idx"] += 1 # escalate to next priority return all(st["succeeded"] for st in chain_states.values()) async def async_notify(self, *args: Any, **kwargs: Any) -> Optional[bool]: """Send a notification to all the plugins previously loaded, for asynchronous callers. The arguments are identical to those of Apprise.notify(). """ tag = kwargs.get("tag", common.MATCH_ALL_TAG) try: all_calls = list(self._create_notify_gen(*args, **kwargs)) except TypeError: return False if not all_calls: return None # Inject per-service call-time retry overrides (same logic as notify). all_calls = Apprise._inject_per_service_retries(all_calls, tag) if Apprise._filter_has_explicit_priority(tag): # Explicit priority prefix: flat dispatch, no escalation. sequential = [ (s, k) for s, k in all_calls if not s.asset.async_mode ] parallel = [(s, k) for s, k in all_calls if s.asset.async_mode] seq_ok = ( Apprise._notify_sequential(*sequential) if sequential else True ) par_ok = ( await Apprise._notify_parallel_asyncio(*parallel) if parallel else True ) return seq_ok and par_ok # Per-tag independent escalation chains -- same semantics as notify(). # # Each chain's current-priority batch is dispatched as a coroutine. # All active chains' batches run concurrently via asyncio.gather() so # async services across independent chains can make I/O progress # simultaneously. Only chains whose current batch failed advance to # the next priority group (escalation). chains = Apprise._build_tag_chains(all_calls, tag) async def _run_batch_async(batch): """Dispatch one priority-group batch; True = all services ok.""" seq = [(s, k) for s, k in batch if not s.asset.async_mode] par = [(s, k) for s, k in batch if s.asset.async_mode] # Sequential items (async_mode=False) run blocking in the caller; # async items are gathered concurrently. seq_ok = Apprise._notify_sequential(*seq) if seq else True par_ok = ( await Apprise._notify_parallel_asyncio(*par) if par else True ) return seq_ok and par_ok chain_states = { key: { "priorities": sorted(groups), "groups": groups, "idx": 0, "succeeded": False, } for key, groups in chains.items() } while True: # Same abort_on_chain_failure guard as notify(). if self.asset.abort_on_chain_failure and any( not st["succeeded"] and st["idx"] >= len(st["priorities"]) for st in chain_states.values() ): return False active = [ (key, st) for key, st in chain_states.items() if not st["succeeded"] and st["idx"] < len(st["priorities"]) ] if not active: break # every chain has either succeeded or been exhausted # Run all active chains' current-priority batches concurrently. # asyncio.gather() interleaves coroutines so async services across # different chains can pipeline their I/O simultaneously. # return_exceptions=True prevents one failing batch from cancelling # the others; exceptions are treated as delivery failures below. results = await asyncio.gather( *( _run_batch_async(st["groups"][st["priorities"][st["idx"]]]) for _, st in active ), return_exceptions=True, ) for (_, st), ok in zip(active, results): if isinstance(ok, Exception): # Escaped exception -- safety net; treat as failure. logger.exception("Unhandled Notification Exception") st["idx"] += 1 elif ok: st["succeeded"] = True else: st["idx"] += 1 # escalate to next priority group return all(st["succeeded"] for st in chain_states.values()) def _create_notify_calls(self, *args, **kwargs): """Creates notifications for all the plugins loaded. Returns a list of (server, notify() kwargs) tuples for plugins with parallelism disabled and another list for plugins with parallelism enabled. """ all_calls = list(self._create_notify_gen(*args, **kwargs)) # Split into sequential and parallel notify() calls. sequential, parallel = [], [] for server, notify_kwargs in all_calls: if server.asset.async_mode: parallel.append((server, notify_kwargs)) else: sequential.append((server, notify_kwargs)) return sequential, parallel def _create_notify_gen( self, body, title="", notify_type=common.NotifyType.INFO, body_format=None, tag=common.MATCH_ALL_TAG, match_always=True, attach=None, interpret_escapes=None, ): """Internal generator function for _create_notify_calls().""" if len(self) == 0: # Nothing to notify msg = "There are no service(s) to notify" logger.error(msg) raise TypeError(msg) if not (title or body or attach): msg = "No message content specified to deliver" logger.error(msg) raise TypeError(msg) try: notify_type = ( notify_type if isinstance(notify_type, common.NotifyType) else common.NotifyType(notify_type.lower()) ) except (AttributeError, ValueError, TypeError): err = ( f"An invalid notification type ({notify_type}) was specified." ) raise TypeError(err) from None try: if title and isinstance(title, bytes): title = title.decode(self.asset.encoding) if body and isinstance(body, bytes): body = body.decode(self.asset.encoding) except UnicodeDecodeError: msg = ( "The content passed into Apprise was not of encoding " f"type: {self.asset.encoding}" ) logger.error(msg) raise TypeError(msg) from None # Tracks conversions conversion_body_map = {} conversion_title_map = {} # Prepare attachments if required if attach is not None and not isinstance(attach, AppriseAttachment): attach = AppriseAttachment( attach, asset=self.asset, location=self.location ) # Allow Asset default value body_format = ( self.asset.body_format if body_format is None else body_format ) # Allow Asset default value interpret_escapes = ( self.asset.interpret_escapes if interpret_escapes is None else interpret_escapes ) # Iterate over our loaded plugins for server in self.find(tag, match_always=match_always): # If our code reaches here, we either did not define a tag (it # was set to None), or we did define a tag and the logic above # determined we need to notify the service it's associated with # First we need to generate a key we will use to determine if we # need to build our data out. Entries without are merged with # the body at this stage. key = ( server.notify_format if server.title_maxlen > 0 else f"_{server.notify_format}" ) if server.interpret_emojis: # alter our key slightly to handle emojis since their value is # pulled out of the notification key += "-emojis" if key not in conversion_title_map: # Prepare our title conversion_title_map[key] = title if title else "" # Conversion of title only occurs for services where the title # is blended with the body (title_maxlen <= 0) if conversion_title_map[key] and server.title_maxlen <= 0: conversion_title_map[key] = convert_between( body_format, server.notify_format, content=conversion_title_map[key], ) # Our body is always converted no matter what conversion_body_map[key] = convert_between( body_format, server.notify_format, content=body ) if interpret_escapes: # # Escape our content # try: # Added overhead required due to Python 3 Encoding Bug # identified here: https://bugs.python.org/issue21331 conversion_body_map[key] = ( conversion_body_map[key] .encode("ascii", "backslashreplace") .decode("unicode-escape") ) conversion_title_map[key] = ( conversion_title_map[key] .encode("ascii", "backslashreplace") .decode("unicode-escape") ) except AttributeError: # Must be of string type msg = "Failed to escape message body" logger.error(msg) raise TypeError(msg) from None if server.interpret_emojis: # # Convert our :emoji: definitions # conversion_body_map[key] = apply_emojis( conversion_body_map[key] ) conversion_title_map[key] = apply_emojis( conversion_title_map[key] ) kwargs = { "body": conversion_body_map[key], "title": conversion_title_map[key], "notify_type": notify_type, "attach": attach, "body_format": body_format, } yield (server, kwargs) @staticmethod def _notify_sequential(*servers_kwargs): """Process a list of notify() calls sequentially and synchronously. Each server is attempted once and then retried up to server.retry additional times on failure before moving on. When server.wait is greater than zero, the process sleeps that many seconds between each retry attempt. A per-call retry override may be injected into kwargs under the key ``_retry_override``; when present it takes precedence over the server's own retry attribute for this invocation only. Exceptions raised by a plugin's notify() -- including those from third-party @notify-decorated functions that are outside our control -- are caught here and treated as a delivery failure. The retry logic still applies, so a plugin that raises on the first attempt will be retried the configured number of times before giving up. """ success = True for server, kwargs in servers_kwargs: # Pop the per-call override before forwarding kwargs to the # plugin so it never sees the internal _retry_override key. retry = kwargs.pop("_retry_override", getattr(server, "retry", 0)) wait = getattr(server, "wait", 0.0) result = False for attempt in range(retry + 1): # Attempt delivery. TypeError comes from Apprise's own # validation; bare Exception guards against buggy or # third-party plugins (including @notify decorators) that # may raise unexpectedly. Both are treated as failure so # the retry loop can continue. try: result = server.notify(**kwargs) except TypeError: result = False except Exception: logger.exception("Unhandled Notification Exception") result = False if result: # Delivered successfully; no need to retry this server. break if attempt < retry: # Delivery failed and retries remain. Log the attempt # number and pause before the next try. logger.warning( "Retry %d/%d for %s", attempt + 1, retry, server.service_name, ) if wait > 0: time.sleep(wait) # Optional-service check. # # At this point all retry attempts for 'server' have been # exhausted (the for-loop above has finished). If the final # result is still False *and* the service is marked optional, # we overwrite result to True before folding it into the # running 'success' accumulator. This silently absorbs the # failure: the caller will not see it as a delivery error. # # Interaction with retries: # The retry loop above has already run. optional= does not # short-circuit or bypass retries -- it only changes the # interpretation of the *final* result once all attempts # are done. A service with retry=3 and optional=True will # still be attempted four times before the failure is # absorbed here. if not result and getattr(server, "optional", False): logger.info( "Optional service '%s' failed; ignoring failure.", server.service_name, ) result = True # Fold this service's result into the running batch outcome. # Boolean AND is used so that a single False from any required # (non-optional) service permanently taints 'success' for the # whole batch -- even if later services succeed. Optional # failures are already re-mapped to True above, so they never # contribute a False here. success = success and result return success @staticmethod def _notify_parallel_threadpool(*servers_kwargs): """Process a list of notify() calls in parallel via a thread pool. Each server runs in its own thread. Within each thread, the server is retried up to server.retry additional times on failure with an optional server.wait second sleep between each attempt. Falls back to _notify_sequential() when only a single server is given to avoid the overhead of spawning a thread pool for one call. Exceptions from a plugin's notify() -- including those from third-party @notify-decorated functions -- are caught inside each thread and treated as delivery failures so the retry logic can still run. """ n_calls = len(servers_kwargs) if n_calls == 0: return True # Avoid thread-pool overhead for a single notification. if n_calls == 1: return Apprise._notify_sequential(servers_kwargs[0]) logger.info( "Notifying %d service(s) with threads.", len(servers_kwargs) ) def _call_with_retry(server, kwargs): """Execute one server's notify() with retry/wait logic. Runs inside a worker thread. Pops ``_retry_override`` from kwargs so it is never forwarded to the plugin's notify() call. Exceptions are caught and treated as failures so the retry loop continues even when a plugin raises unexpectedly. """ # Pop the per-call override so it stays internal. retry = kwargs.pop("_retry_override", getattr(server, "retry", 0)) wait = getattr(server, "wait", 0.0) result = False for attempt in range(retry + 1): # Same exception handling as _notify_sequential: TypeError # from Apprise validation and bare Exception for buggy or # third-party plugins both map to a retriable failure. try: result = server.notify(**kwargs) except TypeError: result = False except Exception: logger.exception("Unhandled Notification Exception") result = False if result: return True if attempt < retry: logger.warning( "Retry %d/%d for %s", attempt + 1, retry, server.service_name, ) if wait > 0: time.sleep(wait) # Optional-service check (thread-pool path). # # All retry attempts for this server have been exhausted by the # loop above. If the final result is still False and the service # is tagged as optional, return True from this worker function # instead of False. The caller (_notify_parallel_threadpool) # collects each worker's return value via future.result() and # ANDs them together; returning True here prevents this worker's # failure from tainting the aggregate result. # # This is the thread-pool equivalent of the same check in # _notify_sequential. See the comment there for a full # explanation of the getattr() guard and the retry interaction. if not result and getattr(server, "optional", False): logger.info( "Optional service '%s' failed; ignoring failure.", server.service_name, ) # Return True so future.result() in the caller reports # success for this optional worker thread. return True # Every attempt for this service failed and it is not optional; # propagate the failure to the caller. return result # Submit all server calls to the thread pool and collect results. with cf.ThreadPoolExecutor() as executor: success = True futures = [ executor.submit(_call_with_retry, server, kwargs) for (server, kwargs) in servers_kwargs ] for future in cf.as_completed(futures): # future.result() re-raises any exception that escaped # _call_with_retry (should not happen given the inner # try/except, but guard here as a safety net). try: success = success and future.result() except Exception: logger.exception("Unhandled Notification Exception") success = False return success @staticmethod async def _notify_parallel_asyncio(*servers_kwargs): """Process a list of async_notify() calls concurrently via asyncio. All coroutines are gathered with asyncio.gather(). Each server is retried up to server.retry additional times on failure with an optional asyncio.sleep(server.wait) between attempts. Unlike the thread-pool path, there is no single-server optimisation here because asyncio can pipeline work across coroutines while one is awaiting I/O. Exceptions from a plugin's async_notify() -- including those from third-party @notify-decorated coroutines -- are caught inside each coroutine and treated as delivery failures so the retry loop can still run for that service. """ n_calls = len(servers_kwargs) if n_calls == 0: return True logger.info( "Notifying %d service(s) asynchronously.", len(servers_kwargs) ) async def do_call(server, kwargs): """Coroutine driving one server's async_notify() with retry/wait. Pops ``_retry_override`` from kwargs so it is never forwarded to the plugin. Exceptions are caught and treated as failures so the retry loop continues even when a plugin raises unexpectedly (e.g. a third-party @notify-decorated coroutine). """ # Pop the per-call override so it stays internal. retry = kwargs.pop("_retry_override", getattr(server, "retry", 0)) wait = getattr(server, "wait", 0.0) result = False for attempt in range(retry + 1): # Mirror the exception handling from the synchronous paths: # TypeError from Apprise's own validation layer and bare # Exception for any plugin that raises unexpectedly are both # treated as retriable failures rather than hard crashes. try: result = await server.async_notify(**kwargs) except TypeError: result = False except Exception: logger.exception("Unhandled Notification Exception") result = False if result: return True if attempt < retry: logger.warning( "Retry %d/%d for %s", attempt + 1, retry, server.service_name, ) if wait > 0: await asyncio.sleep(wait) # Optional-service check (asyncio coroutine path). # # All retry attempts have been exhausted by the async loop # above. If the final result is still False and the service # is tagged optional, return True from this coroutine so that # asyncio.gather() receives a truthy value for this task. # The caller inspects all gathered results with all(); a True # here ensures this coroutine does not lower the aggregate # result for the batch. # # This is the asyncio equivalent of the same check in # _notify_sequential and _call_with_retry. See the comment # in _notify_sequential for a full explanation of the getattr() # guard and the interaction with the retry count. if not result and getattr(server, "optional", False): logger.info( "Optional service '%s' failed; ignoring failure.", server.service_name, ) # Return True so asyncio.gather() sees a success value # for this optional coroutine. return True # Every attempt for this service failed and it is not optional; # propagate the failure to the caller. return result # Run all coroutines concurrently. return_exceptions=True ensures # that one coroutine raising does not cancel the others; any escaped # exception (beyond what do_call already handles) is caught below. cors = (do_call(server, kwargs) for (server, kwargs) in servers_kwargs) results = await asyncio.gather(*cors, return_exceptions=True) if any(isinstance(status, Exception) for status in results): # Safety net: an exception escaped do_call's own try/except. # Log each one and treat the whole batch as failed. for status in results: if isinstance(status, Exception): logger.error( "Unhandled Notification Exception: %s", status ) return False return all(results) def json( self, lang: Optional[str] = None, show_requirements: bool = False, show_disabled: bool = False, indent: Optional[int] = None, path: Optional[str] = None, ) -> Union[str, bool]: """Returns a json response associated with the Apprise object.""" details = self.details( lang=lang, show_requirements=show_requirements, show_disabled=show_disabled, ) if not path: return json.dumps( details, separators=(",", ":"), indent=indent, cls=AppriseJSONEncoder, ) with open(path, "w") as fp: try: json.dump( details, fp, separators=(",", ":"), indent=indent, cls=AppriseJSONEncoder, ensure_ascii=False, ) except (OSError, EOFError) as e: logger.error("Apprise details dumpfile inaccessible: %s", path) logger.debug("Apprise details dump Exception: %s", e) # Early Exit return False finally: # Reduce memory del details return True def details( self, lang: Optional[str] = None, show_requirements: bool = False, show_disabled: bool = False, ) -> dict[str, Any]: """Returns the details associated with the Apprise object.""" # general object returned response = { # Defines the current version of Apprise "version": __version__, # Lists all of the currently supported Notifications "schemas": [], # Includes the configured asset details "asset": self.asset.details(), } for plugin in N_MGR.plugins(): # Iterate over our hashed plugins and dynamically build details on # their status: content = { "service_name": getattr(plugin, "service_name", None), "service_url": getattr(plugin, "service_url", None), "setup_url": getattr(plugin, "setup_url", None), # Placeholder - populated below "details": None, # Let upstream service know of the plugins that support # attachments "attachment_support": getattr( plugin, "attachment_support", False ), # Differentiat between what is a custom loaded plugin and # which is native. "category": getattr(plugin, "category", None), } # Standard protocol(s) should be None or a tuple enabled = getattr(plugin, "enabled", True) if not show_disabled and not enabled: # Do not show inactive plugins continue elif show_disabled: # Add current state to response content["enabled"] = enabled # Standard protocol(s) should be None or a tuple protocols = getattr(plugin, "protocol", None) if isinstance(protocols, str): protocols = (protocols,) # Secure protocol(s) should be None or a tuple secure_protocols = getattr(plugin, "secure_protocol", None) if isinstance(secure_protocols, str): secure_protocols = (secure_protocols,) # Add our protocol details to our content content.update( { "protocols": protocols, "secure_protocols": secure_protocols, } ) if not lang: # Simply return our results content["details"] = plugins.details(plugin) if show_requirements: content["requirements"] = plugins.requirements(plugin) else: # Emulate the specified language when returning our results with self.locale.lang_at(lang): content["details"] = plugins.details(plugin) if show_requirements: content["requirements"] = plugins.requirements(plugin) # Build our response object response["schemas"].append(content) return response def urls(self, privacy: bool = False) -> list[str]: """Returns all of the loaded URLs defined in this apprise object.""" urls = [] for s in self.servers: if isinstance(s, (ConfigBase, AppriseConfig)): for s_ in s.servers(): urls.append(s_.url(privacy=privacy)) else: urls.append(s.url(privacy=privacy)) return urls def pop(self, index: int) -> NotifyBase: """Removes an indexed Notification Service from the stack and returns it. The thing is we can never pop AppriseConfig() entries, only what was loaded within them. So pop needs to carefully iterate over our list and only track actual entries. """ # Tracking variables prev_offset = -1 offset = prev_offset for idx, s in enumerate(self.servers): if isinstance(s, (ConfigBase, AppriseConfig)): servers = s.servers() if len(servers) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(servers) if offset >= index: # we can pop an element from our config stack fn = ( s.pop if isinstance(s, ConfigBase) else s.server_pop ) return fn( index if prev_offset == -1 else (index - prev_offset - 1) ) else: offset = prev_offset + 1 if offset == index: return self.servers.pop(idx) # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def __getitem__(self, index: int) -> NotifyBase: """Returns the indexed server entry of a loaded notification server.""" # Tracking variables prev_offset = -1 offset = prev_offset for idx, s in enumerate(self.servers): if isinstance(s, (ConfigBase, AppriseConfig)): # Get our list of servers associate with our config object servers = s.servers() if len(servers) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(servers) if offset >= index: return servers[ ( index if prev_offset == -1 else (index - prev_offset - 1) ) ] else: offset = prev_offset + 1 if offset == index: return self.servers[idx] # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def __getstate__(self) -> dict[str, object]: """Pickle Support dumps()""" attributes = { "asset": self.asset, # Prepare our URL list as we need to extract the associated tags # and asset details associated with it "urls": [ { "url": server.url(privacy=False), "tag": server.tags if server.tags else None, "asset": server.asset, } for server in self.servers ], "locale": self.locale, "debug": self.debug, "location": self.location.value if self.location else None, } return attributes def __setstate__(self, state: dict[str, object]) -> None: """Pickle Support loads()""" self.servers = [] self.asset = state["asset"] self.locale = state["locale"] location = state.get("location") self.location = ( location if isinstance(location, ContentLocation) else ContentLocation(location) if location is not None else None ) for entry in state["urls"]: self.add(entry["url"], asset=entry["asset"], tag=entry["tag"]) def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return len(self) > 0 def __iter__(self) -> Iterator[NotifyBase]: """Returns an iterator to each of our servers loaded. This includes those found inside configuration. """ return chain( *[ ( [s] if not isinstance(s, (ConfigBase, AppriseConfig)) else iter(s.servers()) ) for s in self.servers ] ) def __len__(self) -> int: """Returns the number of servers loaded; this includes those found within loaded configuration. This funtion nnever actually counts the Config entry themselves (if they exist), only what they contain. """ return sum( ( 1 if not isinstance(s, (ConfigBase, AppriseConfig)) else len(s.servers()) ) for s in self.servers ) caronc-apprise-182f859/apprise/apprise_attachment.py000066400000000000000000000325471524161175200226170ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from collections.abc import Iterator from typing import Any, Optional, Union from .asset import AppriseAsset from .attachment.base import AttachBase from .common import ContentLocation from .logger import logger from .manager_attachment import AttachmentManager from .url import URLBase from .utils.parse import GET_SCHEMA_RE # Grant access to our Notification Manager Singleton A_MGR = AttachmentManager() class AppriseAttachment: """Our Apprise Attachment File Manager.""" def __init__( self, paths: Optional[ Union[str, list[Union[str, AttachBase, "AppriseAttachment"]]] ] = None, asset: Optional[AppriseAsset] = None, cache: Union[bool, int] = True, location: Optional[Union[str, ContentLocation]] = None, **kwargs: Any, ) -> None: """Loads all of the paths/urls specified (if any). The path can either be a single string identifying one explicit location, otherwise you can pass in a series of locations to scan via a list. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass AttachBase() Optionally set your current ContentLocation in the location argument. This is used to further handle attachments. The rules are as follows: - INACCESSIBLE: You simply have disabled use of the object; no attachments will be retrieved/handled. - HOSTED: You are hosting an attachment service for others. In these circumstances all attachments that are LOCAL based (such as file://) will not be allowed. - LOCAL: The least restrictive mode as local files can be referenced in addition to hosted. In all but HOSTED and LOCAL modes, INACCESSIBLE attachment types will continue to be inaccessible. However if you set this field (location) to None (it's default value) the attachment location category will not be tested in any way (all attachment types will be allowed). The location field is also a global option that can be set when initializing the Apprise object. """ # Initialize our attachment listings self.attachments = [] # Set our cache flag self.cache = cache # Prepare our Asset Object self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if location: try: self.location = ( location if isinstance(location, ContentLocation) else ContentLocation(location.lower()) ) except (AttributeError, ValueError): err = ( f"An invalid Attachment location ({location}) was " "specified.", ) logger.warning(err) raise TypeError(err) from None else: # do not set location if no initialization was made for it self.location = None # Now parse any paths specified if paths is not None and not self.add(paths): raise TypeError("One or more attachments could not be added.") def add( self, attachments: Union[ str, AttachBase, "AppriseAttachment", list[Union[str, AttachBase, "AppriseAttachment"]], ], asset: Optional[AppriseAsset] = None, cache: Optional[Union[bool, int]] = None, ) -> bool: """Adds one or more attachments into our list. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass AttachBase() """ # Initialize our return status return_status = True # Initialize our default cache value cache = cache if cache is not None else self.cache if asset is None: # prepare default asset asset = self.asset if isinstance(attachments, (AttachBase, str)): # store our instance attachments = (attachments,) elif not isinstance(attachments, (tuple, set, list)): logger.error( f"An invalid attachment url (type={type(attachments)}) was " "specified." ) return False # Iterate over our attachments for attachment in attachments: if self.location == ContentLocation.INACCESSIBLE: logger.warning( f"Attachments are disabled; ignoring {attachment}" ) return_status = False continue if isinstance(attachment, str): logger.debug(f"Loading attachment: {attachment}") # Instantiate ourselves an object, this function throws or # returns None if it fails instance = AppriseAttachment.instantiate( attachment, asset=asset, cache=cache ) if not isinstance(instance, AttachBase): return_status = False continue elif isinstance(attachment, AppriseAttachment): # We were provided a list of Apprise Attachments # append our content together instance = attachment.attachments elif not isinstance(attachment, AttachBase): logger.warning( f"An invalid attachment (type={type(attachment)}) was" " specified." ) return_status = False continue else: # our entry is of type AttachBase, so just go ahead and point # our instance to it for some post processing below instance = attachment # Apply some simple logic if our location flag is set if self.location and ( ( self.location == ContentLocation.HOSTED and instance.location != ContentLocation.HOSTED ) or instance.location == ContentLocation.INACCESSIBLE ): logger.warning( "Attachment was disallowed due to accessibility" f" restrictions ({self.location}->{instance.location}):" f" {instance.url(privacy=True)}" ) return_status = False continue # Add our initialized plugin to our server listings if isinstance(instance, list): self.attachments.extend(instance) else: self.attachments.append(instance) # Return our status return return_status @staticmethod def instantiate( url: str, asset: Optional[AppriseAsset] = None, cache: Optional[Union[bool, int]] = None, suppress_exceptions: bool = True, ) -> Optional[AttachBase]: """Returns the instance of a instantiated attachment plugin based on the provided Attachment URL. If the url fails to be parsed, then None is returned. A specified cache value will over-ride anything set """ # Attempt to acquire the schema at the very least to allow our # attachment based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in A_MGR: logger.warning(f"Unsupported schema {schema}.") return None # Parse our url details of the server object as dictionary containing # all of the information parsed from our URL results = A_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL logger.warning(f"Unparseable URL {url}.") return None # Prepare our Asset Object results["asset"] = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if cache is not None: # Force an over-ride of the cache value to what we have specified results["cache"] = cache if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information attach_plugin = A_MGR[results["schema"]](**results) except Exception: # the arguments are invalid or can not be used. logger.warning(f"Could not load URL: {url}") return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch attach_plugin = A_MGR[results["schema"]](**results) return attach_plugin def sync( self, abort_on_error: bool = True, abort_if_empty: bool = True, ) -> bool: """Itereates over all of the attachments and retrieves them.""" return ( False if abort_if_empty and not self.attachments else ( next((False for a in self.attachments if not a), True) if abort_on_error else next((True for a in self.attachments), True) ) ) def clear(self) -> None: """Empties our attachment list.""" self.attachments[:] = [] def size(self) -> int: """Returns the total size of accumulated attachments.""" return sum(len(a) for a in self.attachments if len(a) > 0) def pop(self, index: int = -1) -> AttachBase: """Removes an indexed Apprise Attachment from the stack and returns it. by default the last element is poped from the list """ # Remove our entry return self.attachments.pop(index) def __getitem__(self, index: int) -> AttachBase: """Returns the indexed entry of a loaded apprise attachments.""" return self.attachments[index] def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return bool(self.attachments) def __iter__(self) -> Iterator[AttachBase]: """Returns an iterator to our attachment list.""" return iter(self.attachments) def __len__(self) -> int: """Returns the number of attachment entries loaded.""" return len(self.attachments) caronc-apprise-182f859/apprise/apprise_config.py000066400000000000000000000441511524161175200217260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations from typing import TYPE_CHECKING, Any from . import common from .asset import AppriseAsset from .config.base import ConfigBase from .logger import logger from .manager_config import ConfigurationManager from .url import URLBase from .utils.cwe312 import cwe312_url from .utils.logic import is_exclusive_match from .utils.parse import GET_SCHEMA_RE, parse_list if TYPE_CHECKING: from .plugins.base import NotifyBase # Grant access to our Configuration Manager Singleton C_MGR = ConfigurationManager() class AppriseConfig: """Our Apprise Configuration File Manager. - Supports a list of URLs defined one after another (text format) - Supports a destinct YAML configuration format """ def __init__( self, paths: str | list[str] | None = None, asset: AppriseAsset | None = None, cache: bool | int = True, recursion: int = 0, insecure_includes: bool = False, **kwargs: Any, ) -> None: """Loads all of the paths specified (if any). The path can either be a single string identifying one explicit location, otherwise you can pass in a series of locations to scan via a list. If no path is specified then a default list is used. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. Setting this to False does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled and you're set up to make remote calls. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass ConfigBase() recursion defines how deep we recursively handle entries that use the `import` keyword. This keyword requires us to fetch more configuration from another source and add it to our existing compilation. If the file we remotely retrieve also has an `import` reference, we will only advance through it if recursion is set to 2 deep. If set to zero it is off. There is no limit to how high you set this value. It would be recommended to keep it low if you do intend to use it. insecure includes by default are disabled. When set to True, all Apprise Config files marked to be in STRICT mode are treated as being in ALWAYS mode. Take a file:// based configuration for example, only a file:// based configuration can import another file:// based one. because it is set to STRICT mode. If an http:// based configuration file attempted to import a file:// one it woul fail. However this import would be possible if insecure_includes is set to True. There are cases where a self hosting apprise developer may wish to load configuration from memory (in a string format) that contains import entries (even file:// based ones). In these circumstances if you want these includes to be honored, this value must be set to True. """ # Initialize a server list of URLs self.configs = [] # Prepare our Asset Object self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) # Set our cache flag self.cache = cache # Initialize our recursion value self.recursion = recursion # Initialize our insecure_includes flag self.insecure_includes = insecure_includes if paths is not None: # Store our path(s) self.add(paths) return def add( self, configs: str | ConfigBase | list[str | ConfigBase], asset: AppriseAsset | None = None, tag: str | list[str] | None = None, cache: bool | int = True, recursion: int | None = None, insecure_includes: bool | None = None, ) -> bool: """Adds one or more config URLs into our list. You can override the global asset if you wish by including it with the config(s) that you add. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. Setting this to False does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled and you're set up to make remote calls. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass ConfigBase() Optionally override the default recursion value. Optionally override the insecure_includes flag. if insecure_includes is set to True then all plugins that are set to a STRICT mode will be a treated as ALWAYS. """ # Initialize our return status return_status = True # Initialize our default cache value cache = cache if cache is not None else self.cache # Initialize our default recursion value recursion = recursion if recursion is not None else self.recursion # Initialize our default insecure_includes value insecure_includes = ( insecure_includes if insecure_includes is not None else self.insecure_includes ) if asset is None: # prepare default asset asset = self.asset if isinstance(configs, ConfigBase): # Go ahead and just add our configuration into our list self.configs.append(configs) return True elif isinstance(configs, str): # Save our path configs = (configs,) elif not isinstance(configs, (tuple, set, list)): logger.error( f"An invalid configuration path (type={type(configs)}) was " "specified." ) return False # Iterate over our configuration for config in configs: if isinstance(config, ConfigBase): # Go ahead and just add our configuration into our list self.configs.append(config) continue elif not isinstance(config, str): logger.warning( f"An invalid configuration (type={type(config)}) was" " specified." ) return_status = False continue logger.debug(f"Loading configuration: {config}") # Instantiate ourselves an object, this function throws or # returns None if it fails instance = AppriseConfig.instantiate( config, asset=asset, tag=tag, cache=cache, recursion=recursion, insecure_includes=insecure_includes, ) if not isinstance(instance, ConfigBase): return_status = False continue # Add our initialized plugin to our server listings self.configs.append(instance) # Return our status return return_status def add_config( self, content: str, asset: AppriseAsset | None = None, tag: str | list[str] | None = None, format: str | None = None, recursion: int | None = None, insecure_includes: bool | None = None, ) -> bool: """Adds one configuration file in it's raw format. Content gets loaded as a memory based object and only exists for the life of this AppriseConfig object it was loaded into. If you know the format ('yaml' or 'text') you can specify it for slightly less overhead during this call. Otherwise the configuration is auto-detected. Optionally override the default recursion value. Optionally override the insecure_includes flag. if insecure_includes is set to True then all plugins that are set to a STRICT mode will be a treated as ALWAYS. """ # Initialize our default recursion value recursion = recursion if recursion is not None else self.recursion # Initialize our default insecure_includes value insecure_includes = ( insecure_includes if insecure_includes is not None else self.insecure_includes ) if asset is None: # prepare default asset asset = self.asset if not isinstance(content, str): logger.warning( f"An invalid configuration (type={type(content)}) was" " specified." ) return False logger.debug(f"Loading raw configuration: {content}") # Create ourselves a ConfigMemory Object to store our configuration instance = C_MGR["memory"]( content=content, format=format, asset=asset, tag=tag, recursion=recursion, insecure_includes=insecure_includes, ) if not ( instance.config_format and instance.config_format.value in common.CONFIG_FORMATS ): logger.warning( "The format of the configuration could not be detected." ) return False # Add our initialized plugin to our server listings self.configs.append(instance) # Return our status return True def servers( self, tag: str | list[str] = common.MATCH_ALL_TAG, match_always: bool = True, *args: Any, **kwargs: Any, ) -> list[NotifyBase]: """Returns all of our servers dynamically build based on parsed configuration. If a tag is specified, it applies to the configuration sources themselves and not the notification services inside them. This is for filtering the configuration files polled for results. If the anytag is set, then any notification that is found set with that tag are included in the response. """ # A match_always flag allows us to pick up on our 'any' keyword # and notify these services under all circumstances match_always = common.MATCH_ALWAYS_TAG if match_always else None # Build our tag setup # - top level entries are treated as an 'or' # - second level (or more) entries are treated as 'and' # # examples: # tag="tagA, tagB" = tagA or tagB # tag=['tagA', 'tagB'] = tagA or tagB # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB # tag=[('tagB', 'tagC')] = tagB and tagC response = [] for entry in self.configs: # Apply our tag matching based on our defined logic if is_exclusive_match( logic=tag, data=entry.tags, match_all=common.MATCH_ALL_TAG, match_always=match_always, ): # Build ourselves a list of services dynamically and return the # as a list response.extend(entry.servers()) return response @staticmethod def instantiate( url: str, asset: AppriseAsset | None = None, tag: str | list[str] | None = None, cache: bool | int | None = None, recursion: int = 0, insecure_includes: bool = False, suppress_exceptions: bool = True, ) -> ConfigBase | None: """Returns the instance of a instantiated configuration plugin based on the provided Config URL. If the url fails to be parsed, then None is returned. """ # Attempt to acquire the schema at the very least to allow our # configuration based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in C_MGR: logger.error(f"Unsupported schema {schema}.") return None # Parse our url details of the server object as dictionary containing # all of the information parsed from our URL results = C_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL # CWE-312 (Secure Logging) Handling secure_logging = ( asset.secure_logging if isinstance(asset, AppriseAsset) else True ) loggable_url = url if not secure_logging else cwe312_url(url) logger.error(f"Unparseable URL {loggable_url}.") return None # Build a list of tags to associate with the newly added notifications results["tag"] = set(parse_list(tag)) # Prepare our Asset Object results["asset"] = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if cache is not None: # Force an over-ride of the cache value to what we have specified results["cache"] = cache # Recursion can never be parsed from the URL results["recursion"] = recursion # Insecure includes flag can never be parsed from the URL results["insecure_includes"] = insecure_includes if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information cfg_plugin = C_MGR[results["schema"]](**results) except Exception: # the arguments are invalid or can not be used. # CWE-312 (Secure Logging) Handling loggable_url = ( url if not results["asset"].secure_logging else cwe312_url(url) ) logger.error(f"Could not load URL: {loggable_url}") return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch cfg_plugin = C_MGR[results["schema"]](**results) return cfg_plugin def clear(self) -> None: """Empties our configuration list.""" self.configs[:] = [] def server_pop(self, index: int) -> NotifyBase: """Removes an indexed Apprise Notification from the servers.""" # Tracking variables prev_offset = -1 offset = prev_offset for entry in self.configs: servers = entry.servers(cache=True) if len(servers) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(servers) if offset >= index: # we can pop an notification from our config stack return entry.pop( index if prev_offset == -1 else (index - prev_offset - 1) ) # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def pop(self, index: int = -1) -> ConfigBase: """Removes an indexed Apprise Configuration from the stack and returns it. By default, the last element is removed from the list """ # Remove our entry return self.configs.pop(index) def __getitem__(self, index: int) -> ConfigBase: """Returns the indexed config entry of a loaded apprise configuration.""" return self.configs[index] def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return bool(self.configs) def __iter__(self): # type: () -> Iterator[ConfigBase] """Returns an iterator to our config list.""" return iter(self.configs) def __len__(self) -> int: """Returns the number of config entries loaded.""" return len(self.configs) caronc-apprise-182f859/apprise/asset.py000066400000000000000000000450701524161175200200560ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, tzinfo from os.path import abspath, dirname, isfile, join import re from typing import Any, Optional, Union from uuid import uuid4 from .common import ( NotifyFormat, NotifyImageSize, NotifyType, PersistentStoreMode, ) from .manager_plugins import NotificationManager from .utils.time import zoneinfo # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() class AppriseAsset: """Provides a supplimentary class that can be used to provide extra information and details that can be used by Apprise such as providing an alternate location to where images/icons can be found and the URL masks. Any variable that starts with an underscore (_) can only be initialized by this class manually and will/can not be parsed from a configuration file. """ # Application Identifier app_id = "Apprise" # Application Description app_desc = "Apprise Notifications" # Provider URL app_url = "https://github.com/caronc/apprise" # A Simple Mapping of Colors; For every NOTIFY_TYPE identified, # there should be a mapping to it's color here: html_notify_map = { NotifyType.INFO: "#3AA3E3", NotifyType.SUCCESS: "#3AA337", NotifyType.FAILURE: "#A32037", NotifyType.WARNING: "#CACF29", } # The default color to return if a mapping isn't found in our table above default_html_color = "#888888" # Ascii Notification ascii_notify_map = { NotifyType.INFO: "[i]", NotifyType.SUCCESS: "[+]", NotifyType.FAILURE: "[!]", NotifyType.WARNING: "[~]", } # The default ascii to return if a mapping isn't found in our table above default_ascii_chars = "[?]" # The default image extension to use default_extension = ".png" # The default image size if one isn't specified default_image_size = NotifyImageSize.XY_256 # The default theme theme = "default" # Image URL Mask image_url_mask = ( "https://github.com/caronc/apprise/raw/master/apprise/assets/" "themes/{THEME}/apprise-{TYPE}-{XY}{EXTENSION}" ) # Application Logo image_url_logo = ( "https://github.com/caronc/apprise/raw/master/apprise/assets/" "themes/{THEME}/apprise-logo.png" ) # Image Path Mask image_path_mask = abspath( join( dirname(__file__), "assets", "themes", "{THEME}", "apprise-{TYPE}-{XY}{EXTENSION}", ) ) # This value can also be set on calls to Apprise.notify(). This allows # you to let Apprise upfront the type of data being passed in. This # must be of type NotifyFormat. Possible values could be: # - NotifyFormat.TEXT # - NotifyFormat.MARKDOWN # - NotifyFormat.HTML # - None # # If no format is specified (hence None), then no special pre-formatting # actions will take place during a notification. This has been and always # will be the default. body_format = None # Always attempt to send notifications asynchronous (as the same time # if possible) # This is a Python 3 supported option only. If set to False, then # notifications are sent sequentially (one after another) async_mode = True # Support :smile:, and other alike keywords swapping them for their # unicode value. A value of None leaves the interpretation up to the # end user to control (allowing them to specify emojis=yes on the # URL) interpret_emojis = None # Whether or not to interpret escapes found within the input text prior # to passing it upstream. Such as converting \t to an actual tab and \n # to a new line. interpret_escapes = False # Default number of retries after a first notification failure. # Individual plugins may override this via their ?retry= URL parameter # or the 'retry:' YAML key. Clamped to [0, APPRISE_MAX_SERVICE_RETRY]. default_service_retry = 0 # Default wait (in seconds) between retry attempts. Individual plugins # may override this via their ?wait= URL parameter or the 'wait:' YAML # key. Clamped to [0.0, APPRISE_MAX_SERVICE_WAIT]. default_service_wait = 0.5 # When True, as soon as any independent OR tag chain exhausts all its # priority groups without success, notify() / async_notify() return False # immediately without running further escalation rounds for the surviving # chains. When False (the default), all chains are allowed to complete # regardless of whether another chain has already failed. abort_on_chain_failure = False # Defines the encoding of the content passed into Apprise encoding = "utf-8" # Automatically generate our Pretty Good Privacy (PGP) keys if one isn't # present and our environment configuration allows for it. # For example, a case where the environment wouldn't allow for it would be # if Persistent Storage was set to `memory` pgp_autogen = True # Automatically generate our Privacy Enhanced Mail (PEM) keys if one isn't # present and our environment configuration allows for it. # For example, a case where the environment wouldn't allow for it would be # if Persistent Storage was set to `memory` pem_autogen = True # For more detail see CWE-312 @ # https://cwe.mitre.org/data/definitions/312.html # # By enabling this, the logging output has additional overhead applied to # it preventing secure password and secret information from being # displayed in the logging. Since there is overhead involved in performing # this cleanup; system owners who run in a very isolated environment may # choose to disable this for a slight performance bump. It is recommended # that you leave this option as is otherwise. secure_logging = True # By default, HTTP redirects (3xx responses) are followed, matching the # behaviour of the underlying requests library. Set this to False to # disable redirect following globally across all plugins without having # to add redirect=no to every individual URL. Individual URLs can still # override this via the redirect= parameter. http_redirects = True # Optionally specify one or more path to attempt to scan for Python modules # By default, no paths are scanned. __plugin_paths = [] # Optionally set the location of the persistent storage # By default there is no path and thus persistent storage is not used __storage_path = None # Optionally define the default salt to apply to all persistent storage # namespace generation (unless over-ridden) __storage_salt = b"" # Optionally define the namespace length of the directories created by # the storage. If this is set to zero, then the length is pre-determined # by the generator (sha1, md5, sha256, etc) __storage_idlen = 8 # Set storage to auto __storage_mode = PersistentStoreMode.AUTO # All internal/system flags are prefixed with an underscore (_) # These can only be initialized using Python libraries and are not picked # up from (yaml) configuration files (if set) # An internal counter that is used by AppriseAPI # (https://github.com/caronc/apprise-api). The idea is to allow one # instance of AppriseAPI to call another, but to track how many times # this occurs. It's intent is to prevent a loop where an AppriseAPI # Server calls itself (or loops indefinitely) _recursion = 0 # A unique identifer we can use to associate our calling source _uid = str(uuid4()) # Default timezone to use (pass in timezone value) # A list of timezones can be found here: # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # You can specify things such as 'America/Montreal' # If no timezone is specified, then the one detected on the system # is uzed _tzinfo = None def __init__( self, plugin_paths: Optional[list[str]] = None, storage_path: Optional[str] = None, storage_mode: Optional[Union[str, PersistentStoreMode]] = None, storage_salt: Optional[Union[str, bytes]] = None, storage_idlen: Optional[int] = None, timezone: Optional[Union[str, tzinfo]] = None, **kwargs: Any, ) -> None: """Asset Initialization.""" # Assign default arguments if specified for key, value in kwargs.items(): if not hasattr(AppriseAsset, key): raise AttributeError( f"AppriseAsset init(): An invalid key {key} was specified." ) setattr(self, key, value) if plugin_paths: # Load any decorated modules if defined self.__plugin_paths = plugin_paths N_MGR.module_detection(plugin_paths) if storage_path: # Define our persistent storage path self.__storage_path = storage_path if storage_mode: # Define how our persistent storage behaves try: self.__storage_mode = ( storage_mode if isinstance(storage_mode, NotifyFormat) else PersistentStoreMode(storage_mode.lower()) ) except (AttributeError, ValueError, TypeError): err = ( f"An invalid persistent store mode ({storage_mode}) was " "specified." ) raise AttributeError(err) from None if isinstance(storage_idlen, int): # Define the number of characters utilized from our namespace lengh if storage_idlen < 0: # Unsupported type raise ValueError( "AppriseAsset storage_idlen(): Value must " "be an integer and > 0" ) # Store value self.__storage_idlen = storage_idlen if isinstance(timezone, tzinfo): self._tzinfo = timezone elif timezone is not None: self._tzinfo = zoneinfo(timezone) if not self._tzinfo: raise AttributeError( "AppriseAsset timezone provided is invalid" ) from None else: # Default our timezone to what is detected on the system self._tzinfo = datetime.now().astimezone().tzinfo if storage_salt is not None: # Define the number of characters utilized from our namespace lengh if isinstance(storage_salt, bytes): self.__storage_salt = storage_salt elif isinstance(storage_salt, str): try: self.__storage_salt = storage_salt.encode(self.encoding) except UnicodeEncodeError: # Bad data; don't pass it along raise ValueError( "AppriseAsset namespace_salt(): " "Value provided could not be encoded" ) from None else: # Unsupported raise ValueError( "AppriseAsset namespace_salt(): Value provided must be " "string or bytes object" ) def color( self, notify_type: NotifyType, color_type: Optional[type] = None, ) -> Union[str, int, tuple[int, int, int]]: """Returns an HTML mapped color based on passed in notify type. if color_type is: None then a standard hex string is returned as a string format ('#000000'). int then the integer representation is returned tuple then the the red, green, blue is returned in a tuple """ # Attempt to get the type, otherwise return a default grey # if we couldn't look up the entry color = self.html_notify_map.get(notify_type, self.default_html_color) if color_type is None: # This is the default return type return color elif color_type is int: # Convert the color to integer return AppriseAsset.hex_to_int(color) # The only other type is tuple elif color_type is tuple: return AppriseAsset.hex_to_rgb(color) # Unsupported type raise ValueError( "AppriseAsset html_color(): An invalid color_type was specified." ) def ascii(self, notify_type: NotifyType) -> str: """Returns an ascii representation based on passed in notify type.""" # look our response up return self.ascii_notify_map.get(notify_type, self.default_ascii_chars) def image_url( self, notify_type: NotifyType, image_size: Optional[NotifyImageSize] = None, logo: bool = False, extension: Optional[str] = None, ) -> Optional[str]: """Apply our mask to our image URL. if logo is set to True, then the logo_url is used instead """ url_mask = self.image_url_logo if logo else self.image_url_mask if not url_mask: # No image to return return None if extension is None: extension = self.default_extension if image_size is None: image_size = self.default_image_size re_map = { "{THEME}": self.theme if self.theme else "", "{TYPE}": notify_type.value, "{XY}": image_size.value, "{EXTENSION}": extension, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) return re_table.sub(lambda x: re_map[x.group()], url_mask) def image_path( self, notify_type: NotifyType, image_size: NotifyImageSize, must_exist: bool = True, extension: Optional[str] = None, ) -> Optional[str]: """Apply our mask to our image file path.""" if not self.image_path_mask: # No image to return return None if extension is None: extension = self.default_extension re_map = { "{THEME}": self.theme if self.theme else "", "{TYPE}": notify_type.value, "{XY}": image_size.value, "{EXTENSION}": extension, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) # Acquire our path path = re_table.sub(lambda x: re_map[x.group()], self.image_path_mask) if must_exist and not isfile(path): return None # Return what we parsed return path def image_raw( self, notify_type: NotifyType, image_size: NotifyImageSize, extension: Optional[str] = None, ) -> Optional[bytes]: """Returns the raw image if it can (otherwise the function returns None)""" path = self.image_path( notify_type=notify_type, image_size=image_size, extension=extension, ) if path: try: with open(path, "rb") as fd: return fd.read() except OSError: # We can't access the file return None return None def details(self) -> dict[str, str]: """Returns the details associated with the AppriseAsset object.""" return { "app_id": self.app_id, "app_desc": self.app_desc, "default_extension": self.default_extension, "theme": self.theme, "image_path_mask": self.image_path_mask, "image_url_mask": self.image_url_mask, "image_url_logo": self.image_url_logo, } @staticmethod def hex_to_rgb(value: str) -> tuple[int, int, int]: """Takes a hex string (such as #00ff00) and returns a tuple in the form of (red, green, blue) eg: #00ff00 becomes : (0, 65535, 0) """ value = value.lstrip("#") lv = len(value) return tuple( int(value[i : i + lv // 3], 16) for i in range(0, lv, lv // 3) ) @staticmethod def hex_to_int(value: str) -> int: """Takes a hex string (such as #00ff00) and returns its integer equivalent. eg: #00000f becomes : 15 """ return int(value.lstrip("#"), 16) @property def plugin_paths(self) -> list[str]: """Return the plugin paths defined.""" return self.__plugin_paths @property def storage_path(self) -> Optional[str]: """Return the persistent storage path defined.""" return self.__storage_path @property def storage_mode(self) -> PersistentStoreMode: """Return the persistent storage mode defined.""" return self.__storage_mode @property def storage_salt(self) -> bytes: """Return the provided namespace salt; this is always of type bytes.""" return self.__storage_salt @property def storage_idlen(self) -> int: """Return the persistent storage id length.""" return self.__storage_idlen @property def tzinfo(self) -> tzinfo: """Return the timezone object""" return self._tzinfo caronc-apprise-182f859/apprise/assets/000077500000000000000000000000001524161175200176615ustar00rootroot00000000000000caronc-apprise-182f859/apprise/assets/NotifyXML-1.0.xsd000066400000000000000000000017321524161175200225310ustar00rootroot00000000000000 caronc-apprise-182f859/apprise/assets/NotifyXML-1.1.xsd000066400000000000000000000033361524161175200225340ustar00rootroot00000000000000 caronc-apprise-182f859/apprise/assets/themes/000077500000000000000000000000001524161175200211465ustar00rootroot00000000000000caronc-apprise-182f859/apprise/assets/themes/default/000077500000000000000000000000001524161175200225725ustar00rootroot00000000000000caronc-apprise-182f859/apprise/assets/themes/default/apprise-failure-128x128.ico000066400000000000000000002040761524161175200273220ustar00rootroot00000000000000€€ ((€ !8KVdpwwpdVK8! Gƒ³δνπσυχψϊϋϋϊψχυσπν䳃G  J™γγ™J =qΈόόΈq=,‰ΔξξΔ‰,DΒυύ ) 18?CEC?91 )ύυΒD BΡAf&&„,,™00§33³77Ώ::Θ;;Ξ==Υ??Ψ??Ψ??Ψ>>Φ<<Π::Θ88ΐ44΄11§,,™&&„fAΡB RΏ<$$„55Ί::Κ<<Ο>>Τ@@Ψ@@Ψ@@ΨAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩ@@Ψ@@ΨAAΩ>>Τ<<Ο;;Ι66Ή''…<ΏRNΎ 6''Ž==Σ??Χ??Χ@@Ψ@@Ψ@@ΨAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩ@@Ψ??Χ??Χ>>Σ)):ΎNΆϋ4k11«99ΐ==ΞAAΨAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩBBΩBBΩBBΩBBΩBBΩCCΩBBΩCCΩCCΩCCΩCCΩBBΩBBΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩ@@Ψ??Ψ88Α##{<ϋΆ]ω[*)55Ά88Ύ::ΐ;;Γ<<ΖAAΩAAΩAAΩAAΩAAΩBBΩBBΩCCΩCCΩCCΩDDΩDDΩDDΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩDDΩDDΩCCΩBBΩBBΩBBΩAAΩAAΩAAΩAAΩAAΩAAΩ@@Ω>>11©kω]*₯ i22ͺ65΅88Ί99½::Ώ::Α;;Γ;;Ε??ΣAAΩBBΩDDΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩDDΩCCΩCCΩAAΩAAΩAAΩAAΩAAΩAAΩ??Υ::Ζ$$|₯*eέ O21«65³66΅88Ή99»99½::Ώ;:Α<<Δ=<Ζ??ΜDDΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩCCΩBBΩAAΩAAΩAAΩAAΩAAΩ@@Ψ<<Ο` άe•ψ @++“54―65±77΅88·88Ή99Ό:9Ύ<<ΐ=<Β>>Δ?>Ζ@@ΙDDΧEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩFFΩEEΩFFΩFFΩFFΩFFΩFFΩFFΩFFΪFFΩFFΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩCCΩBBΩAAΩAAΩAAΩAAΩ@@Ψ66ΊQ χ•­ +%%32«5466±76³77΅88·:9Ί;:Ό<<Ύ>>ΐ>>Β??Δ@?Ζ@@ΙCCEEΩEEΩEEΩEEΩFFΪFFΪGGΪGGΪGGΪHHΪGGΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪGGΪGGΪGGΪGGΪFFΪEEΪEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩCCΩBBΩAAΩAAΩAAΩAAΩ??Χ//’9­.Ν^00€33ͺ55­65―66±77΄77΅:9Έ;;Ί=<Ό==Ύ>>ΐ?>Γ??Ε@@Η@@ΙBBΞDDΩGGΪGGΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪFFΪFFΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩCCΩBBΩAAΩAAΩAAΩAAΩ==Ρ&&Μ.Hγ('Š22¦43¨54«5565°66²98΄::Ά<;Έ<<Ί==Ό==Ύ>>ΐ?>Γ??ΕA@ΘAAΚCCΞGGΧHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪIIΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪFFΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩCCΩBBΩAAΩAAΩAAΩ@@Ψ22­ #αHGρ;-,–21£43§44©54«5576°99²;:΄;;Ά<;Έ<<Ί==Ό>=Ώ>>Α@?ΔAAΖBBΘBBΚDCΜFFΥKKΪMMΫHHΪHHΪHHΪIIΪIIΪJJΪJJΪJJΪJJΪKKΪJJΪJJΪJJΪJJΪJJΪJJΪJJΪJJΪJJΪJJΪIIΪHHΪIIΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩBBΩAAΩAAΩAAΩAAΩ::ΗNξG8ϋ^..œ21£33₯43¨44ͺ54¬77:9°::²;:΄;;Ά<<Ή<<Ί>=Ύ?>ΐ@@ΒBAΔBBΖCBΘCCΛccՈˆβ‘‘θ““ιŽŽηqqβJJΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫJJΫKKΫJJΪIIΪHHΪIIΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪFFΪFFΪEEΩEEΩEEΩEEΩEEΩDDΩBBΩAAΩAAΩAAΩ??Φ&&ƒϊ8Fϋ $#{00ž21‘32£33¦43¨65ͺ87¬99:9°::²;:΅;;·<<Ή>>Ό??Ύ@@ΐAAΒBAΕBBΗDCɏΰ¦¦δ––Ξzz¨ŠŠΎ¦¦δͺͺퟟλeeΰKKΫLLΫKKΫKKΫKKΫLLΫLLΫKKΫLLΫLLΫLLΫLLΫLLΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫJJΪJJΪIIΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪFFΪEEΩEEΩEEΩEEΩEEΩEEΩCCΩAAΩAAΩAAΩ@@Ψ// ϊFEρ%$0/œ10Ÿ22’32€44¦65¨87ͺ98¬99―:9±::³;;΅<<Έ>=Ί??Ό@?ΎA@ΑAAΓBAΕCCΗ‘ίœœΣ@@V%ppš‘‘ήηXXέMMΪNNΪNNΪNNΪNNΫNNΫNNΪNNΪNNΫNNΪMMΫMMΫNNΫLLΫLLΫLLΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΪIIΪIIΪHHΪHHΪHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩEEΩEEΩCCΩAAΩAAΩAAΩAAΩ22«νE,θ&%~//š1021 22’43€65¦87¨88ͺ98­99―:9±;;΄=<Ά>=Έ?>Ί??½@?ΏA@ΑBBΓCBΕkkΣ¨§γCCZ..?xx£――μzzγXXάOOΪOOΪOOΪOOΪOOΪOOΪPPΪOOΫOOΫOOΫOOΫOOΪOOΪNNΫMMΫMMΫLLΫKKΫKKΫKKΫKKΫKKΫKKΫJJΪJJΪIIΪHHΪHHΪHHΪHHΪHHΪFFΩEEΩEEΩEEΩEEΩEEΩDDΩBBΩAAΩAAΩAAΩ66Ή +ί,Υ #('†/.˜0/›11ž21 43’55€76¦87©88«98­99―;:²=<΄>=Ά>=Έ?>Ί??½A@ΏBBΑDCΔNMΘ––ΰ„„²66IŸŸΧ°°ξ››ιeeίQQΫQQΫQQΫQQΫQQΫQQΫQQΫPPΪPPΪPPΪPPΪPPΪOOΪOOΪOOΪNNΪMMΫMMΫMMΫLLΫKKΫKKΫKKΫKKΫKKΫIIΪHHΪHHΪHHΪHHΪHHΪGGΪFFΪEEΩEEΩEEΩEEΩEEΩCCΩAAΩAAΩAAΩ88ΐ7Ε΅ ((‰/.˜00š10œ11ž43 54£66€76§87©88«98;:°=<²=<΅>=·>>Ή@?»AA½CBΐCCΒDDΕppΤ««ζ%††Ά°°ξššκYYάRRΫRRΫRRΫRRΫRRΫRRΫQQΫRRΫQQΫQQΫQQΫPPΪPPΪPPΪPPΪPPΪOOΪOOΪNNΫMMΫLLΫKKΫKKΫKKΫKKΫKKΫIIΪHHΪHHΪHHΪHHΪGGΪFFΪEEΩEEΩEEΩEEΩEEΩCCΩAAΩAAΩAAΩ88ΐ +¦•&%|.-•0.˜00š10œ22ž55‘65£76₯77§87©:9¬::<;±=<³=<΅?>·??ΉA@»BBΏCBΐCCΒMMΗ£’γ]]} ŸŸΧ±±ξssαSSΫSSΫSSΫSSΫSSΫSSΫSSΫRRΫSSΫSSΫRRΫRRΫQQΫQQΫPPΪPPΪPPΪPPΪOOΪNNΫNNΪMMΫLLΫKKΫKKΫKKΫKKΫJJΪHHΪHHΪHHΪHHΪHHΪGGΪFFΪEEΩEEΩEEΩEEΩDDΩAAΩAAΩAAΩ66·[ό m.-“/.–0/˜00š32œ54Ÿ65‘66£76₯77§98©;:¬;:―<;±=<³>=΅@?·A@ΊBAΌBBΏCBΑEEΓ‚‚Χ““Δ bb„±±ξηUUΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫRRΫRRΫRRΫQQΫQQΫPPΪPPΪPPΪOOΪOOΪNNΪMMΫLLΫKKΫKKΫKKΫKKΪIIΪHHΪHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩEEΩDDΩAAΩAAΩAAΩ22ͺφZ#νe-,‘/-”//—0/™31›4454Ÿ65‘66£76¦87¨:9«;:­<;―=<±>=³@?·A@ΉA@ΊBA½CCΏEDΑ^^Ι¬¬δBBXSSo²²οššκYYάUUάUUάUUάUUάUUάUUάUUάTTάTTάTTάSSΫSSΫSSΫSSΫSSΫSSΫRRΫQQΫQQΫPPΪPPΪPPΪOOΪNNΪMMΪKKΫKKΫKKΫKKΫJJΪIIΪHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩEEΩDDΩBBΩAAΩAAΩ..œΧ#ΎY,+.-’/.•//—10™43›5454Ÿ65‘66£87§:9©:9«;:­<;―=<±??΄@?·A@ΉBB»DC½EEΎHGΐ››έ‚‚­ kk²²οššκYYάVVάVVάVVάVVάVVάVVάVVάVVάVVάUUάUUάTTάTTάTTάSSΫSSΫSSΫSSΫRRΫQQΫQQΫPPΪPPΪPPΪOOΪNNΫMMΫKKΫKKΫKKΫJJΪIIΪHHΪHHΪHHΪGGΪFFΪEEΩEEΩEEΩEEΩDDΩBBΩAAΩ@@Ψ%%~še6+*‹.,..“/.•10—33™43›54ž55 65’87₯98§:9©;:«;:==°?>³??΅@?·AAΉDCΊEDΎGGΐssΟ₯₯Ϋ::M¨¨β²²οηXXάVVάVVάVVάVVάVVάVVάVVάVVάVVάVVάVVάVVάUUάUUάTTάTTάSSΫSSΫSSΫSSΫRRΫQQΫPPΪPPΪPPΪOOΪNNΪMMΫKKΫKKΫKKΫJJΪIIΪHHΪHHΪHHΪHHΪFFΪEEΩEEΩEEΩEEΩCCΩAAΩAAΩ>>ΣIMϊ&&|,+Ž.-‘/.“0/•21—43š43›54ž55 77£98₯98§:9©;:¬=<―>=±?>³@?΅A@·CCΈED»FF½RRΓ››έzz‘yy’³³ο³³οwwβXXάYYΫYYάYYάYYΫYYΫYYάXXάYYάXXάWWάVVάVVάVVάVVάVVάVVάTTάTTάTTάSSΫSSΫRRΫQQΫQQΫPPΪPPΪOOΫNNΪNNΫLLΫKKΫKKΫJJΫIIΪHHΪHHΪHHΪGGΪFFΩEEΩEEΩEEΩEEΩCCΩAAΩAAΩ99Α ςΎd,+Œ.-.-‘/.“11•32˜43š44œ54ž65‘87£98₯98¨:9ͺ<<­>=―>=±?>³AAΆCBΈDDΉFE»FF½xxΠ­¬γ66H΄΄ο΄΄οκ]]άYYάZZάYYάYYάYYάYYάYYάYYάXXάXXάXXάYYάWWάWWάVVάVVάVVάVVάVVάUUάTTάTTάSSΫSSΫRRΫQQΫPPΪPPΪPPΪOOΪMMΫLLΫKKΫKKΫKKΫIIΪHHΪHHΪHHΪHHΪFFΩEEΩEEΩEEΩEEΩBBΩAAΩAAΩ11¦’A<*)‹-+.-.-’1/”32–32˜43š44œ65Ÿ86‘87£98¦:8¨=<«=<­>=―>>±A@²BA΅ED·EEΉFE»RQΑͺͺαPPižžΡ΄΄ο²²ξuuαZZάZZάZZάZZάZZάZZάZZάZZάZZάZZάYYάYYάYYάXXάXXάYYΫXXάWWάVVάVVάVVάVVάUUάTTάSSΫSSΫSSΫRRΫQQΪPPΪPPΪOOΪNNΪLLΫKKΫKKΫJJΫIIΪHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩDDΩAAΩAAΩAAΩ""uϊ=γ*)Š,+‹-,.-/.’21”32–32˜43š54œ76 87’97€98¦;:¨=<«=<­>=°@@²BA³DD΅ED·EEΉHGΌ‹‹ΦΌ WWt±±μ΄΄ο››ιZZάZZάZZάZZάZZάZZάZZάZZάZZάZZάZZάZZάZZάZZάYYάYYάYYάYYάYYΫXXάWWάVVάVVάVVάUUάSSΫSSΫSSΫSSΫQQΫQQΫPPΪPPΪOOΫNNΪLLΫKKΫKKΫJJΪIIΪHHΪHHΪHHΪFFΪEEΩEEΩEEΩEEΩCCΩAAΩAAΩ>> -²qh,*‰-,Œ-,Ž/.00’21”32—32˜43›65ž76 87’97€;:¦<;ͺ=<«=<@?―BA±CC³DD΅FEΈGGΊ^]Γ©¨έ66H"œœΟ΅΅ο²²οddί[[έ[[έ[[έ[[έ[[έ[[έ[[έ[[έ[[έ[[έZZάZZάZZάZZάZZάZZάZZάYYάYYάYYάYYΫXXάWWάVVάVVάVVάTTάTTάSSΫSSΫRRΫQQΫPPΪPPΪOOΫNNΪLLΫKKΫKKΫJJΪHHΪHHΪHHΪGGΪFFΪEEΩEEΩEEΩEEΩBBΩAAΩAAΩ--šϊEη5)(…,+Š-,Œ-,Ž0.21’21”32—43š55œ75žAA₯VU°a_ΆecΊdc»`_»RQ΅AA―CB±DC³ED·FFΉHG»|{Ο­­β~~¦``€΅΅π΅΅π‚‚ε\\ή]]ή]]ή]]ή]]ή]]ή]]ή]]ή]]ή]]ή\\ή\\ή\\ή\\ή[[έ[[έ[[έZZάZZάZZάZZάYYάYYάYYάWWάVVάVVάVVάTTάTTάSSΫSSΫSSΫQQΫPPΪPPΪOOΪNNΫLLΫKKΫKKΫJJΪHHΪHHΪHHΪGGΪFFΪEEΩEEΩEEΩDDΩBBΩAAΩ??ΥH°h ! i,*ˆ,+Š-,Œ..1021’21•32—43šcb²ΙœΟžžΟŸŸΠ ŸΡ‘ Σ£’Τ’’Χ——ΣVUΈED΄FF·HGΉHH»]\Δ­­βγ―δWWr ΄΄ο΅΅π€€μccί^^ή^^ή^^ή^^ή^^ή^^ή^^ή^^ή^^ή^^ή^^ή^^ή]]ή]]ή\\ή\\ή[[έ[[έZZάZZάZZάZZάYYάXXάYYΫXXάWWάVVάVVάVVάTTάSSΫSSΫSSΫQQΫPPΪPPΪOOΪNNΪLLΫKKΫKKΫJJΪHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩCCΩAAΩAAΩ33¬ 0ι3)(…,+ˆ,,‹-,/.10‘21“55–[Y«œœΝ•”Ώ|| XXq88H&&1"!+))6CCW‡‡―©©Ϋ€ΜGG΅FF·HGΉIIΌKJΎttΞ¨¨αδ°°ε••Β88J ƒƒ­ΆΆπ΅΅οzzγ__ή__ή__ή``ή``ή``ή``ή``ή``ή__ή__ή__ή^^ή^^ή^^ή]]ή]]ή\\ή\\ή[[έ[[έZZάZZάZZάYYάYYάXXάXXάWWάVVάVVάUUάTTάSSΫSSΫSSΫQQΫPPΪPPΪNNΫMMΪLLΫKKΫKKΫIIΪHHΪHHΪHHΪFFΪEEΩEEΩEEΩDDΩBBΩAAΩAAΩMΊP!!n+)†,+‰,,‹/-1010‘EDΎŸŸΛZYr%%/ ““ΎͺͺΫ‹‹ΟIHΆGFΈWWΏddΕZYΔMLΑWWΖ‰ˆΧ©©γ°°η±±η||’22B@@UΆΆπΆΆπκccή``ή``ήaaήaaήaaήaaήaaήaaήaaήaaή``ήaaή``ή``ή__ή__ή^^ή^^ή]]ή]]ή\\ή[[έ[[έZZάZZάZZάYYάXXάYYΫXXάVVάVVάUUάTTάSSΫSSΫSSΫQQΫPPΪPPΪOOΪMMΫKKΫKKΫKKΫIIΪHHΪHHΪHHΪFFΪEEΩEEΩEEΩDDΩAAΩAAΩ::Ζ'ζ0*)„+*‡,+‰--‹/.10QQ’”“Ζ‡†¬(ee‚ͺ©ΪͺͺΫpoΔFFΆcbΒ‘‘Ϋ«ͺߟŸάQQΒQPΕgg͏ά±±θ²±θ§§άvv› ‘‘Τ··π΅΅οvvβaaήbbήbbήbbήbbήbbήbbήbbήbbήbbήbbήbbήbbήbbήaaήaaή``ή``ή``ή^^ή^^ή]]ή]]ή\\ή[[έZZάZZάZZάZZάYYάXXάXXάVVάVVάUUάTTάSSΫSSΫQQΫQQΫPPΪPPΪNNΫMMΪKKΫKKΫKKΫHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩCCΩAAΩAAΩg₯9$#u+)…+*‡,+‰/-‹/.JI™™Ι‘'©©Ωͺ©Ϋ––ΣNM·GGΆ££άtt–WWqͺͺέβ££ίnmΟSRΗUUΚllΣ§¦ζ³³κ³³μ­­βWWr cc‚΅΅ξΈΈπ˜˜ιbbήbbήbbήbbήbbήbbήbbίbbίbbίaaίaaίbbίbbήbbήbbήbbήbbήbbήaaήaaή``ή__ή^^ή^^ή]]ή\\ή\\ή[[έZZάZZάZZάYYάYYάXXάVVάVVάUUάSSΫSSΫSSΫRRΫPPΪPPΪOOΪNNΫLLΫKKΫKKΫJJΪHHΪHHΪHHΪFFΪEEΩEEΩEEΩDDΩAAΩAAΩ::Β τΝ-)(‚+*…++‡-,Š/.Œ88’ŠŠΐ‹‹°‰‰°©©Ϊ¨¨ΪmlΒFE΄}|Λ’’((3rq“¦¦Χ°°ε““ά^]ΝUUΝWWΠ‰ˆί±±λ΄΄μ΅΅ν°°ζuu™€€ΦΈΈπ΅΅οddήbbήbbίbbίbbίbbίbbίccίccίccίccίccίbbίbbίbbίbbίbbήbbήbbήbbήbbήaaή``ή``ή__ή^^ή^^ή\\ή\\ή[[έZZάZZάZZάXXάYYάWWάVVάVVάUUάTTάSSΫSSΫRRΫPPΪPPΪOOΫMMΫKKΫKKΫJJΫHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩCCΩAAΩ??ΣYύqό! i*)‚+*…,+ˆ.,Š0/Œcc©‘ Λ$$.GG[§§Φͺ©Ϊ”“ΡED²WV»§§άji‰..>HΘ„9*(€*)*)ƒ-+…..‡/.‰0/‹10Žvt΅£’ΞEEW ’’Ί§§Σ¦₯ΤZY°BA§‰‰Ι––Ώ&ΪΪΪLLcΈΈπ··π||δ^^ή]]ή[[έZZάZZάZZάYYάWWάVVάUUάTTάSSΫSSΫQQΫPPΪNNΫLLΫKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩDDΩBBΩ??Υiν―O*(€*)*)ƒ-,…/.‡/.‰0/‹11Ž@>˜£’Ξ££ΟWWo₯€Ρ§§Τƒ‚Γ@?₯`_΅££Τ\\u’’’;;;***GGG444&&&¦¦ΩΈΈπ««νffί^^ή]]ή[[έZZάZZάYYάYYάXXάVVάVVάTTάSSΫSSΫQQΫPPΪOOΪNNΫKKΫKKΫIIΪHHΪHHΪHHΪEEΩEEΩEEΩEEΩAAΩAAΩ''„ωά d*(€*)**ƒ.-…/.ˆ/.Š0/Œ2154’rp΄££Οˆˆ¬&&1Ά§¦Σ₯₯ΣBA€BB¦ŽΛ–•Ύ jjjqqq «««χχχέέέΑΑΑ§§§‹‹‹``` gg‡ΈΈπΈΈπ„„ε``ή__ή]]ή\\ή[[έZZάZZάYYάXXάVVάVVάTTάSSΫSSΫQQΫPPΪOOΪNNΪLLΫKKΫJJΪHHΪHHΪGGΪFFΪEEΩEEΩDDΩBBΩAAΩ--–όξ$#q*(€*),+„.-…/.ˆ/.Š1/3132‘CB›””Η€€Ρ˜—Α¦₯§¦Σff΄?>£ddΆ¨§Χ:9I777£££888ϋϋϋ«««''3ΈΈπΈΈπ₯₯μiiίaaή__ή^^ή]]ή[[έZZάZZάYYάXXΫVVάVVάTTάSSΫSSΫRRΫPPΪOOΪNNΪKKΫKKΫKKΫIIΪHHΪHHΪFFΪEEΩEEΩDDΩBBΩAAΩ00£ ώς&%u*)€*)‚,+„.-†/.ˆ//Š1/3132’42”TS¦£’Π₯€Ρ¦₯ŒΗA@’BA₯˜˜Οyyš!!!ςςςήήή ―――ψψψ,,,••ΒΉΉρΆΆπεbbήaaή``ή^^ή]]ή[[έZZάZZάYYάYYΫVVάVVάUUάSSΫSSΫRRΫPPΪPPΪOOΪLLΫKKΫKKΫHHΪHHΪHHΪFFΪEEΩEEΩEEΩCCΩAAΩ44ώ9υ &%x*)€*)‚,+„.-†/.‰//Š1/3132’42”65—FE‘utΉnmΆFE£>=‘oo»¨¨ΦΜΜΜφφφΗΗΗ›››lllAAA+++ 555’’’NNe΄΄λΉΉρ££μbbίbbήaaή``ή__ή]]ή[[έZZάZZάYYάYYάXXάVVάUUάSSΫSSΫRRΫPPΪPPΪNNΫLLΫKKΫKKΫIIΪHHΪHHΪFFΪEEΩEEΩEEΩCCΩAAΩ66Ά ώQ+φ&%x*)€*)‚,+„.-†/.‰0/‹1/Ž3142’43”65—76™87œ:9<;Ÿ>=‘₯₯Τqq  ¦¦¦ψψψΪΪΪΌΌΌžžžyyyΠΠΠεεε ˜˜ΔΉΉρΉΉρnnαbbίbbήaaή``ή__ή]]ή\\ήZZάZZάYYάYYάXXάVVάUUάTTάSSΫRRΫPPΪPPΪOOΫLLΫKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩDDΩAAΩ88½ &ώd9ω&%y*)€*)ƒ,+„..‡/.‰0/‹1/Ž3142’43”65˜86š87œ;:==Ÿ>=‘’‘Σ¨§Υ{zœ66ETTTVVoΊΊπΉΉρŽŽθccίbbίbbήbbή``ή__ή]]ή\\ή[[έZZάYYάYYάXXάVVάUUάTTάSSΫRRΫQQΫPPΪNNΫMMΪKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩDDΩAAΩ::Β +rEω '&y*)*)ƒ-+…..‡/.‰0/‹10Ž31‘42“43•65˜86š98œ;:== >=’lkΉ©©Χͺ©Ψ’’Ξww˜```όόόΌΌΌ――βΊΊπ««ξiiαccίbbίbbήbbή``ή__ή]]ή\\ή[[έZZάYYάXXάXXάVVάUUάTTάSSΫSSΫQQΫPPΪNNΪMMΪKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩDDΩBBΩ;;Η/zJϊ '&y*)*)ƒ,+…/.‡/.‰0/Œ1031‘42“43•65˜86š98;:ž== ?>£A?₯VU°••Ο«ͺΩ««Ϊ©©ΨSSj@@@λλλύύύφφφχχχRRRwwšΊΊρΊΊπƒƒζddΰccίbbίbbήbbή``ή__ή]]ή\\ή[[έ[[ά‚‚δ˜˜ι˜˜ι€€δWWάTTάSSΫSSΫQQΫPPΪOOΪMMΪKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩCCΩAAΩ;;Η0vHω'&y*)**ƒ-+…/.ˆ/.Š0/Œ1031‘42“43•65™86›97<;ž== ?>£@@₯BA¨DBͺppΏ€£Χ««Ϋ­¬ά—–ΐ44C ΫΫΫεεεiii‚‚‚ŸŸŸΉΉΉΤΤΤνννΈΈΈ--:ΊΊρΊΊρ€€μiiΰddΰccίbbίbbήbbήaaή__ή]]ή\\ήggίͺͺν££Ω––Ι  Υ±±ξ©©νeeίSSΫRRΫQQΫPPΪOOΪMMΪKKΫKKΫJJΪHHΪHHΪGGΪEEΩEEΩEEΩDDΩAAΩ::Δ -k>ψ&%y*)‚+*„-+†/.ˆ/.Š0/Œ2032’42”54–76™87›98<:ž>=‘>>’@@¦BA¨DBͺED­ON²‚Ι₯€Ω­έήxw™..<ΚΚΚβββ !!!111AAAΈΈΈώώώ888››ΘΊΊρΉΉπ€€δeeΰddΰccίbbίbbήbbή``ή__ή]]ή\\ή€€μΌ))7 &vvŸ²²ο««ν__έRRΫPPΪPPΪOOΫMMΫKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩDDΩAAΩ99ΐ (Y1υ&%x*)‚+*„,+†/.ˆ//Š0/Œ2032’42”43–66™87›98<: >=‘>>£@?¦BA¨DC«ED­GF―GF±^]»‰‰Ξή―ί€€tt•¨¨¨ξξξEEEψψψŸŸŸXXqΈΈξΊΊρ  λffίddΰddΰccίbbίbbήbbή``ή__ή]]ήttβ²²μ99K Β±±οκUUΫQQΪPPΪNNΫMMΪKKΫKKΫJJΪHHΪHHΪFFΪEEΩEEΩEEΩDDΩAAΩ77Έ !ώF$ς&%v*)‚+*„,+†/.ˆ0/‹0/2032’42”53–66™87›97<;Ÿ>=‘?>€@?¦BA¨DC«ED­GF―IH²KJ΄MM΅bbΐŸžΩ°°α°°β§§ΦDDWuuuώώώϊϊϊeee™™™τττ "Κ»»ρΊΊρmmαffίeeΰθ¬¬ξ°°ξ₯₯μzzγ``ή__ή]]ή‚‚εͺͺΰ++:λ°°ξ……εPPΪPPΪNNΫLLΫKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩCCΩAAΩ55±ώ$ρ$#r*)‚+*„,+†/.‰0/‹0/2042’43”53–76š87œ98ž;:Ÿ=<‘>>£@?¦AA©CB«ED­GF°IH²KJ΄KKΆMMΈNMΊ€€Ο©©ΰ±±γ²²δppCCCύύύƒƒƒ///iii^^xΌΌρ»»ρŠŠηhhΰnnΰ©©ν¬¬ΰ””Α••Γ――δ··π™™κ__ή]]ή€€δ΅΅οNNh@@V±±ξ§§μccέPPΪNNΫLLΫKKΫKKΫIIΪHHΪHHΪFFΪEEΩEEΩEEΩCCΩAAΩ22§ώμ"!j*)ƒ+*…,+‡/.‰0/‹0/1042“43•53—76š87œ98ž;: =<‘?>€@?§BA©CB«EDGF°HG²JJ΄KJΆMMΉNMΊOO½__ƐΨ――γ³³ε””½.-; ύύύ’’’ΆΆΆΘΘΘ#³³ε»»ρ««νjjΰhhί‘‘θβ11@==P°°η··πŠŠη]]ήhhഴ﨨ΰ%%1‚‚―°°ξ„„εQQΪNNΫLLΫKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩCCΩAAΩ..œϊζX*(ƒ+*…,,‡/.‰0/‹00Ž1042“43•53—76™87œ98ž;:‘=<’?>€@?§AA©CB«EDGF°HG²JJ΄LKΆMMΉNN»POΎQQΑSSΓutΟͺͺβ΄΄η¦₯Υ;;LβββΏΏΏJJJρρρωωω___ˆˆ­ΌΌρΉΉπƒƒεhhΰkkΰξhhˆ;;NΆΆπ±±οqqβ[[ݍζ΄΄οžž55G««η­­νZZάNNΫKKΫKKΫKKΫIIΪHHΪHHΪEEΩEEΩEEΩEEΩCCΩAAΩ**υΎB*(ƒ+*…,+ˆ.-Š0/Œ00Ž2032“43•53˜76š87œ98Ÿ;:‘=<£?>€@?§AA©CB«EDFF°HG³JI΅LK·MLΉNN»POΎQQΑSSΓUTΖbbΛ¨¨γ΅΅θ΅΅θ??Q¨¨¨ήήή ͺͺͺΖΖΖ<€@?¨BAͺCB¬EDFF±HG³JI΅LK·MLΉNN»POΏRQΒRRΔUTΖWVΘYYʟŸα΅΅ιΆ΅κJJ`lllόόό---@@@ϋϋϋύύύIII ₯₯ΤΌΌρ··π~~δhhΰggίkkᳳΙ&&2§§ή΅΅πllΰZZάnnΰ­­ν¬¬ζ##/VVt­­λ­­ξNNΪKKΫKKΫJJΪHHΪHHΪHHΪEEΩEEΩEEΩDDΩBBΩ??ΣU¬f+)„+*†,+ˆ..Š0/Œ1010‘31”43–54˜64š9798Ÿ::’=<£?>₯@?§BAͺCB¬ED―FF±HG³IH΅LKΈMLΊONΌOOΎQPΑRRΔTTΖWVΘXXΚ[[Ν””ί΅΅κΆΆλQQh---IIIΏΏΏ¬¬¬__yΊΊο»»ρžžκhhΰhhΰggίffΰ››λΈΈπnn{{£΅΅οššιZZάZZ܌Œζ±±ξll’%§§α――ξooαKKΫKKΫJJΪHHΪHHΪGGΪEEΩEEΩEEΩDDΩBBΩ>>Ο3f2('+*†,+ˆ.-Š0/Œ1010‘31”53–54˜75š87œ98 ::’=<€?>₯@?§BAͺCB¬DC―FE±HG΄IH΅KK·MLΊNMΌOOΎQPΑSRΔTTΖVUΘXXΛZZΝ^^Ο‘‘ΰΆΆμ±±εCCWqqqOOOϋϋϋ&₯₯Σ»»ρΆΆοllΰhhΰggίeeΰddΰxxδ³³ο±±η55EII`°°ι΄΄ο__έZZάaaήξ––Κ"““Ζ――ξ’’ιKKΫKKΫIIΪHHΪHHΪGGΪEEΩEEΩEEΩDDΩAAΩ<<Ι 2_+*†,+‰-,Š/.1010‘21“43–54™64›8798 :9’<;£>=₯@?§A@«CB¬DC―FE²GG΄IHΆKJΈMLΊNMΌOOΎPPΐRQΔSSΗVVΙXWΛZYΝ\\Π]]Ρ——γ··μΆΆμ&&2ΞΞΞηηηuuugg„ΌΌρ»»ρƒƒεhhΰhhΰffΰddΰddΰccߏθΆΆοyyŸ&ͺͺα΄΄οyyβXXάYYά““θ²²ο77J``ƒ――ξ₯₯νKKΫJJΪHHΪHHΪHHΪGGΪEEΩEEΩEEΩCCΩAAΩ66΄Ρ;+)‡,+‰--‹/.1011‘21“43—54™64›8798 :9’;:€>>¦@?¨@@ͺCB­DC―ED±GG΄IHΆKJΈMLΊNMΌPOΏPPΑRQΔTSΗVVΙXWΛZYΝ[[Π]]^^՜œζ··ξ²²η šššΦΦΦ(»»π»»ρ¦¦μjjΰhhΰggίeeΰddΰddΰccίggί²²ξ––Δ₯₯Ϋ΄΄οζXXάWWάmmΰ²²ο\\|55H――ν]]ήJJΪHHΪHHΪHHΪFFΩEEΩEEΩEEΩCCΩAAΩ$$zά‹+)‡,+‰-,‹/-1011’21”43—54™65›86:8‘:9£;:₯>=¦@?©A@ͺBA­ED°ED²GF΄IHΆJIΉLLΊNM½POΏPPΑQQΔSRΖUUΙWWΜYXΞZZΠ]]^^Τ``Ψ  θ··ο˜˜ΗaaaϋϋϋkkkΆ»»ρΈΈπδhhΰggίeeΰddΰddΰccίbbίbbή  λ₯₯Ω&&1™™Μ΄΄ο˜˜ιYYάVVάVVά±±ο}}© ξͺͺξkkαJJΪHHΪHHΪGGΪFFΩEEΩEEΩEEΩBBΩAAΩ8”>%$u,+‰-,‹/-1011’21”32–54™65›7598 :9£;;₯>=§?>©A@ͺAA­ED°ED²GF΅HH·JIΉLK»MM½ONΏQPΑQQΔSRΗUTΚVVΜXXΞ[ZΠ\\^^Υ__ΧkkάͺͺμΈΈπvvš444ΣΣΣ JJ`»»ρ»»ρŸŸλiiΰggίeeΰddΰddΰccίbbίbbήbbތŒζͺͺΰ**8””Δ΄΄ο™™ιXXάVVάVVܜœκ••Ι ξ««ξvvγIIΪHHΪHHΪFFΪEEΩEEΩEEΩDDΩAAΩ??ΣFχP,+Š-,Œ.-Ž1021’21”32—64š65œ75ž87 99’;:₯=<§?>ͺA@«BADC°ED²FE΄HH·JIΉKJ»MMΎNNΐQPΒQQΔRRΖUTΚVVΜXXΞZZΡ\[Σ]]Υ__Χ``ΩuuΰΆΆπ··οBBV!!!κκκύύύWWW ͺͺΫΊΊρ··πvvγggίddΰddΰddΰccίbbίbbίbbήbbή‹‹η₯₯Ω%%1››Ξ³³οššιWWάVVάUU܊Šη££ά §§ζ¬¬ξ{{δHHΪHHΪHHΪFFΪEEΩEEΩEEΩCCΩAAΩ++ϋ Ή.,+Š-,Œ--0/21’21”32—54š65œ76ž87 :9£;:₯<;¨?>©@?¬CBCB°EE²FE΄GF·JIΊKJΌMLΎNNΐPOΒQQΔRRΖSSΘUUΜWWΟYYΡ\\Σ]]Υ__Χ``Ϊaa݊ŠηΈΈπ¨¨ΫΘΘΘΒΒΒhh‡ΈΈοΊΊρ——ιggίeeΰddΰddΰddΰccίbbίbbήbbήaaޞžκ••Δ₯₯Ϋ΄΄ξ––θVVάVVάTTά€€δͺͺε ’’ί¬¬ξ||δHHΪHHΪHHΪEEΪEEΩEEΩEEΩBBΩAAΩ@Κ s&%x-,Œ--0.‘21“21•32—33™65œ76Ÿ86‘99£;:¦<;¨>=ͺ@?¬BA­CB±ED³FE΅GF·IHΊKJΌLKΎNNΐOOΒRQΕRRΖSSΙVUΝVVΟYYΡZZΤ]\Φ]]Ψ``ΪaaάaaߜœκΈΈπ¨•••όόό&&&##-§§ΩΊΊρ²²οggίeeΰddΰddΰddΰccίbbίbbήbbήaaήggί±±οww +©©β΄΄ξ‡‡ζVVάUUάTTά䦦ΰ €€α¬¬ξ{{δHHΪHHΪFFΪEEΩEEΩEEΩDDΩAAΩ;;Η>υ_-,Œ--/.‘21“22•32—33™55œ76Ÿ86‘98£;:¦<;¨=<ͺ@?¬A@BB°DC³FE΅GF·IHΊJJΌLKΎMMΐPOΒPOΕRRΗSSΙTTΛUUΞWWΡZZΤ[ZΦ]]Ψ__Ϊaaάbbήjjΰ««νΈΈπLLc***HHHvvv₯₯₯‚‚‚qq‘ΊΊπΊΊπ}}δddΰddΰddΰccίbbίbbίbbήbbήaaή``ή‘‘θ±±κ//?NNh°°κ³³οqqαVVάUUάSSۈˆζ˜˜Ξ ­­ξ««ξzzδHHΪHHΪFFΪEEΩEEΩEEΩCCΩAAΩ''ƒϋ>Ν6,+‰.-/.‘10“22•32˜43š5476Ÿ86‘87£::¦<;©=;«@?­A@―BB°DC³EEΆGFΈIHΊIIΌLKΏMLΑNNΓQPΕQPΗSSΙTTΛUUΞVVΡYYΤ[[Φ]]Ω]]Ϋ``έbbήbbή~~δΆΆπ©©έ!!!SSS%%0ΊΊρΉΉρλggΰddΰddΰccίbbίbbίbbήbbήaaή``ή‚‚ε΅΅οnn’©³³ο²²οVVάUUάTTάSSΫ™™ι‚‚° ­­ξ««ξvvγHHΪGGΪEEΩEEΩEEΩEEΩBBΩ@@ΦDΨ‘('.-.-‘1/”22•32˜43š4476Ÿ87’87€:9¦<;©=<«?>­@?―CB°CC³EDΆGFΈHGΊJI½KKΏMLΑMMΓPPΖRQΗSSΚTTΜUUΞVVΡXXΤZZΦ\\Ω]]Ϋ__έaaήbbήccޝλΆΆοkkŒ––ΓΉΉρ΄΄πxxδddΰccίccίddίccήccήccήccήddߎŽηΆΆπ©++9¦¦έ³³οηVVάUUάSSΫSSΫ¬¬νbb…))8­­ξ©©νllαHHΪFFΪEEΩEEΩEEΩDDΩBBΩ44°‘:ψO.-.-’0/”21–32˜43š44œ65 87’87€98¦<;©=<«=<­??―A@±CC³DD΅FFΈHGΊIHΌJJΏMLΑNMΔOOΖQPΘRRΚTTΜUUΞVUΡWWΣYYΧ[[Ω]]Ϋ]]έ__ήaaήbbήssα³³ο§§Ϊ$$/RRkΉΉρΉΉρ˜˜κccίccίttβ‹‹η––ι••θη‹‹ζ˜˜ι±±ο€€ΩZZwvvž³³ο²²οffίUUάSSΫSSΫddή°°ξ>>TPPo¬¬ξ§§ν^^ήGGΪEEΩEEΩEEΩEEΩCCΩ@@Χ$$yϊ:Ξ -,.-’/.”21–33™43š4465 87’97€98¦;:¨<;«=°A@±CC³DD΅FEΈGGΊIHΌIIΏKKΒNMΔNNΖPOΘRQΚRRΜUUΞVVΡWVΣXXΦZZΩ\\Ϋ]]έ^^ή``ή``ήaaή’’θΈΈπdd‚§§ΪΈΈπ³³οmmαbbί‰‰ζ°°ξ¦¦Ω‰‰²  Ρ¬¬β€€Χ……°OOi%HH_³³ο²²οθVVάTTάSSΫSSیŒη››*„„Ά¬¬ξ¦¦νMMΫFFΪEEΩEEΩEEΩDDΩAAΩ>>Ο $ΞYS..’/.”00—22™43›5454Ÿ75’97€98§;:©<;«=<>=°@@²BA³DD·EDΈGG»HH½JIΏKKΒMLΔNNΖOOΘQPΚSSΝTTΟUUΡWVΣWWΥYYΩ[[ά\\ή]]ή^^ή``ή``ήaaή΅΅ο³³κoo‘··οΈΈπ‹‹ηbbίrrα±±ν||’ //>³³ο²²οͺͺνjjΰTTάSSΫSSΫXXά¨¨μvvŸŸŸέ¬¬ξ——κHHΪFFΩEEΩEEΩEEΩCCΩAAΩ,,”Yλ-,/.”//—22™43›5454Ÿ75£87₯98§:9©;:«>=>=°??³AA΅CCΆEDΈGF»GG½JIΏJJΑLLΕNMΗOOΙQPΛRRΝTTΞTTΡWWΣWWΦXXΨYYΪ\\ή\\ή]]ή^^ή^^ή``ή††ε··πaa**7§§άΈΈπ­­νbbήbbή““θ­­β *55G賳ﰰεTTάTTάSSΫSSΫƒƒε§§ε55GCC]§§η¬¬ξssβFFΪEEΩEEΩEEΩDDΩBBΩAAΩ μ^S/.•0/—10™32›54ž55 65’76₯98§:9©;:«<;>=±?>³@@΅CB·EEΈFEΊGGΎIHΐJJΒLLΕMLΗNNΙPPΛQPΝSSΠUUΡUUΤXWΦXXΨYYΪZZά\\ή\\ή]]ή]]ή^^ήccήξ΄΄μ yyžΈΈπΈΈπwwβbbήbbή››κͺͺΰ  XXu««ε²²ο²²οŽŽηTTάSSΫSSΫSSΫbbή₯₯μ||¨xx¦¬¬ξ¬¬ξNNΫEEΩEEΩEEΩEEΩCCΩAAΩ((…_Ϋ))†0/˜00š31œ54ž55 65’66€98¨:9©;:¬;:>=±?>³@?΅BA·CCΉFEΊFF½HGΐJIΒKKΔLKΖNMΙOOΛQPΝRQΠTTUUΤVVΦXXΨYYΪZZάZZά[[έ\\ή\\ή]]ή]]ވˆζΆΆπ^^|11@··π··π——ιaaή``ή``ή™™ι±±ι??SIIa‘‘Α²²ξ²²ο²²ο„„εVVάSSΫSSΫSSΫRRΫ––覦ΰ&&5‘‘ί¬¬ξˆˆηFFΪEEΩEEΩEEΩDDΩBBΩ==ΜίjW/.—00š21œ54ž55 65’66€87¨:9ͺ;:¬;:==°?>³@?Ά@@ΈCBΊEDΌGF½GGΏIHΒKKΔLLΗMLΙNNΜQQΞRQΠRRTTΤWWΥWVΩXXΫZZάZZάZZάZZά[[έ\\ή\\ήooα­­ξ₯₯Ϊ’’Υ··π――ξqqα__ή__ή^^ށε΄΄ο€€ΪKKc 00?ll₯₯Ϋ±±λ³³ο²²οξttβUUάTTάSSΫSSΫQQۏη°°ξ))8hh¬¬ξ¬¬ξVVέEEΩEEΩEEΩEEΩCCΩAAΩ kjΧ+*‹00š10œ32ž55 65£76₯77§99ͺ;:¬;:<;°>>³@?Ά@@ΈBAΊDD»EDΎGGΏIHΒJIΕKKΗMLΙMMΛOOΞQQΡRRSSΤUUΧXXΨXXΫYYάZZάZZάZZά[[έ[[έ[[έ\\ޚšκ°°ιBBW[[xΆΆπ΅΅ο‘‘θ^^ή^^ή^^ή]]ή]]ή¦¦ν΅΅π΅΅π΅΅ο΄΄ο°°κ΄΄ο΄΄ο΄΄ο³³ο΄΄ο²²οξ””θ^^έTTάSSΫSSΫSSΫQQېθμKKe$$2¬¬ξ¬¬ξˆˆηHHΪEEΩEEΩEEΩDDΩBBΩ66Ά Ψuώ>00š10œ32Ÿ43‘66£76₯77§87ͺ:9¬;:―<;±=<³?>Ά@@ΈA@ΊBBΌEDΎFFΑHHΒJIΕKKΗLLΙMMΛOOΞPPΡRQΣSSΥTTΧVVΩWWΪYYάYYάYYάZZάZZάZZάZZά[[έ}}δ²²οyy &¬¬β΅΅π­­ξffί]]ή]]ή\\ή\\ή\\ήddί––ιͺͺν³³ο΄΄ο΄΄ο΄΄ο³³ο³³ο««νŸŸλ‹‹ζffίVVάTTάSSΫSSΫSSΫ__έ““θ­­λRRp ’’Κ¬¬ξ¦¦ν]]ήEEΩEEΩEEΩEEΩCCΩ@@Χ^ώuΧ! k1021Ÿ33‘66£76₯77¨87©88¬<;―<;±=<³=<΅@?ΉAA»BAΌCCΏFEΐGGΓIIΔIIΖKKΚMMΜNNΞOOΠQPΣRRΥSSΧUUΩVVάWWάXXάYYΫXXάYYάYYάZZάZZά]]ά혘Κ"ss™΅΅π΅΅πε[[έ[[έ[[έ[[έZZάZZάZZάZZάjjίttαyyβzzγzzγxxβrrαjjΰ]]έVVάUUάTTΫSSΫSSΫSSΫzzγ££λ§§β77Kww₯¬¬ξ¬¬ξ……ζEEΩEEΩEEΩEEΩCCΩAAΩ11₯Χ4,,‘21Ÿ22‘43€76¦77¨87ͺ88¬;:―<;±=<³==Ά?>Έ@@»BA½BBΏEEΑFFΓHHΖJIΗJJΙLLΜMMΞOOΠPPQQΥRRΨTTΪVVάVVάVVάWWάXXΫYYάYYάYYάZZάZZά““θ³³ξ77I..<ͺͺβ΄΄ο‘‘λZZάZZάZZάZZάZZάZZάZZάZZάZZάYYάXXάYYάYYΫXXΫVVάVVάVVάVVάTTάTTάSSΫWWΫ||γ€€μξ€€­[[~©©λ¬¬ξ‘‘μMMΫEEΩEEΩEEΩDDΩAAΩ@@Φ4‘810ž22‘43€65¦77¨87ͺ98­99―;:±=<³>=Ά>=Έ@?ΊBA½CBΐCCΑEDΒGGΖIHΗJJΙKKΛMMΟNNΡPPQPΥQQΧSSΪTTάVVάVVάVVάWWάWWάYYΫYYάYYάYYάppΰ΄΄ο^^}||₯΄΄ο΄΄οiiίZZάZZάZZάZZάZZάZZάZZάZZάXXάXXά^^ά}}㍍牉ζyyγkkΰbbήeeίooΰ‚‚δ››κ±±ξ¦¦ΰΔ::ODD_¨¨ι¬¬ξ¬¬ξ[[ήEEΩEEΩEEΩEEΩBBΩAAΩF‘ Ψe22’32€54¦76¨88ͺ98­99―:9±<<΄>=Ά>=Έ?>Ί@@½CBΐCCΒDCΔEEΖHGΗIIΚKKΜLLΞMMΡOOΣQQΥQQΧRRΩTTάTTάUUάVVάVVάVVάVVάWWάXXΫXXάXXά²²ξ  Υ @@U΄΄ο΄΄οŽŽη[[άZZάYYάYYάYYάYYάYYάYYάYYάXXΫqqαͺͺν££ΫΐœœΡͺͺδ²²ο±±ο――μ₯₯ί••Ι~~ͺ^^11BLLjͺͺ쬬ξͺͺνkkαEEΩEEΩEEΩEEΩBBΩAAΩ&&Ψ Kχ ((…32€43¦65©88«98­99―:9±;;³>=Ά>=Έ?>»??½A@ΏBBΒDCΔEDΖFFΘHGΚIIΝLLΞMLΠMMOOΥPPΧRRΩSSΫSSΫSSΫTTάUUάVVάVVάVVάVVάVVάWWά††ε³³ο””Δ77I VVr©©ΰ΄΄οͺͺνhhίYYάYYάYYάYYάYYάXXάXXάWWάWWά[[έ©©ν«(&%%2++9,,;))8"". pp›ͺͺ쬬ξͺͺνooβEEΩEEΩEEΩEEΩCCΩAAΩ22ͺχK•10Ÿ43§44©76«98­99―::±;:΄;;Ά>>Ή?>»??½@@ΏA@ΑCCΕEDΗEEΙFFΛHHΝJJΞLKΡMMΣNNΥOOΧPPΪSSΫSSΫSSΫSSΫSSΫTTάUUάUUάVVάVVάVVά\\έ¦¦μ²²ο――ꫫ歭簰쳳ﳳεXXάWWάWWάWWάWWάVVάVVάVVάVVάVVά}}δκ%%3’’Κ««ν¬¬ξ««ξmmαEEΩEEΩEEΩEEΩDDΩAAΩ==Ξ #•Υ "22₯44©65«7699°::²;:΄;;Ά<;Έ>>»@?Ύ@@ΐA@ΒAAΔDDΗEEΙFFΛGFΝIIΠKKΡLLΤNNΥOOΧOOΩQQΫQQΫSSΫSSΫSSΫSSΫTTάTTάUUάTTάVVάVVάddߟŸλ°°ξ²²ο²²ο±±ξζVVάVVάVVάVVάVVάVVάVVάVVάVVάUUάUU܌Œη——Μ ``…¬¬ξ¬¬ξ¬¬ξ₯₯μ``ίEEΩEEΩEEΩEEΩEEΩBBΩ@@Ψ;Υ"μ=32₯54«6687°::²;:΄;;Ά<;Έ==Ί?>Ύ@@ΐA@ΒAAΔBBΖEDΘFFΛGFΞGGΠIIJJΤLLΦNNΧOOΩPPΪPPΪPPΫRRΫSSΫSSΫSSΫSSΫSSΫTTάSSάTTάUUάUUάuu≉扉恁εaaήVVάVVάVVάUUάVVάVVάUUάUUάUUάTTάTTάTTά‰‰ζ°°ν!!, 00Cbb‡’’ବ퉉ηOOΫEEΩEEΩEEΩEEΩDDΩBBΩ??ΤQμ"?χN54¬5577°98²;:΄;;·<<Ή=<»==½@@ΐAAΒBAΔBBΖCBΙDDΚFFΝGGΠHHIIΤJJΦMMΨMMΩOOΪPPΪPPΪPPΪPPΪQQΫRRΫRRΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫTTΫSSΫTTάTTάTTάTTάTTάSSΫTTΫTTΫSSΫSSΫSSΫSSΫSSΫSSΫmmΰ°°ξ••Ι??U)AAYll•‹‹ΐ££ΰ¬¬ξ¬¬ξ¬¬ξ‘‘θccίGGΪEEΩEEΩEEΩEEΩDDΩBBΩ@@Χaχ?aώV6566°87²98΅;;·<<Ή=<»==½>=Ώ@@ΒBAΕBBΗCBΙCCΛEEΝFFΟHHIIΥIIΧJJΩMMΫNNΫNNΫOOΫPPΪPPΪPPΪPPΪQQΫQQΫRRΫQQΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫSSΫRRΫRRΫUUۚšκ°°ξ¬¬ιͺͺζ₯₯ΰ™™Πΐ‰‰»ΓœœΦ¦¦δ¨¨ηͺͺκ¬¬ν¬¬ξ¬¬ξ¬¬ξ””ι^^ήJJΪFFΪEEΩEEΩEEΩEEΩEEΩBBΩAAΩ""rώa‹ώT66±76³88΅:9·;;Ή=<Ό==½>>ΐ>>Β@?ΕBBΗCCΙCCΛDDΝFFΠFFHHΥIIΧJJΩKKΫKKΫLLΫNNΪNNΫOOΫPPΪPPΪPPΪPPΪPPΪQQΫQQΫRRΫRRΫRRΫRRΫSSΫSSΫRRΫRRΫSSΫRRΫRRΫSSΫRRΫRRΫQQΫRRΫQQΫQQΫPPΪPPΪ]]ܚšι¬¬νξ――ξ――ξξξ­­ξ­­ξ­­ξ­­ξ««ξ§§ν££μƒƒζYYέHHΪFFΪFFΪEEΩEEΩEEΩEEΩEEΩCCΩ@@Χ$$zώ‹ŸL44¬77΅99·::Ή<;Ό==Ύ>>ΐ>>Β??Δ@?ΗBBΙDCΜDDΞEEΠEEGGΤHHΦJJΩKKΫKKΫKKΫKKΫLLΫMMΪNNΫNNΫOOΪPPΪPPΪPPΪPPΪPPΪPPΪQQΫQQΫQQΫQQΫQQΫQQΫPPΫQQΫQQΫQQΫQQΫQQΫQQΫQQΫPPΪPPΪPPΪOOΪOOΪOOΪhhΰ{{γˆˆηθθθθŽŽθ‰‰ζ~~δooα[[έHHΪHHΪGGΪFFΪEEΩEEΩEEΩEEΩEEΩCCΩBBΩ>>ΟcŸ;5488Έ98Ί::Ό<<Ύ>>ΐ?>Γ??Δ@@Η@@ΙCCΜCCΞEEΠEEFFΥGGΦHHΩIIΪKKΫKKΫKKΫKKΫKKΫKKΫLLΫMMΫNNΫNNΫOOΫOOΫPPΫOOΪOOΫPPΪPPΪPPΪPPΪPPΪPPΪPPΪPPΪPPΪPPΪPPΪOOΪOOΫOOΪOOΪOOΫMMΪMMΫLLΫLLΫLLΫKKΫKKΫKKΫKKΫJJΪIIΪHHΪHHΪHHΪHHΪHHΪGGΪEEΩEEΩEEΩEEΩEEΩEEΩCCΩBBΩ<<ΙI32©98Ί99Ό<;Ύ==ΐ?>Γ??Ε@@Η@@ΙAAΛAAΝDDΠFEΣFFΥGGΧGGΩHHΪHHΪJJΪJJΪKKΫKKΫKKΫKKΫKKΫKKΫLLΫLLΫMMΫMMΪNNΪNNΫNNΪNNΪNNΫOOΪNNΫNNΪOOΫNNΪNNΪNNΫNNΪNNΫMMΫNNΪLLΫLLΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫIIΪIIΪHHΪHHΪHHΪHHΪHHΪHHΪFFΪEEΩEEΩEEΩEEΩEEΩEEΩCCΩAAΩ;;Η +˜ύ *)Œ99½::Ώ::Α>=Γ??Ε@@Η@@ΙAAΜBAΞBBΠDDΣEEΥGGΧGGΩHHΪHHΪHHΪIIΪIIΪJJΪKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫLLΫLLΫLLΫMMΫMMΪMMΪMMΫMMΫMMΪLLΫMMΫLLΫMMΫLLΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫIIΫIIΪHHΪHHΪHHΪHHΪHHΪHHΪFFΪFFΪEEΩEEΩEEΩEEΩEEΩEEΩBBΩAAΩ33¬ ύ˜„ύf77·::Α;;Γ>=Ε??ΗA@ΚAAΜBAΞBBΠCCCCΤEEΧFFΩHHΪHHΪHHΪHHΪHHΪIIΪIIΪIIΪJJΫJJΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫJJΫJJΫIIΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪFFΪEEΪEEΩEEΩEEΩEEΩEEΩDDΩCCΩAAΩ>>Ο##uύ„Uσ40/ž;;Γ<;Ζ==Θ?>ΚAAΜBBΞBBΠCCΣCCΤDDΧEEΩFFΩGGΪGGΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪIIΪIIΪJJΪJJΪKKΪJJΪKKΫKKΫKKΫKKΫKKΫKKΫKKΫKKΫJJΫKKΫKKΫJJΪJJΪJJΪJJΪIIΪIIΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪFFΪEEΩEEΩEEΩEEΩEEΩEEΩDDΩDDΩBBΩAAΩ66΅<σU1γ g<<Ζ<<Θ==Κ>>ΜAAΞBBΠCCΣDDΥDDΧEEΩEEΩEEΩFFΪFFΪGGΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪIIΪHHΪIIΪIIΪIIΪIIΪIIΪIIΪIIΪIIΪIIΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪGGΪFFΪEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩCCΩBBΩAAΩ""r γ1Εώ -22§;;Ζ==Ν>>ΟA@ΡBBΣDDΥDDΧEEΩEEΩEEΩEEΩEEΩEEΩFFΪFFΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪFFΪFFΪEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩCCΩBBΩ??Τ55±.ώΕ|σW11¦>>Ο??Ρ@@ΣBBΥCCΧEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΪFFΪFFΪGGΪGGΪGGΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪHHΪGGΪGGΪFFΪFFΪFFΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩCCΩBBΩAAΩ44―_σ|5ΘQ33«??Τ@@Υ@@ΨCCΩCCΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩFFΪFFΪFFΪFFΩGGΪGGΪGGΪGGΪGGΪGGΪGGΪGGΩGGΪFFΩFFΩFFΩFFΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩDDΩCCΩAAΩAAΩ55±UΘ5cώ@44―@@ΨAAΩAAΩBBΩCCΩDDΩDDΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩCCΩBBΩAAΩAAΩ66΄E ώc±ύ700 ==Ν??ΥAAΩBBΩBBΩCCΩDDΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩBBΩAAΩ??Υ==Μ//Ÿ7ύ±HΚό f..œ99ΐAAΩAAΩAAΩAAΩBBΩCCΩCCΩDDΩDDΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩEEΩDDΩDDΩDDΩCCΩCCΩBBΩAAΩAAΩ99Α//žf όΚHKΎ (M&&€66ΆAAΩAAΩAAΩAAΩBBΩBBΩBBΩBBΩCCΩCCΩDDΩCCΩDDΩDDΩDDΩDDΩDDΩDDΩDDΩCCΩCCΩCCΩBBΩAAΩBBΩAAΩAAΩ66Ά&&€M 'ΎK-± F%%}44??ΤAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩAAΩ??Τ44%%}H ±-‘σώ7U""r((ˆ..š22©66΄77Έ66΄22©..š((‡""rV7ώσ‘VΎνύύνΎVS”ωω”S 8…ΛυυΛ…8 ,n‘ΖέκςωόύόωςκέΖ‘n,€πόπΐ?ψΰΐ?όψπΐ€?ώόψπΰΐ€??ώόψψππΰΐΐ€€??ώώόόψψψππππππΰΰΰΰΰΰΐΐΐΐΐΐΐΐΐΐΐΰΰΰΰΰΰπππππππψψόόώ?ώ?€ΐΐΰππψόόώ?€ΐΰΰπψώ?€ΐπψόΐΰψΐψώ?ώ?caronc-apprise-182f859/apprise/assets/themes/default/apprise-failure-128x128.png000066400000000000000000000374071524161175200273360ustar00rootroot00000000000000‰PNG  IHDR€€Γ>aΛ pHYs  šœ IDATxΪνw˜Υ•φ·ͺsOΞA“”Κ„%@Ψ"ΩΖ`ΜŽΨx°qZΌ³ Ζ,»Kt/ΨŒ1Α`ŒΦFDFΚy4£€Ι©g:V}Τ­žκκκž‘,av—ϋ<ύΜtWuuwsΟyΟ{Ξ=>ŒΖγƒρtˆεΏοΰCΐ|`Pδ>ΐ ¨ςθ@ˆa`8 μή^Ά} οΟοο—Bm.Ξ¦“πyCΐΰ―ΐŸ€RY†₯} οΡ¨ηgSΟία{Dέΐ+ΐKΐ›@ΫŽεδ π iŽ»€ΙΦίG„ό^/Κοιϊΐœ˜‘όψ,<Φο\ζρΠPλχSγχSζρPδροrtΉp A\Χ ΕγτΕγtE£tD£΄†Γ4 Ρ84DG4z¬ίYBΐ τ} Η6<¬ pΡh'»…ΐ£(δΉ\ΜΜΛγΤ‚ζζη39Δ­(σ—‰i{B!6υυ±Ύ·—­τΖbD5˜>&Χ,pƒtΡ σπˁoIžωDE‘!`R0Θόό|0!΄ύ:αό\Πυτγ)sXOύί<_ΎΎ?b]o/οτυ±gpΓΓD5m΄ίχp°ZF(€e¬¦~ž ΟGίΟω₯₯,*(`b0H₯Ο—.d« ᘠΊžώΏ©ΐ‘p˜ύCCΌΥΣΓ_;:hΞv΅°QΊ†ώ@ ψ5°Θh―OΝΟηΛ ΜΜΝΕ§(Έ%UΐVΑΏ—CΧAΣ@Χ‰kÚΖΦώ~~ήάΜΫ½½Ωή©ΟW½ΐ%όύ™Ύ‡ON+,δλγΗ3-'‘Iΰβ}bΘ€"˜–ao(ĝΌΦέΝpfχ _“ ώEw§:,v»9«Έ˜+««“‚W‘JΟpŒΐNXD·=w’†t;ΈΣυ±[«ev ςΫΦVΦvuў9’xψG`ύvΈψ.γtπςͺ*../gZn.ŠΖCQ’Gύ1NVαDX«`₯’Œͺ¦5όρΘnm%ƒ=n“ΡΟ:(ώ,Ό΄Ο™›Λ&M’ΞοΗ›ΈŠ’H” ‚LΞθς³R˜ ‘M,JK$Ψ?4ΔwοfK&#τ&π‘χΌW 0WΖΒγμrT•OUγκښ€!PTETσΉMπΐΨ|Αθαί±š|›u«" λό’Ή™Ÿ773”H8έ*9MΣΰ2Ɉ₯™όωω|₯ΎI9AT„1Ϋ…‚’*I‘«B δ_E [Œό9…‚ΊžΥ'…$ΟɈΖ δŒŠ`Ύ.γΎPˆ[χνγ΅ξξL.α³ΐN–pΤ“,όξ”O sχωΪ:ΎPOΉΧ‹‘¨EAQcR#δδ6ΐx] ΜπΟξγM尜'€²˜Χc™ω6+“ν‘ρύζη;Ν0k#En7η––’ηrρFO#ϊ1ωχΕI@ξ•αMŠcφ+ 7O™Κά‚όΣΊδμ™υ E€j€Š]ˆ–Ω=ͺpmοΣm7@„•ν3ŽΕhZvP(£ŒV!‘]gk?WΏϋ.‘t— ΙpωZI$½―-€Έ]~Ω©Τψ|ά7sυΑ 1‘*(BI™νΙY―ͺς˜Γ,––ΐœι™f"Š‚. !ŒM…‘³T—Χ±ΣΝs„@—η‘eΦguKζu3έ1 ^Λ=.«¬δ­ή^{J 0c/ŸHΎΰD+€K†1ί°XRXď§N%Χν2Μ»b>δ~ȚΎ_1AUΝ°ͺ©V%IG« νaΎΗTEIεlHX”&«’8YωΏͺr~i)‡†‡Ω74d?λ4‰₯ΦH«πΎsŸ±ΏΈ²¬œΟΤΦΰWΥdL― %Εμ+B@PUŒcR!ι!’ξΐ~γt‹2θYΡMŒθNΉΐSY‰Z\Œ+7αvƒͺ¦˜}-C …Hτυομ$ΡΥ5:P”ΰp(ηžψUK‹ΣWzψο7Έ#‹—r͏WVς55†O7QΎb(€¦ΏΗΊύ'Ž˜‘€)x‹iΦ˜Ρ,›ΟMXg₯υ[Π˜’›Kξ™gβ**₯Ω’MCΗΡγq’MM οΨA’§';6Π4΄D‚‡[[Ήeί>'ΰΰϊχ‹ ψΐOμΒDUŸW#-₯!D‘JŸn’|9λΣΠΏτΩζμObU1νR ‘»I 9™|;NΘτΏƒ;.ωηœƒ»¬ŒΈ¦K$ŠD8ΪΣCs{;­τ ’iŠ’ I%R\.·Wi)ώιΣqWU‘θλC ‡ πιδ.€9yyx…7S#! ΅ƒΐ»o °#­™’—½¨ΌœOΧΤ¦\QδΜWP“¦]’|ιο“Ο%>Pe„²CRguΆ•ΘΐθVW±»«ͺΘ[ΆŒ½‡ρo?ΞλΫΆΡfšv 4΅¦†9&pƌΜ¨«£Ύ’‚<Ώ?yNτΠ!†·l!ΦڊnOΙ|‚¦iόt~LwaŒ4ϊΪΏ—ŒΦΕΦΟ,*βšϊ<Š‚j%w€Mwΰ2q€ωΊ€UEβAJ@˜Φΐœ‘ΦξdώMΚγžϊz\%%Ζ½ ‡‰45‘…BΞX ƒί,\ˆwΚ–~σ›l;pΰΨΈπœζOœΘςyσψδΩg“0 —υD‚hSƒoΌf~R b‰?ά½›§±_Ά X4ΎΧ ΰ6c”`hD ΐΝS§β’³^ΒβΫG@΅‚=»5ηͺͺ!tUšt+(tβώ…½βGWσςΘY²΅°0ιt]‡D‚πξέ mΫ*• xκ© 9ϋΊλΜ^ψ‘qΈ].ŠssωΗ‹/ζΛ+WŽΈύH„^ zθ#W kŸΫΌ™ΧΣ £ΐμγ  p7πaλ …n7·L›Ž[QGόΈ2γ+Ώ¦άΚτIa[ωΞ)B1ΒE9σdrvΏνρwήyΈ ˆkΡXŒ„Κδrα.+U%ήޞξby5'‡’‰ω―U«Ž[4Mc0ζΕwίeυ¦Mœ>}:…99(.ΎI“ΠB!❝ι˜@ΧY^RΒκΞNzb1λΡR xώ½R€·b)}ͺ*ߝ8‘"7 ή„ͺ€€=‘&ތν“ΰPRψ’0_KΖϋ©T°°ƒ>kμoyώ™3ρΦΤ°ύΰA»ο>Ύzηάη?33g$|jn.±Γ‡Ρ£ΡT°_S}}&Mbκψρl=p―ΫMeQuεεΤWVRW^Nuq1Eyyψ½^‰Ρxζ‰yΈ»›Η_y…šR¦Ž‡P<΅΅θα0ρŽŽ΄ΘΕ#§δ沦³Σ^d2SΒ½'Ϋδ0ͺv K,Ÿ«©εΜ’bιίω°ψr9Λ‘XΒ<ͺ¨)Έ@H 0ΒXg 3fM^@>-ΊςJwwσ‘ο}&›Όμμ³ωε·Ώm ©={½σΞ¨τ―λx*+ΙYΌ˜ξH„CζζRœ—GΠη]'‘i ΡήΫKσΡ£lojβΥ­[yqWσ••+ωρ§?’(θšΖΰΪ΅„χμq€=tˆοΩc_Ž΄[2†ƒ'Σά.-@rœZPΐ%•UDK³―¦έ:Σ“‘ Dς¦ε (¦&_5g»d ­–‡š@έΚ «’ί„ άάs<χζ›iρvc[Σλκ˜4nβb’’G"YΩA‘λhƒƒ„χοΗ—HPζυ…Πd|?ΌmΡύϋννͺ«9}αBV,\ΘΗΟ<“ώ~v;“;lΨ»—£==,›3UUρ64kiIͺŠΊΞΜά\v‡BμO%r‚ώχΙR€ΐ―¬/δ»\|mόx|ͺΛPLΔτŠ’Z|ΈΥμKεPG ?L`7ω&84qEڌ7I!‹BfΝB-(ΰž§Ÿfokkڏ‰'΄χφrαβΕxέn\ΕΕDφο[R)‘ ΡΣC΄­ΨαΓ$Ί»Ρ††Π£QτH­ΏŸψΡ£„χνcxϋv<šFem-—.]Κ’iΣxωέw …Γi—ίάΨHΐλeαδΙ(DφμA·Ί rΟ/-ερΆ6{=Α©…7m'Z\ΐ RΛ’γσ΅uΤ‚#ώ[<Ε θd(§¦$yTi!f©frFM‚Ga±*#bMτΨ5ζ_U%Έhϋαϊ=Ž?ͺ₯½©΅΅œ2~ aΣΥߏΧνζŒY³ΐεBΈ\ΔN‹ςΟ;wy9ŠΗ“|= ΤΔ.J0ˆ»Έοψρ¨EEΔΪΫΑbΒυh”hc#ξΒBΞ>σLΊΨΈ7ΌGγqήΩ»—O/_nXU—ΛPL›’ ΪλεΥξnϊF>C΅KΨ³KK₯ oͺυΕeεδΈ\©iXΜ₯²:Β^£ΨžΛψ_·ψs]±₯ahŠ‚ΠτŒt―)|UšηΎPˆ§^yeLώ“GαPgg2 S‹‹SΐŸwβD”Ό<Ϊ{zψΦ}χ1υͺ«(\Ή’²K.‘pεJj/»ŒKnΈ‡ώς:ϋϊˆλ:ΒνΖ[[Kα%—ΰ©―O a…΅k‰57sΫΎΐiΣ¦₯}§­MMόfυjΌ'’ζε9r‚A.,/· sŠ”›ψ[-@ΎLσY ;VVTpΉFΎ"lΐOΙαCŠi7£cζ39H₯PμQ„L )‚ L‘'#SXγΗγ7ŽŸ=σ /o{M厦&XΆ αr!T•X[[ςFϋ¦M# rέ½χςΘ /€Έhzλκ@ƒp²d£ΝΝΈJKΉδόσωνκΥ E")„QW?v>ΕλMΕ–1='‡ηΪΫHεfHΩEώΈψœΥd,/-eZnžE –pKα†…½S,η Ε0ƒ 0(ισΥnG˜ £OΨC5‡œΕ‹I( Wήt‘TΖ,λhiogRM Σκλqλθ@6”ͺΆ–a—‹οώόη„GY.λ:οξΫΗS―ΌΒp4ΚόΙ“q»\†ϋˆY#!Htw“ΫΠΐΤργyΒf±Ϊ{{Y8y2«ͺp1Ό}{zDx… θrρb*ƒXμ’”ύq»€;¬Or]nΞ,.!ΉIzEMΦ‘Π4~ρΜ3tJ=ΈhΡΘl ‡QT•ͺ’’1_―hˆΫ{ŒΟώτ§Dβρ€uςϞbΕ}}D9sΖ Ξ_Έ0-T]΅~}R‘½“&9WB ΑΗ++©³dδw¬ πI{Ψ·Ό΄Δ@νό°lyu³ B4¬υs€ψ|C9Μ–I”/Œu‹ΙOΉΎ… ̞M4ηgόγqqάoοΨΑŸίz ]ΧQƒAόΣ¦!€Θ].ΎwΥUδ(Š‚KU“5Λ’”Ώ¬_Ο'nΊΙψʊ‚oΚ\ee)η oߎW8'}UόσλΧ3$]ŽoβΔτ ¨¬^?a‚ύνΕRŽΗε~T&9`Uε3΅u#Hί α’ι\%iΊ“ ƒ0ς˜±½DόŠ@J^‡‘°YQdγ°(C`ξ\ά₯₯άϋτΣ¬’B<ž±³©‰}6AΏWQΡ–4Ι#LŸ5‹«.Έ€s.δ;W^Ιχ>υ)>α…,›7Βά\ϊB!ΊVό4=Κ‘žV,Zd` E!ΪΪ:‚Π4 W~>ωUU¬έ²…ŽΎπ>‰°bαBΖ•” άnΒ;v”°CΖ²ηcMGέ©oΌδŽINΎmMψ\P^NC0')˜‘Κ1ΒZbη$πKoϊ™P,Ι $0³ˆ²H$™a4 CδC-*"8w.ύύάτΰƒΞP€1–Ρ ‘ι:Λ,0B0·›X[‰.β4šœ‚Ρ(JWށj 8דּΈψ¬³ϊ|ΌΉ}{šnmldςΈqL««ΓUXhPΟαpB&B!ΚζΞ孝;ΩΡܜςή’Ό<Ξ5 4hk«A[―/-€ ΔuέΎΠ€£΄εX\ΐεXtx…9ωI34ΧΦXQΟ΄ SQŒσΝκ\Kˆh­δΡ `^Ψ)­IΚλΆ ``ϊtPUž}ν56§ΧΠσΈχ©§Ψ"iaΟΈqΈKKBθι!ΌkC72΄q#Γ;vή΅‹ΠϊυτώιOδ΅·σύ«βw?ό‘cψώ?™ξdda­ZNττ Ηγ̝8—š:7ίή½;ιξΤΌ<η΅rZP@…7u-Ž”η˜1@)p†υ…Ωyω\€oΦ…sbQΧ|¦Ά$'_ Ÿ\m>— [γ=§žfŠ΄Ίwe%έάώ»ίYΘ |μc£°°Πρψ?ώΗ7ΪνΖ;iX“MΊŽd•o"Απ–-„6lΰΌE‹Έλk_KOψμήΝ†έ» nρΧ¦0γ]]Μ¨―ΗγJm2Ά]†BQPd%Q¦bΧΙ99œ’›kθσ­Dήh 0ΝJό`ZnjRˆXf­1sSB’xα“&LTԟXΆŒ₯³g§\3σ¦MΔ wuuŠ2'zz¨-+K³–βa!ΰœ¬€*K‹‹q§› ε:ͺ( ’6K=^*}ώτ2=CiHΧJ]β…τΘΑQθ#!#$"φ0σΖλ:yσPssωο·ίζWΟ=—re―ΧΛM7έΔφνΫY΅j·έvW^y%3gΞL9ο?ψ“'Ov xφYŽvu!€ΰΒ…θ–j$aΟIX”`xΗάΐ§W¬H»ξ«[ΆŽD Ε²)•6sζ8–’Ϋ•@F‰uu1₯Ά–q₯₯iζ<"Ι%??ν^Έ].GΠΜ £ _ kYδρ0ΟΌvj%—o4πΛt’qs„`| ˜bΎ3Ξbs]ΊeΦK%H.²Ι ŒΔϋιΐQJ* ΤSΉ΅€)§ λ:τΛ_rΘZ>e†­99Œ?~Lx`Ι’%\~Ή#N⧏›‡† αr₯_ ŽDCX―۝ χklΟ/©¨°_b.F³Ν¬ 0Γͺ%.!˜”“cι‰jEw BވΦΟa6 ‡•ΊV\`_FiDΙP'$gαBPώπK<!αS\\Liiι˜@QnΈα‚φ^ƒ@ΟΐϊΠCΙlŸoΪ4c ΝΨ‘ Χνœe ™•GΦRt!P‹Šθκο™νVΣμυ`3“X–ΞZΊ€dU€ SΘd·›"·'Λg₯gψLΝΠmζέ‰-4]€™t%&%l†‡ςσr.DΝΝegS7ά?± E—v_?ژ:u*ŸωΜg=τόσΌΎe‹SUVβ*+K[™dwξ’"±Xά§Σ:›-χΠ]ZJΣ‘#ΔmDOUqqς|-ΚΌΒΙςά£(œ–n}.MRόD댐ς°vHN‘ͺ°[‹H…@—α]RG,Ρ‚υC’Λ²IύwΖxΚΛ Ηb|ςG?’=½F>9/^|L ΰrΉΈφΪk3ZλξΊΛψ™ͺŠoΚ”‘%ηNJΰv£ΡΡΧG§CΏΐόœœ$ωc*«Ό„`λi•ijƏ7‘°3™–€J·+FS€Y) &C<]· œΜMςu!’œ€°šχ ¦KO"‡«Κٟ·x1žκj4]η#ίώvZ•―},X°ΐρυ|#ή;mΪ΄ŒV`_k+χ<ρ„ΤJKqWVf4ώ©S.{[Zh1ΧΘ1Ή¦΄ΙϊEΑ'#‘{χ’°Ή€%3fΘΠ$AΒ‘L¦ϋzzQ‘ύ₯ Ω`žύμziF a«–xίΫΫ>[Ÿ>+G`›ϋΙΟΠu‹ΕQς,ΐ_WG$εΪ;ξ`£½\ΪΑ§Ο;ΧΩoέΚ5Χ\“ρ½·άr‹£ˆ'όfΥ*= @`ώ|1ΉΥRΉKKρNœH8γQYΠ‘"”SNΑ'£ͺX[›‘ξφωπTW³·΅•]¦½g™Œfβ’βc K=r]iληeR€”)“οrγVTηψ>Νθ)3]Οτ₯,€^ΰ”UΤSf=BP°h‰с›zˆί―Y3ͺ9ŸaΞ‡±kΧ.žyζ^~ωεŒΰg?ϋ™γ±=ςθ_Цλ(n7yσFΒ\IO{'L@Έέό~Ν^XΏ>m¦.›7ΫM΄­Νt€oΖ PUήΪ±ƒΖΓ‡Sή3©ΊšjiΚΓΦͺε1΄Ερ( “Ӂν‚1Y€j{nYO5ΧΊξM ±’ΌΌQj„ ¬ib‹b”,_N ‘\Ο=όόι§ΗδΟ—.]κœσO$h•7έtρ ςK/ε‚ .p<φŸ=f„Bΰ‘€0ω³άn\μ>xoί{oΪ{ηNšΔ’SNAΓTΊJJπ64ŽΕψ•CλΕK–πx@ӈdͺoΜ  .!dΔyvH‘Γ*Όήΰ–!⡚}έ¬ΫΒΔ¬˜!I)ΛP/'HεΕγ-+CΎτΣΫxθω±/};γŒ3_?xπ 2΅»nέ:žΞ PŠ’πΓώojR%™žύž΄BηKι-€ͺόnυκ4$οq»ΉzΕ Šςςˆ:D’―αv˜;Ελε™7ή`³m]B^0Θ9³g£ͺ*ΓΫ·gv†%ο.!œŠDNΙ€)…Ÿe–opυΒΤ¬ω€TgΞΔΫA‡ίβ――§|Ε \99tυφρω[nαι΅ΗΆώτΣOw|½ΉΉ™AY^=44ΔC=DΏsΗNζϟΟW\αxμΉΧ^cυ† z/+Γ]U5¦…ΓT€ƒ/V,ZΔΥ+V Ηγ„wξMΓ?kξRZΪΫωκώgz$3}:σ&O6M»v·K’q{Oݞ–΅i-υ‘&“€όE6ήΗ‘ˆΒΑΤ돒v!#ZΠqηεQzΖ™”,9ΥοgGS_όΙOxξυ׏IψΉΉΉTWW;kiiIZ€ηŸžΧ3\ίγρπε/™"a|ηž{6ͺ|¦N5ς‰Ρ–<ο<ΞΆ€ΠKΞ<“_\=Axχn❝¨ΉΉψ§L‘op«o½5 ω»].ΉπBΌn7α;ϊΔ,ζ^d`'σ].{bΘν€UΦ>™˜Ρg΄ΘxLO νt›»Ι΄°ωZΡάΉT}ψΓΗ7 T•ΗΧ¬αςnΰυ­[Ž9—ΏΘRΛηd"‘Τ"ΩοϋΟ?υΤS3Rč‡qχώ`X‚Όυυ Τ‚±ύΣ?±φž{xυή{ΉηΊλπ{½DLšrWEΑO}”M{Συ^zΖ,=mx˜πckNν0‚.ΑτΔP•]Φ©κ57eΘβRθ> 0΄δEEˆ”τqς݊‚―΄”ΊK/₯hΞ―—‘!ΎuΧ]\w睎ΚXΖΌyσύa,γ Cˆ΅yσf~ωΛ_fΌήν·ίNNNŽΓύΦy|υjvΛκΐœ9·=f`υj|ΝΝΜ¬¨`Fu5Ύ‘!^}•Α7ίL™±}‘P²πΔ:Κ Ήχk_]'Ψ8alΤο4ͺJ@M+ώ Ψ K‰˜OQY’TΒΖA5[R(νlMΓ[\LεgP»r%ž‚ϊωΣk―ρ‘―~•ί―^}άυ|¦οvR€ΑΑAš2ΤΥ_ύυi–!9ƒ‚AξΎϋng+ΠΦΖο^x„|3ϋ¨λ„wξ€oΥ*ϊž}–ώ5kˆΩΘ§D?9>…Ά~‡?άx#.U%ήΥΕΠ»ο¦FY™*―2$‰ΌŠ‚7έ=Œ7MA°₯­?α€y#/jςB)΄―|ŸκσQ>>&ΰ’θτ―oΏΝΓ«žη΅-[8#Sppp½Τ*ŠB~~>ΙΗΛ/ΏΜωηŸοψή«―Ύš_όβΌυΦ[iΗξ{ςI._ΆŒiu΅3’ΐ­IDATuΈ++qWU?thT“?rϊϋωζΗ?Ngo/Ϋ Ί€„ο_y%3Ǐ7ZΖΌςJ²'@2ί’‘šPϊoͺΘΎNL.ΐe­χw˜Λ::Ί.ΛΈ“MDJH¨ €Ό―θ”TΜ_€ΛηE…–£GΉωΏ~Ελ[ΆŒΊΨb¬£ΎΎ>c‰—ͺͺ\pΑTVVRSSCUUΥΥΥTVVfzVηG?ϊ+WLγ"±Χέu«ξΈΓX˜:q"ƒG‚Σ’›^}•ΉΛ–ρ›ό€ήr* Ρ†‡ι_½:Ή TXs)Ά΄σhŠζΒAτrΩhαŠ-Ρ£gƒ6δ/HU]ΰ+-‘ξμ₯JΛΒ¨΄ΉλρΗΈοΙ'9Ρ£ΎΎž‡ό;@EEwάqώτΈxΜδE]ΔSO=•v썭[ωύš5\Ύl²2 眍P½r–ηTUQ4yZ"Αα 8sΦl.X|ϊIU€²²2GαΌώϊλ ‘Τh»{a”`ίvΫmΣΕ^―—oΌ1γϋΏyΧ]Ζg( ήΙ“fbm¬38Σμ·χ1Μ†δy‘xάiš6§dPJ­‘΅Ϋ„kint„’P9k6Ύό|φΆΆςψwZۍŠ™Ί₯KͺJoc#ƒ‡s½\Z}²FΆ57n<ζλmήΌ™?Κeζαp˜ΖΖF^yε{μ1ώνίώ?ωΟξέ»ω™L5»Š‹ρTW;n<=†”YF†O·^'‹b $φVrš؊₯f¬#aœ?`Ayz2%l‚>O HΥ<#λuΝO~Bgo/yλ->{αE¨ͺBέ9ηppΝΪ7o¦nω2ωθGΉύ·Ώ=) pΪi§e<Άξ8θππ07ήx#<π]]]τχχΣΫΫK___FΪ8%‡π»ίρΡ₯K)/*Β7eΚΘj`)<},ΫΠ9)‰ΣϋdΑ¨αԍΪcΈ„΄ψi`{JXc‘zΊύ―‘™·Ÿόζ7ΙδΝύΟ<ΓΡξ.t pΒ‚UUτϊ¨ζδ€,s FQaُxLPβšΖ‘τ†”ƒVφ/Εr₯8‰p8₯Μ; ε§Μ ₯½η^}5ωϊP8<2ΛAΕάΉ ¨΄Ότ2…|bΩ²Qχ>Φαv»ΣBΐh4šδ­ΰο½k6l`l «š ₯“°lnAλn€£p ]η@z«œ½)leΌm}r$Ά%€ttδκέ‚ϊ:P«ήxƒaΫ¬xaέ:ΦnάΘΩσζ‘[3ŽόΊϊ4qθΥWωτ‡?ΜΦΌΘξƒΝ'μFϚ5‹#Gްwο^Ž9B[[ϋφν£±±‘;v8΄y/FW?νέέL6W;ν5<`(Μ(!ΓζΥ™v9ι:{­½†mrv9 Γ²0$’i D£δyΌ)fΕτyΥU$4νϋ½qlγ_ώλΏ8kΞTE‘|ή±l9―Y}Bntww7έΞϋο¦ UQΘρϋ ϊύψΌ^ rr¨(.¦Ί΄”βό| ss ϊύxeέΎλΔb1†#zθθν₯³·—ζ#Gθ … ‡ ‡ …ΓD²Λ,`FCZ8œlηhœv*Λ¦v ’A±φ…BNy€·³)ΐ3VΨdvA\Ό™šPέnϊ†‡3ι:OΏό2,^L}e%•§žFώτ<Θ`K Χ}ς“ΌψΞ†γόλ(ΚΛcζ„ L©­ebM ƍ£‘²’ͺRό2>Χc1΄αa£N_nΟB"1²ΰΥμMδυ œ=4M£εθQš‘ιΘv77³³Ή™ΦŽrό~Ξ™;—―_vΪΊΥθ$nœΥ¬§ω|σL˜!3‰‘<Ύ6½’(Ž₯“Έ“όcΓgš­ΒՍRg€@<O+ΆŽΦφvžzι%ΎvωΈU•šΓηž£}γ&Vδڏ}Œ›~υ«.tΫΝ%gΕ…K–0₯Ά–ΒBŠdoD_ρξnΫΆ1 ·nKaΡδοIcΥ,ΐUβρPRXHEQ§ΧΥ‘žw½‘ύ‘—‹Š’"΄h”Α7ί$*λωu'.ΐΊ£x/² =‹2¬Άo=)°N Π) ‘"€h"AΛΠuΆ€†.ρhwn.n—+«0xφY.[ΆœΪς2εδo`°©™\°x1O½ό2[GλΣ?Ζ1qά8Ύyωε\rΦYψ½^MC‹F‰wv2°mqΉ¬Λβ1mo*‰|$†‡&fΑ¦ά€’$?„`pχn£΅«πːΨΡ3»,€/Σχ>Ӛ¦¬s&w~ύλάϊ₯/1³Ύ5"ΦΪΚπφν„·n%ΦΦ–ΪnέΙίΪw ΅ίdkc(Ϋλ¦9Χ‰wtoo7v΅˜ς4ΑΒΒρgœΥš–ΚΪφ"H{Ÿ|ναΦVήNw―('yFH`τ˜Mφ PLΛΝΓ•lξ([΅j,ΰΝ­[9Π–}ƒŠƒGPUΕδšZ<9ΉDϋϋ wt …8}ω2^ΫΌ™Άts5κψΘι§σ―_όΧ_y%υ%%D oΫFdΟβmmθ…₯lϊ<ώ݌|uέΘ{ MKΌΕ§))'ΐ'_KΎ“ο—¦ίjώυl`ΠζZnΪ³Ηή0²ΈKσθLlΜF`ΘΚvF"–E":Ί¦Σψ0‰H”yS¦¦u΅rw<ς}‘At]§lώ|·›ΑζfZZψι΅Χ“ΰ§ΧΧσϋ›oζΎo›3fΝ"ΨHΟσΟ3τξ»ΔΪۍJS‰D’E³&ƒμ³ϊšyΎ5œf}&4oσιc‘}3a+L„N$‘|Ύm`€Άtσ 0<š 0qΐΗ€ €¨SασRνσ§ξγ+·s³p!O½τ‘Xφς§P8L<‘ΰŒY³Q½tt†"14Dέμ9$4υ;wf½†Ογ៾šΪטT]3πϊk„χνC$FΟU•«•Σ#ΜYε°„εΈSY–]ˆ)εΩΆΩlš]ΈΊΥχgwφٟΥMΨ,Οoβνž{x›œά£*€&“BΙόmTӘ‘›‹[Ξts˜‘.κηΞ%!FžΙ œ6c%δTUΡ·oΡN|…E,\΄?ΎςŠcXτω8cζ,ΉρŸ9wαBD(ΔΠΝτΛνVέreN`Φ,|γΗ£ψ|Δ::2.štjτ˜i†κN³ΞIψ&έiζ‹,΄/²KΨ¬ŒΣLΧv)θΕψys3‡SΩΩŒ ΎŽEΜΔΠ7L7яsJ^9.ΧHhΊ–t–wooΫ–Φ¨1-594„ͺͺœ1{6ŠPπ—–»k±ή^ΚfL'?'—ΥRΧԟ1kτιOσέO]EA0ΘΰΆmτ½σ±ΆΓΈrsȝ7ΰŒxkjPύ~ΏWi)‰ΑA΄ήήΜ3L–Z §(@ˆTβΠήΞ)?οdφ³DiBΝ–ψΙF[Ξ§·—‡Z[I€žσΊ ο΅±*ΐp1–nαi[sθpoE΅΅,^°€΅›618JŸώ­ϋχsξ’E”ΰˆ 3Τڊκρ2uΞlΦνάΙαΞNΚ ωΙ—ΏΒ7.3ni‘sυ"­­ˆxœΌ9s)8}1ξ’"·›H4ΚΣ―ΌBQ^ΉξŠ †wοNw£$P…žΙϏζσΝΧνμž=4΄Ώ΄smžόE5_·ΆςnκŠg ΈΧΞŒ¦`Τ|Ξ δ{bv‚SΠγq†»Ί™0{6s¦MγυΝ›“ύν3M{φςΙσΞE ξœ ƒMM„ZR1{6ωωωL©α—ίωΣκQ#Ί^{ώ-[AKΰ+/§τάσπ«F‚‘h”Ϋ}”Οάr O­]Kya! §NEQUc‡§Ν˜Α# Dζ₯νv‘;(Ašπ¨]k‰—ύΉέο;$~²ΝώΞh”vν²WI9«΄_BΦ Ι…8Σσς,ΫΒ¦4 1ΤΥΙ€§°tαBΆμΫG,ΑrΜ’υυQVXΘ)γ'ΰˆ ιθ έΓμE‹XŠ.€pή\|%% /¬_ΟΝΏώ5w=ρDΪN‘XŒŽή^.Z²ΔPR―—hkkφΩ„žeζλ™»&ΪΟ3qBΧGψ±ΖύςœH„―oίξ΄[δeΘ*ΰγQ€&ΰ‹Θ₯D ]'Γ΄œΉ›w ˆΣ½o±ž>–.ϋη,XΘ9σζΡPUEANΉΕyω4TVpκŒL7޲ΒB†Ϋ3ΨrŽ‰’@‹Dɟ6•ςeΛπ••‘Έέμ;tˆοά{/χ>ύt²ΠΒiμhjbαΤ©Œ―ͺBΙΙ1Άv5ύβX 33E6;΅v_žIψ–όƒΚ™B6ΩΦ^υkΓΌ{7;Σs/™Ρπ]$ΫfεΊ\\5†Ί@0Ήέ‹jΩυC…δΞί³gS>s&žά\0w adx(Δ‘_"ήΣcμ.·‘ρ—”R{ι%Ιχόϋογξ'ŸΜΨΤ>κ+*Ψτΐ†bφτ0πKΙ\ccΌSŒο`κ3 ?ΙQX;?»50I*`k?°i“}Ky$­n¦Ÿηγmx ΈΡ$†βq6τφ0ΞηC‘Ό€ΙnΏj*ˆjίΊ…Ξ­Ϋπζ–”β qΉ\ΖΉρ8Ρώ~’½=ɝCΡE‚uuFΒ»οςνϋξ;fͺΈ₯½;Ÿx‚o\vΒB$ΫYμ qVQΡΘήΎ&°neξμ!FΜΏ½ƒ“»Š"·47”ΖXƒ¬ ()fζμΩ<±vν˜M ύ50@ίΟ’3Œ°0??s―½c΅ Ntm6smQ†$Ϊ· ¬ŒŸΉμ+‘ΰ»»vΡ–^©ό<ƞρ₯q©W'Ν‚¦1œˆ35˜3²6AZvMn++B—Ϋɚη˜J‘3²}œ‘£νL8ύt‡‡Ω°kΧqΙν­;ΉςάsΙQ‚Aβ==ΙmΰŽIθ–€£ΰ³˜kέ`¦Έ ' ’)δ³(ΧC--<}τ¨ύ[Ηdψή8ΪΟ;ΦuΤMΐR !ID"Τϊύ”x-@†Υ)\%T]—ΫΗY,"™;…Ε’ΰόπΨκΥΛΟFsM‡σρ₯K“ΫΐGLeθFΎε<αNΪ³vYΒΎdjΩ)Ø­ΠΓΖ ΌΣΫΛ·œσ/b©κ:‘ `ΒοšοΥ%˜ž›‹_QχLqι{ «{°(ζ~B 0n“'N䏖υΗ2φ:ČΊ:¦ΤΤ ƒ$zzHτφ¦΄;ΗzOŽ·•Ζ»ϋ %]Α‘p˜·‡jŸ/9σ:Bw…ϊΘφ³KΖ£rσΩ$0ˆvtR0iΌ°~ύq5•ŠD¨)-eΞΔ‰θΡ(K½ή11ƒYx'rHŒUψ£(•ωΎί·΅ρ+ηIp?πdλΤkΗ»>KΎ€m5ροΪ±£Ώ_†NVΠ’Ύ°LwBΚ’OHω_VεˆD‚ΞW_εΤιΣ9?K'Π¬(6‘ΰ€½=MΆPΠR €Ϋͺ‰@™Sώί|"1&αλv²Θv}€ΧΊ»ΉΥygΤ]²ΰγ˜–@ύ- τφ_ΓVΰ·m‡Ψ70 ΫΘ™‹Š)‚5ύ­ŽŽfEΘς‘iZ²@T·ά„θαΓ μΨΑW/½”’ Λ²³ώX!(ΝΟGΧu‘zΗuύ(³Ζ'ΨΘΰ‹/mmE·˜}'!λΆ<~ k—₯„[·²t¬g#“lΗΆτυρω-[2™ύnIΙΏυ· MpβΗy LSRΝAUευ LΘΝA•©_³Ε\²δŒ6tΒRk $+‘zσ˜Bωʏΰ*/§½·—=­­”0yά8ό.Z(Dθ7Œ­Ωμ”―•εmΕP6W‘ ȍfξ„―λ:oυτpݎφU=Vž%πΧ!¬“‘§aτpΩ?μͺ«9«Έ―κBQ,ΒD ¨ Š’Ž<F£cΡΌZƚD“mT0”(P_Oξ€Ixό~„’ ΕbΔZZˆμή=ΆάX“BζΎ+€΄™ό„¦ρδαΓόλΎ}NMαŸu"fώΙV0j·¦Ν±€ €O«!ΧνΖ₯€ SŠ‘!'-AR)€΅ƒ"όͺ‚Ϋν66uΆΞα΄7!cj³ž&Μ±Ό'›©Ο x€ξH„[χνγYΫ.£–Ρ|ψΛ‰’z`Ώ€ŒΟΆ+AK8Μ;}}Μ ζ€€Γ$ΨΣυdκΨ1XicaΙVΒ£ͺ(£uΡ₯$λ±Ρ„žA°ŽΙ?°½ΏŸ/oΫζTΝkŽƒΐ;ΡΒ?Ω °XœŠe‰™@z‘«“ ’ΰχ¦^Xj䍉,‘cς’ ΰΕ²ϊWNϊ°ϊφΡ”Δ‰ύΒρ8O9Β—·m£'s­β;UΩ―ŸŒŸ‘žό;ΕQΰ)`"0Ν~pσ@?»)υx(u{,‰$kfΠ5€XΓj$7Έ²ΜZαδl !Ζ”r0ίcΊ­/pͺ>h¬λιαG{φπp†E+r<%…ΰd η½P$QρP‚±;y 8μˆEYίΧG[x˜q>Ή.w’»O2… I$Λ_yN@²£š~{fΟφvNX]ΣumQΓΑ‘!nΫΏŸ»›šhΜ\ά~‰±šη€68Όχγ ΐχ»VΩGŽͺ²¬Έ˜«ͺΗαsΉP(š€Οpͺ =ͺBΫύޘύγΊ)ψαxœ;›šψΓαΓN›νΨι6ŒΤξIκίαvmVc΄§Ÿi?Υuv‡B¬κh' ͺύ>\ŒpVŠX TYš€g^ήu2>Š™£\»'εΙΓ‡ωΚΆmΌέΫλ”Ι³ŽG―œ °χ~²ζpΙ°ζl[ΦZG‘ΫΝ₯₯,Μ/`rNŸͺΚ} Œ…'Εn·Γ―rƒ™ώ+p*ήΘ𞈦±s`€WΊ»yςΘŽŒή[8 |#±/…πχTsδaΤžK–…*yͺΚδœNΝΟημβbJ}>ς\..ΧΨ~„“Π3Βcμβm↣‘νθ`mw7Ϋθ½Œ=ޱtλ dϋφχzΌΐa,d8'«ΟΏ’0=δŠΚJΞ-+Knqk‚β$Ίk-@\ΧYΣΡΑγGްm`€αDb43oŽ—0Φμ=ϋχΌιο'Θ–a΄2;{¬o:£ €%%,*,€ΐνΖ­(Έm{Bθ–mΪc‰1M#ͺλτΖblθλcU{;―ΟaΌ ά-ω‘ΎΏχ Ώ)€9‚’<ϊ6ŽyTxγq ϊc1:’Q³7bW(dο½3Φ±ψ&FŸήΠϋεFΏ_ΐ:– Μ|c›Kr₯πsT5ΉWQpΙΠR“¦<©‰‘D‚©ργk`©ΙxώΰΗ2ςyߍ `ŽS0ͺ_–Σeω~=ΐ κφa`Ϋϋω¦ώORsKX€‘q\ώwβ3¬#!gψ_€ RΊψ`œtε HKπaŒκδRw΄=™ω™–ί!π?qB‰…Š‘‹Ρΰς `F‡ Ώ΄ͺΔfα±uΗt]ϊmσ―&gv£ζqFzϋ5Œb́-³θΒp5@Ήt!AI@ε0RΏΑHΌτK”ή…‘Ιlα=fηήΛρDf(Α u§ΊIENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-failure-256x256.png000066400000000000000000001217131524161175200273340ustar00rootroot00000000000000‰PNG  IHDR\r¨f pHYs  šœ IDATxΪμw|ε΅χΏΟΜV­z±eΛ’-ΉΫΨWL±)Ɛ›RL!ΐMξMr“poΈδΝ ! €‡š !ΰ0½ƒ±MΗΖ½χ&Ιj+iwηύcΚ>3;3;’ 6θ|¬δέi;;η<ηόΞ9Ώ}'}'}'}'}'}'}'}'}'}'}'}ςΡΡw >rR ϊQ (ŠŸ"γ΅(2φI@;Πμ7~ZŒΧ:]ΐ`Oί-ξ3}ςαJ9p"0 4* Š­ί―0^S€Ώ½Ύw ΘHΏΝΏ5 mŠŒρχ:`)°X Όμλϋjϊ @ŸΈ„ˆρSœL&Κ~$ΛRΰMΰuΰE`/Πeόtχ}΅} Oάe0Pc¬κγ eŸjΈλG³΄ΖΰMΰ=Γ[Ψjx}g>Φ2ΛPς€:ΓT|Δ?σ^Γl^1ŒΓs}BŸ|\d.p°h5άbνcϊΣmάƒΖ=™ΫχxτΙGI"@‰±Βίρ1Vτžώάaά³γφI_pTI p,pp&:BΨ€(’$’0"‘ͺ”†Γ…Bͺ*ͺJLU‰*τ―?­itiΙtš6γ§%•’Ή»›ΦtšΦTŠ&γοΓ,λ€…ΐcΐΫFθΠ'}ΰˆ•Σ€Ο“ρθ)·C"qEapAƒb1 QXH],F"•\Qˆ( ŠzΠPvBXωAδ₯WΣΘ―eŒΏ3šF·¦Ρ‘NΣnƒΝΙ$«Z[YΧήΞζŽ6ttΠ™ΙΚϋšή–σ§ϋ΅>p$ΙΏίA/ΐ)8X_LH"ŠBBUšH0"‘`Bq1γ‹‹©ˆD(PΥ#βΓw€Σμικβέύϋyw~VΆ΅±Ί­ŽtšL†”¦‘ΌΣ΅£$] ό©οΡλ3†„€Rΰ«ΐ–¨*ΥΡ(c1†«ϊΨ’" ˆ(Α™4Νύu°Ώgnλ΅OιΞdXΫήΞ²–V΅Ά²¦½mΙ$;:;i?x‘D;p5pΠ„^¨Τ'}ΰ*ώtΰΰ›€ͺ8βΈ’¦”–2"‘ .g@4J΄§«»¬°nΚmώΙδn£i (ξ@ήΖό-οΠHte2lK&ΩάΡΑΚΆ6^olδέύϋiJν~< ΌΦgϊ ΐ‘Oώ θ₯Έ΄Ο,/ηSύϋ3Ά¨ˆ²p؊Χ{₯δ²’;WnY±έίΉ½ίΚoΎζfœΗt{OΒ:3š»»y―₯…νάΙσ{χ’p0d:p0‘·+~H’ŠΒ€’>Y]Ν¬Š C‘ž­ξNV}57Cηκν/ΓTά[6²ςΚ†Ηi0<€5•β…½{ypϋvήoi‘-&₯υ9θή.5<‚>ι3=\ΜινAͺ"Ζ1½¬Œ3ͺͺ‹Wvσoy%6•ΚΟψ°ΕΛ{pŸ°asGΟμΩΓ+ϋφ±’΅•]]]rEO¬Bobκ“>ΰ+S€/ίθνŽ+.ζŒͺ*¦”•1¬ €˜_<ο³»­ϊG³Έ… n―I’L§YΣήΞ’¦&έΉ“χZZδ ώόXάχˆχ7©AGτΟͺ{s€S+*ΈΈΆ–ΡEE¨ͺwLο\εεΥώγ$2°θζ)H˜A2“aYK χlΩΒΒέ»{{Ζθ@αΥτυIΎ\ τοιΝ+ …˜]YΙ%uu4X7Tδκœ1τ‘μΦnΟΐΛC0d}{;·mάΘ3{φ°?•’ΠαNΰ'†WΠg>¦’ wΰέ LλιΞΓ ˜]YΙ9ΥΥΤΕ @dΛώ„lό\zΡgσzn^‚![“IξίΆgχμae[[oΞΆψzgb¦Ο||€ψOΰͺžξ8Ί°Ο ΐ εε ˆΕ¬’ZΉΌVqΊυ‡!ž7 Žf(ŠMΣτίπEΛϋΊ[ΣŸA0kηά–LςςΎ}ΜίΆ­·8ΑUΐ­FˆΠg>βrαώνΙNUα0—64pJE UEŠΫn@Q²ιΉCΌΚ‹|8ƒW-€3­η‚Έ)Έ‡; ΔA7fΊΡ'Π4Φtšgχμα†υλٚLφτ ˌ0π}ΰ£)!ΰ>ΰltBΜ@R 1―¦†/ͺ%ͺ*Y…GΨ €’θF@έJew‚²—α¦ΨS} ‡–ΙΨ¨CβΈa.Ωƒ[7nδήmΫhμξ Y'π(0I5‘ψ˜|Ζγ€Ρ›uIy(Δμͺ*ΎXSCm;`Η—ΨWyyΥ7Jws τΚΨbuΏzώ£Y%ΔΝ ˜Ηρ0ο47σΧ­[ydηΞ5 \ŽNNάgŽ> \ή£Hͺ£QΎ;t(Η—–Q”œΨή2†»o3Ξm{ ΄β£Zόč—Β™^Ω’€tΌL†·š›ωΡΚ•¬οθθΙџώΫΐϊ ΐQ"s; ˜Χ Α'««ω―!υ„Ε ξYŠ-ͺltƒ`FόN ίΥ^ΣriοαR|7όΰΪ~vΈ μθ-FL§ωυΪ΅<΄cmΑΫ’wΤEύˆ}žπmΰn 0ΘΗsω°a|ͺΊUΙ*uΦ::γ{)φwn›ΗBθJoxΊ΅P~ 3υθLG:Ο)_~Νkƒi¬Η=`Εν³Λˆ’prEŠ‹ΩžLΝη‘ΐR>B5%`z‰η—ƒl‚―Μά~ύ(…₯Xίpσ­‡PAQ^€πΘxΥ9sœ+ώΑP·ΏέΌ §«άΣU30Žο»Z;;t5οmˆΰ¬#dw7lίΞο7lθ ία_ΠKΖ·τ€#GΖ_ΜqA6‘Hπ† £‘ ΐˆγ]Rz€PU„¦‘(>ι?Ηͺ/!§Dιύʊ8Β‡Σk>n½ηk=‰ο%ε= Cΰ’θ+Z[ωŸeΛXΧήτho Ν{}ΰΓ—i謱eyγ!˜UYΙ·λλ) …¬―ΘJ-BQmW$šŠ‡;·ΚΩηƒ ˆ#(τ­τ)λ l4 ν@ˆC2ΧλHkί_±‚;vυοΡYŸυaž|6:žoΓ~α_­ε+ƒ•€œΎTΜ#$·„ω/G!εxUAΪΟψιqμj† ŽΈΫYkpΔ(Žλ΅½ξ¬Fμιύ1q–ƒ8φU„ΰτͺ*ϊE£|ΠΪ$$ˆ_>ΰ(Ξ¨Gρu_ός—ΊO(*β‡ cFE…΄‘€ΤBΡ•ίJύ)ω\]}Ŋ"Ε—ΠvPήq\ΝΈFM>¦[Ώ‚θΧ‹ϊαυ™zΫ'αf zb|>ίΨ’"¦––²Ί­νyύΰs@ϊˆ3­ΟΉψ?όG]pZeί>ŒκhΜ½¨G˜+Ώ‚@sΟι»Εύ¦»/dΓΡƒΔŒΥώ€^Bν5!ΐΐ/¬;•ΝμW0½Γ@XHΣΠTΥΪO€ώΕJ`œδϋΥγ,ƒ‡ι±Χ%}fΣC©ŠFωdώ4§Rωš‹Μ“Š>Σρρ>pθεqτ”Œ’/ή?oΰ@Ύ^_oυ8β|ΛΥWŒΠ90C^υs €"§ς„ΘQ W »ψςΠ:ާ©*Βθ”ΣΌw.©1Σ@ω5Y1|<a6ΉmΫK¬C83+ι±·dœWΒjόz£Ή9?‘‚>δuπΧ>πЫ灙ω6,‡ωΑƒ9©’2§P'‹ς›+Ώ’/)½ fW}(ΰ·ͺυx₯w)|ΎTMV.DΆ@Θ:†˜J LPΟασ:χ³Ϋ£e·' ­9Y‡{ Jϋτ(c`‚‹ρ_;wς‹5kΨŒ›πeΰŽώΑ£Ε(0\-_©/(ΰς‘ØXZκR¨£H.Ώι δ7Y€Oρ¬ϋwύ[ZsφυS~3η.―°| ΗΣp‚ˆRxΡ£/οΑ§0?£i@ŒΧ57―£ΈθνΚ.c½άOώ=’°Ι₯₯,km bκ€γΡ3SΙ#]±Ž , ΐθθϊ‚~:r$₯‘ˆ« Χ]ωsϊf™―τΫκ<1‚l&@9ί\ݝE’’Y«έ‘~£-XσΨNτpuΧό†ƒ\SO<š‹φuuqΩςεΌΪΨ4Tύ$Gx[ρΡ`^fδΫhLa!Ώ34τΚ=S!«π†λ/Ώ―‡.nΏP²ϋš@ͺυbVΝζΤ(Άν­φ^@›1·vz䜽Χί½>Ά‰KHΖAECζηgδΤτ$4θmH _›±_sw7—-_ΞKϋφ9Β«θƒdϊ @/ε₯|1ΏfWTςΝϊzTEXŠ™Uh)―{ŠΟ©ΘΊβŠμκ/π-VάNO!―›/―ŽFœ―ΙJί …Οηu˜ 4&w„iΥ·”_6nŠbM‰p£D"z&CΞn€ΣN“ικ"“L’uw[ AZ:ί30ήοΙuητ†’­ZΕΓ;1ˆ½ œΨgz&QΰΟΐyω6<§z_¬HDUm νΦ3Σbσž=ώŠ©τŠΤψc†°^@d …’―-Ψ±ΪΛ«Ίυwϊςz8;}Ζ}ΙΖ$r A¨ͺŠHe%jEJ"ZR‚z+‘—N··“nm%έΤDzί>ΝΝ€[[Ι΄Ά’ΉΕλ= <Œ@*“Ꮫ6qΓϊυAξζ߁‹Π‡ϊ @ω%πέ|}vΐΎX3(g•Vr˜z»βΊ(΅b}ΆUίΗHΉ5(θ€μ² q$₯tήKΩ]Wn?/Γ‹ο/`>n@Ÿ€~aDxΰ@β£GF„ΓΉsݎΩCWί2 tmέJΧƍΉ“‰€­'FQ*%ξΞdψΛ–-όzνΪ { ψ}Y€ός-τΎΌΚ^Ν ¬’JJͺst+–ςε¬ά₯V$…G ΆΕ±κη΄­:Λ^±WΑi"oΉωΘCsJj FωͺψΌp?<Αλ΅ Yr«LILJbςdB₯₯Ω> ΚCΝλzόBσώ†Γ¨……„*+‰ L|ΜΤ²2MMh­1K‚‰%%$3ήέΏ?_Α θΓK¨ΙDGš0Οp—<Λ{πikεwKΩ)φU\rεέ@;ΕΪ^ΪίmΥ—kύΑ?+ ΧΘ‘Β έχζDΎΚΈ£Πψ;>~<Η›sΝιtš¦Ά6Z;:hνθ`_K --μki‘Ή­Ξ.ΊΣi SSVXH²2Š HΔbPTP@Bxx έ;wΎt)έ»v靁&8Ψ“Μ‚Kkρ/Φ¬αΞΝ›}χ4σΠΙiϋ €CNž$OmΩύϊqAm-a7·^‘ά}·_‘W}+Θφ©Ζ*Έq8ψε:uΕTϊ•Ρθ…Cό6š^VΖ7 •+ΟͺŸΟϋ C’(Š-ύηiΜϊzEA‘‰@zζυ8Δ9¨γPw ΚF!/π'ρ±c)˜8Ρ¦ίΉωfξα…)|‰E"”& (/ηδργΉΰΤSi¨φυ˜Ϊ½›δš5$—/·βϋΌFΐμΚdΈvέΊ|žθ¬Γg‘w~μ @}†ϋΏF% ΉjΤ(›²Ω €±ϊ›#Ί|Ρ~‘X¦kοcΌ³£PΚύλ‘V3Ž[՟―‹ί‹n»@ΰ °ηβ$μμ"οš.ŒD(ύΔ'l«?Ξέ~;έ©ΓS#sάΠ‘\ϊιO3g$’α°«B§φν£υε—IνΪΥ3ΎΗ ’ΦTŠYΆŒσΧ ΌL:>Lε;@ΐ› kθ«ό—¦7υHΰ\VkλoOΊ.Ή²Οκ3žmΆBΕP~Ή·ΐ‘ τκ[ΟΗ\J}σy n₯²Β ¬S=―ͺˆPHήΜsΙCI½@C―f&ΰΞ ψS’Q=φ7€Ή΅•Ÿή}7›vν:l؎ΖF~υUξyζ*‹‹)-,΄έ?₯ €Ψ¨Q(‰©έ»!¨qrάƒˆ’0½¬Œ7š›ΩιίN\NZϋϏ³Έψ_ΏΈΏ6γΫ ”™ε½ΒQΰƒΓ•—{7΄? Ϋovϋωε…€ΌŠαΊk^½NO d $~Κ 8‚ργ‰s ρ±c‰Atψp’C†*/G( ™Žχβ™žτx ω–AŒΕˆm}ΉkΆnεŽ… i NΏuΠ€-™δΉwήα±%KΨ²gύΛΚθWZjΫ&TYItΘ2ννdš›ƒ₯ ©ΛD(ΔIΌ°w―ίd" ˜„^*όΗΡ|ΈΡΈW-ˆΑ·†28Q«°Š‚ΠŒί.΅ύ8έuŁδ έ8ΰ βRλoφΣ›…GšΤ,―ϊŠ›ΐ<‰3ά|δ{6d‰I“ˆK¨¬ %³UΧ)ρ8‘ςr"uu„««!“!έΤt θ’―QΡ(ρQ£¬ϋ²sί>xρEZ:><Οw{;KW―fα₯μέΏŸΙ#G…²Ά9#2x0jQ©νΫυ*Γ|˜€μY…‘3ΚΛY°s']ξ!…ωΕΝVΛ?N`p;ΠΟœψz}=¬ιfŒŽQ„sΥ·žU½G(ͺΰdΫυX?[RŒ°wZ΅ސBQ\ςχyV|ί8>α(œ9“ψΈq¨EEωsεB &„kjP ΊέΚZ€Ώί§5xG[χμa{c#M­­€3 b±,…™ͺ’VV‚tοޝ%Α “½ŽήE!6j”eK Y½u+KW­βH‘ΖΦVώϊ쳴'“3d‰XΜ† Dλλιή±ƒL[[`,ΐό»‘ €nMc±Ώ—U”£w¦>κΰ?οωm0«’’s @΅ϊφ³{B’ΠςŠυmmΌR±ΒΕν7VqΕ4ς±DΩGQ-%U$%u nc’Ϊ‚ύά|α… δ«χ‚Ȑ!Δ'LΘ1σŸžίήwΧΟϭό'χ=<Ο½υΫχξ₯aΰ@ŠβqkϋHώto݊Φήn)Ύq ιoΩ‹ΠΜ¬†ΗA¨Ό΅ΈΨϊψ†ή\½šΝ»ws$Ιβ•+Y²r% P[UeΛdD† !ΣΤD:Ÿηβr&•”°©£ƒU~Ζہ%‡σ3ξ4ΰdtςDΟσΦΗγ\Ϊ0”ςhΔ]!…½^ί+…§Θ!‚œ0V}E“†|ΊΥώιE„Ή­½ΘΗΙ4€8@ΐšh€IήGρg’άΧ.ΎyΓ <ΊhHτθΊ:n½μ2Ζ54X―₯i~όqwΰΛΑς£9[–ρ`4Β55|²­Œyοώύόε‰'Έι‘‡hjm _i)CdθΐΤTVRQ\LI"aέ―Ž.φ67³w~VmΩΒΚΝ›I6‘’Έ˜+Ύπ.:ύt{¨ŸJΡϊΚ+$W¬Θ ΈΤ|jΙ’|³4tj±%EV£Oπq•„ͺriΓPFζ*€\²k+Χ’Ϋ$Υ–—wYυMΰQ(wbΝ Μ†Ά|Ώδξ+.=9εΑ½‰±e₯w€ύΒ55Ÿ|²υΊ–ΙpΩΝ7ση… σ΄ ¨¨ΰε›n’\ZΫή|SΐMΰJQr3n”_¦7ΰμσ7½xφlΒύsG5¦Σiφ΅΄ ( eFjNΈhEŽ‚‘Χ΄%“ΌΉz5Z΄ˆgή|“υΑZtΙ·>ωI~|α…φSwwΣϊϊλ$—`7‡ŒλέάΡΑg–.₯Ω?ΕΈΞab:œ!ΐ ΐl?£σΉ™\Z&εσM·ί%έηPz€U۞Šςpϋe~§‘ΘʞMϋ!ŽΒ= ΉΣ‚§ς»ƒzΖ”¦Ϋ-S„ θ€“P$WώΕ·ίζκ»ο¦Λ;ύdK‹΅wvrΪ€IΦυ…«ͺH]› ΣƒΩ€nΤ`fΫsΊ₯…HM BBΫ1B¦D,FA4jΟz0ˆB"‘ƒϋχητI“ψΟO|‚³§N₯€°ζΆ6’t@±Ρβ•+ΩΎo's γΊ…ͺκαž=dφοΟΙP–„ΓTE£Ό°w―_γP‘<φQ2³k /ΐ}ƒΚ*Ξ0@β˜ΛΩ–ή|mΊφΨ[x}B8F‚ ;G ΉΏ¦‘ž‚³«P‘’Ϊά|g_€p|~«ΏΙμλHχε %%%a–Ψ]έέάϊΟ²hyπŒζ]»˜qΜ1 4Ba χέ[·λ t‚„’1HΊ£ƒξ;Q‹‹Qβq{WcPMψΉ«ŽϋΨ―¬Œ“'L`ή)§0ΆΎž‚h” ;vτΪΌ³n»›š8iάΈ¬…ˆΤΤΠ½u«^K†°%™d…φxxε#Π»Χ @*y¨ΧC Έ¨ΆŽˆͺΈδζΒ^O—ήaχU_ή7\@dλΜ•^VBW€ΡQ,―‚ζκfsΩ½V}/MC„ΗC2„Θ lDΥΦΩΙUwήI£?—½MΪ“I:»Ί8cςdBΖ₯°Τ]d‚λΈΠ”»=ψΠ’I’7’Ϊ·‘(Ωt₯΄mz~ϋφΡ½kέΫ·Σ΅y3]›7“Ϊ±C'όΨΏ­³SΏο‘ˆχC3bΠ N›8‘3Ž?žt&Γ»λΦυκ~wύzΦοΨΑ§¦O·ƒ55tmΨΰN<βqT!˜ZZΚγ»v±ίΫ(ΕΠϋcξβ³  ΰ2ΰ7žŸTQψ―ϊΖΉΔύφξ>ΕΈb7ΰO± ηΘbvΕ—»%μ@dϋ,Τή%–7 Cη΅α3#Πζx¬ψn¬ΉrŒνΰ 4₯ψτΣ KhυͺΝ›™ϊυ―χκKϊηΟΞΜqγ¬wnΨ@λ’EΑ 8σbn`‘ „BϊΡ^«e2N뿝@šYl#RλΜB΅΅Djks FςήΊu|ύϊλYΎiS―>Κ§gΜΰOίωŽν΅ξ;hzδ‘όχ@žI,jlδβ·ίΞΗ!πΏθ)σ£Φ„>»ΟSN­¬βδΚJΠπ0"‹H‘¬-Š<ΛΉκcЁ™+»›‘(YΚp+ν§δ*΄ F‚;‘ΣpxρJD!9οη«ΣD4JbΒΫΏ<ρ/ΎσN―Ύ¨χΦ­γ‹§ŸN()»JKιήΎ]o•= %&χ3MΛ*}w·ώ“NλυχfsΣ:”IK§ΡΊ»I77Σ΅iοΏOjο^ύΪMPΣapϋ—•qќ9tvu±bσf:ΰ$²¬0Ίό¦eywͺ‘₯θήΆ-pjPΣ4jγq4ΘWpπ'`Ρj΅ž(t4Κ%uƒ 9ςύΩ•S±Qzγκ+Nώ?yIQ\iΥχχ%W^ψbŠ}€SΑγΊD8Ϋ!Ρϊz"ƒَωνod_άYv55‘ˆΕ˜1v¬u­α~ύθ\³ΖζEΠ3g1‘βXx‹’“εp§=<ͺΜώύtmΪDrΝ΄ξn½όΉ ΐqΑ)&0ΎžuΫΆ±=»―%o^Ν°šFΥΦZΧͺ¬$΅gi?PΠ1Œτ9„ομίΟV–θι†8κ ΐׁ―β3Βλ[υυTEcΉŠλ(€ρ‹ϋ­αœi7yŒ—›wTœζ“γ}ΰ T„„Δ"lυψ„2ΐ'#ϊ2’Δnj!TVf)ΖϋλΧσΫϋŒhfΩ† Μ™<™Κ’έ³ŠFAR»vω€·fi‘MωεVe#βZ₯R€vν’kˍ„**μx4 ΐi'²cί>>θAHJ§Y²r%§OœH…αiU%TVFηͺUώΝCΞΎ Ea@,Ζ³{φΠιj vK&0ψ5Pγ΅ΑιUUΜ(―4:yνά{ϊ΄^Uχγςσxr@Ίά9ΖI¬˜ήπΰsζχeγ#χ Έ 1<“4g\vŠ‚B%%z‡]4j—λζΟgιΚ•τ₯΅wvήΩΙά©S­Ο₯––½m›Ξ₯ηϊL‹œί†@U‰ LtθPβ£G7NοT3Ζώ3z4±#l53i:ΈWXα…―€R€ι\»₯ @7˜rΎ­ €3&Mbgc#οmΨψ>΅tt°xεJ.:㌬-K$Θ$“€‚&Iίύ xœmΙ$ο{{n 0ΐH ξ? €..φΪ :εΒΪZ’Šš[wo)΅μ2KJi.–BΙ©ΆC^mUΕΫν7•Q*Β1@TqV’ο£I ξA"„’{Vχ c5pI‘ω)™‚π€D‡ ³Ž•J§ωφ7z:μΆξΩΓΔ‘#\]­ίΟPHO nΩβK\κu­J,FtΨ0ŠgΟ&:x0αΚJΤΒB”h4 :~”HDoTͺͺ"\W§…aΓ‘ZG}πσ­ IDATZ*εΪ|γ遀Rtm܈–L0ΐΦT …˜;e ;φνγ`μΎμlj’-™dΦ„,M€Ά–δ²eϊυ ƒŒλYXΘΒέ»iυξ:lF―’=β @5π7 αzB!ψLυ…9q?BΚ©+ΗoN³°ετsˆ9₯UHρ/PΤ,y‡šKξ™%φP\™‡mΧ 8+²FΕb'Φ4{,ΤƒΆ` θ‘ͺŒ—Ί€G-bώsΟ‘>PΔθθμ€-™δΜ©S­Y5‘ ΅oŸή γ—κs(€Ž’™3‰"‘° eOΞH»…ϋυ#6r$α~ύt₯«+λα‚π!OMνΩCΧΦ­„«ͺτβ)IΜ<™ΞTŠEgιz{νZN3ΖΦ7*+£+ˆ!‘³(’,ζι={όφ˜ά ΄ιΰτΒW]XΔYΥΥ„]σϊΞw 8Rk9@Ql~DNΊOAA³yΩ‰ΎŠ­ή_’±U ·α#diΖrψpιφσQz/jm%‘ `ςdΫkΏΊχ^ήλeŽΫMVnήΜqΓ‡3ΒΊ„ͺ"b1Ί6oφŽΙ坦©―§pκT@ΊΞΟέ–LΦF[2I*•"η ?u¦ja!‘Aƒ€λ|κ)| ˜΄Ž:7n$TQZTd{oκ¨Qμkiαν€ž@:“aΫή½Μ=ώx’Ζ “Ϊ½;Ӑ#Υ;ͺ°·š›Ωμ &Πkjlwύ`Κ0τzW‰ΑWflqIv…Ε1ΆΫ§ΰΗt U™…Ηψ-‡ŠW=Ύ…ψ+Ά }Υχ*9υώŠ“@UŒΟ€ Œά¦y}J€Ύ,Ÿ0ψ˜1Φ1VmήΜy?ώ1λόP½Ϊ~ύX|λ­Δ M£ε•WθήΌΩ½@Z (œ93'ζήΣάΜ“K–πΤ₯¬Ψ΄‰ϋφΡΦΡAΖ βT…ͺR’H0¬¦†γGŽδδ ˜6f ±h4;šά1λOλμ€ύwθ\½:gf k?D&ŠBρœ9Ί7!)δώφv.ψωΟyωύχέ'EQΈε[ίβ³'f§umΪΔώ'žπη”Y…ΏW·΅qξ₯^"¦ Φ©ΐ| ΑλΝc‹K8½_ A²*άwδώ~%›Ο·) ξΙΝBNΧ_Κ`Λυ{~Žt£βΔ„λLlž‡ΘΛ ΰ΅Ϊγ2Ϟ­!Π4}ν5ξ}ϊιƒξΖνok#­iœrάqΦωΓΥΥ$ε>~Ο­%6l˜ν½G_{Ο^y%χ?<+6mbOs3Ι.™Œe2™ ©tšΦŽ6νΪΕ’εΛΉοΉηΈώxwνZT!(,(  C5g b”ζDΈΊš­[mή€Ÿt^MΈ›' ‡9¦Ύž'—. ΔZ€i[φμαΣ3fd½€xœΤξέdόR²Ξ©M@i8LK*ΕΫώ=cΡ+8p*π}―cF„ΰkC†WCvδ|”ΞΡήk£ύΖ΅ή^ΙΑ²t2KΥ-#ώ&_ ΈΜ°₯Š/S„ιqŠl$ΟΔ 0σEΟδ ±±c³uBμμδ‡ϊ[Q_ύ60kβDͺΛΛ³‘@8¬³yq†B$&MΚΖΨBπΤ₯|ιg?λ5H©i«·lα‘W^αΡE‹ΨΎw/Eρ8«ͺ²iT«ˆE¦­ΝrΑE sγFέ˜¬ΕBΠΏ΄”Ϊͺ*~ε•@Χ·£±‘‘΅΅3dˆuŸΘdάC&,@‚ͺH„φξ₯Εۈ BgΡ^$€ϊ<Ώq^|’5cŠ‹νŠ“³Bβχ ·ζGl©8bv9φG1β~CQsz‰&άYtd[ω…ύ¬HΚ)fΝ·Hx₯°Ό@6E!1eŠ>5ΧxύύuλΈϊ»8TΩέΝώφvζN›f­ΈjI‰ΎΊy(³ˆΕH˜^ƒγ_qϋν¬ΩΊυ \Ss[KV¬`α’%Ό΅j“FŒ D’ŠBΈΆΪͺσ<[±ΣiΊwξ$2hm8ιΘΪZZΪΫYΉhιͺUόηYgια) λ!IjCΙHTF"μθμδo/@5π€Œϊ*ιY™βόU„ΓL+/G Π„MpΪK‹qΖ ΑΠcs3} α™ΒΏsϊπέƒ@ۈo‘Ρrγ|-ΧεΧόRW.Hx΄Ύ%‘°½~Νέws¨εΙΕ‹yαν·³ˆ|8LlτhO:s«έΧx}γŽ¬Ω²ε _Χξ¦&~ωeNΌτRξα;1‰ΔG’θδ“mμQ^ΟM¦₯…ΆΧ^Λ’Χ~ωΌyΤυλ8-xΟ³Οf‰92XΖΓq}_4ˆr·ωY™mθG‚ŸΚΌ68Ή’’U |4ΓΨR(—œ‹D E5χCΓ_‰E.&κ\•5·~xΉWίA€a_ia!%‰Δ!ΏΖ;{ŒΟψΗΌη˜Νͺ"1}:HD$^™:ήz‹Tc£ν½3&MbJΎ•ܐΥ[·ςΤ›oΪΒ‘ΘΰΑ½ͺψJ]•>νΞ†ΞU ΰ?B/έ9¦€˜Šp4g φœ/\rqΡ…›…09sεΦlηΩ5Ω•·)ΒZΙΝΏζφi]_#β:ΨΓy ξΏμZΏ½f ΟΌρ‡S~xϋν4IqiΈͺŠhmnŸ—–JΩ>ΓΰκjF|XqɊ\όΛ_ζΔν±aÈ ξ½KίCλΛ/Ϋή*/)α ³gλ\ y€+•b‘Γ+‹λο}8ΞoJ"βώχ­ΠΠ½ΥΰJ―7 T•eεΎ+Έζg,εqν]‚t#…c[ {‹©j8=Ιawύ²lcŒd·ίε—VPΟ1\.BJQQσA0ή»ηΙ'JΩoOcξέq‡ν‹KLšdχ Ϊ»ΧΊήP(ΔwΞ;Οj0:Τ²vΫ6.όΩΟX㨋(˜8Qχ’ό¬@Ί©‰G5ΰ9'œΐΐŠŠ`FhΥ*ΆH}‘ΚJD,–?pρΟκߟςΘσ\Ή’¬ε9Π,ΐ•ψπϋO--cRY™{QŽͺκhΌpαύǍeׁόΛϋ‰άJB$d_ξΫ·³‰ly―{A&‹°ΩΚͺΈΥH‹ΞΟαΖ{ηΕ·§($¦NΝ橁5[Άπ«Ώύ­G¬?KVnΪΔ‰&0¨ͺΚ2Xja!έ›7gn£ˆ%R“νPQΑ΄±cygΝΪ;;Ιd2¨ͺJQφ8άΤΓ%©tš½ΝΝ̝6ΝβΗS‹ŠHνέK¦­ΝZŠ2Ι$αώύQbY Țͺ*.˜3‡ Γ†qάπαΜ™<™/ϝΛ%gŸΝ%gΕ…gžΙηgΟζΜ©S™qΜ1Œ¨­₯ cίώύ=&ξ}όΧ«VqΦ΄iČXZ( jq1]›6Ή·Ο$“„«ͺP%―ε˜ϊzn^°€LγΡ•J1ΊŽΙ#FXίu¦£#ΫLΥƒvaEŠB!^Ψ»Χ―Qh:αnζp€ €y^ǘZf¬ώΆ•ΩAψžΤέψ·’_3` ζ°N{\h€δςΚΝBΒ@Z#Γr²€ω=*VΩ―k±Χ|?‡X8c†­ieOSίψνoiO&ω°dσ͌:”‘uuΦC«ΔbtmΫ–mΖI₯ΠΪΫ‰ΤΦΪωπ…‘55?jGŽ€aΐϊ—•QZTDIa!ΕΕΤφλΗ1υυœ|챜9u*gM›FUi)KWμq³Σζ]»θθκβΤγŽΛΆ7Σ½{·5εΗΥη‰ΦΦZ=Ρ(‹W¬D; …8Ϋl¦2š–:7l€t:˜Ώ.‰ςp˜Z[Yι=T$ l@7~X1€sΠ›\ΚΚ]­šˆ _X©;3hK Κ]_šψ“i©ϋZgλΒΣς§i€UZ―<“Χ%Ηο 4ΉΉύ.χ$Z_*OΥ4ξ~β vΘ Οƒ έέόξΑΩ/††««υA£’toίNΛK/ωfζ™‚T’H0~θPΎΑΌ{η|֜ΠΉηΙ'yEJο EΡgϊu' AΧζΝvΰ«gŸθœο_o£eW ²ΤνAS‚Šbysϋυ#ζέ91tρ°‚€ΗαSˆ0ͺ°ͺXΜR\%ΦΘS"©oΓθu5MBμσεeύwΗV>:!< KCβ•ΫχA€…ΡC/€4fck+?ΏηŽy}ωrzα[H=ڊ™ΝϘڡ‹¦G!Ή|9™d"ό΄~Μ§R:/`*eI₯{ΤΏΌœΫΏϋ]Φ·,&ž ήΩΙoηΟ§MͺEWWλ%Υ~υ™LΞΐΤq V8α';Ω(ƒ¨‰„š,P]€δΜͺ¬€ΑAmζ)†N–@ŸΞσzσ΄~ύ¨‰Εs鼝‹ΧΦJ‹Y‡[ω―Ωmηή‚+δώz0'Ο ΄Ήϋ8Ω‡…λ ‘ˆ,‹¦,·W@Qœœ’a‘ΫŒ=V~€XCΡ!Clο_zΓ ΌΧŠC-‹–-γ³³fY9~%Gλμ$½o_ΞžΪ½›υλIοίOjΟR»v‘ΪΉ“ξ­[ι\·ŽδκΥ$Χ―§{Ϋ6ϋφYμCΒh%–εΨaΓΧΠΐ+οΏoσBόdγΝLΒ‘(h™Lvφ“GΐΔ:;‰ j a^]Ά,Π8²ι£G3ΦΘήUΥιɚš²ίwzŠΒ3ή|Εΐ»τ‚6¬7@ά0/eH,ž››w*ƒc ΝΙΛϋΈΡz₯½V@&@+­fx–7")₯³mTΣ44£x(»ς뻦s€=ΕΣΕΥδyςN‹œH5ΚφήΣK—Ϊ qŽijmεΚΫo·?γΖY+]Nϊ³»›Ν›ι\½šδͺU$W­’sύzΊwμ έάLf~R»wΣΉnmK–Πςβ‹΄Ύό²}6Ž9{βDξ»κ* α"ο$cΞ.’ƒ#ΜύݞΓƒ‘5 1qψπΐι@›’9Hzš8«_?*όΛƒ?mθζ!7εΐi^oIPΚNjxCl?\rφRΘβŽΣέ4 |LC€ΉΉξ.–Ys^ͺλφΉοyMΕšFΑqΗιycCZΪΫΉω‘‡i9moeα’E<ΉxqφϊU•‚γχΗCŒΏΒ'Χ½m-―ΎJϋ»οf ΄qΌ±C†πχύΘb-Κλ–οΫΗ½Ο=—}ZΒa’ ._―=δλήΉ3›ΩRUFΦΦΓ-jQ‘ { 4I6ͺΚ§Νςkw9ΝΠΝCnΎζυFHŽ).ΆΥΎη~μQ΄‰ynž…&ΛМJξΣΰœλΔ,ώϊόγ*έ?Ÿs˜y½‘S€Ž°£ό©%KzΝυ¨₯½³“ίγ4··[Ÿ;TU•σ„ΛlDaͺ>@@*ErΕ Ϊί|3 Κο8~<ίνA­ώνϊ—•(6b„‡—˜'³OΏR’αό%ψ›vν²·ϊ'~υ( ϋtuu>…ύΪα0yΊαΓ ήj"όtHψίEδQ@-Ϊξ'<Ί EΆΞίςœnΎf§.³’R/€k˜ΰδΡ3ŒŒλΝ#’4Ά΄π›{ο₯ϋ\jyε½χxό΅Χ¬²k‘ͺΔFŽΜ‚^†ΐ|ΐΟο;θ\»–Ά·ήΚ²μΗΊpΞŽ6,Π΅Ψ΄‰7$Χ\-,Τsύ2AγϋιήΎέώŒSZX˜χ\{[ZτCς8rc‘y]!E!ΠuΗcΤΜΟμΈυŒπA>¨ ΐιxTώ_Zζ―©ΎUPΒVΔccππΨΗLύY»Ή)½£“Πςy¬τyš­·qΙ^‡ΘiOφ"Θ2ΠdΌ—˜4 ΅°ΠφΠύόξ»:Λο‘–T:ΝχnΎΩφYγ£G£[¬LΎ†@“πΒΜs55‘’›k„ΰ’Ήs]ηξ¦&v75ιΧd’³ω/)f‹3z-€ͺ+ŸIΛυϋAͺCθMͺ2©€Υ{߈‘«‡ΔΔάވ) u^•J2& Ή―ΛΒAΦΡS7)γŠ)θΏ–!rWλ|­£²»―!,όΐλ<š‰|Ÿ56z4‘AƒlΫΌπφΫάυψγςάoπμi‰I“μχΒ%ΞvεDtσ$/ ΅{·Ν£œ0th t~_K »eγ‘(φ½ ()α¦Έ>&"Π³˜Χ;p€ΆO©¨ κν…Δ…ˆγ½Ά―‹PdδcsιΌςητM† £lXβ~wW`aqο»{ŽX]ΈsjΖ eγu§AΜ/ΤϊΙ!#W=€αξKο‡ 6j”ν³5·΅ργ;ξ Ω»ργˆ–ΞξnnyψaφIΔ!‘ςr’υυΩοΊ°£ojΟ«i ‹1,π¨i­­Ω―ΡΜδ΅@ƒQώŒA»meΓAAΏžσ˜’"jb1?οη­χƌ4~\ex"αZS¬uu„ε|»~xαBΆ‘y Ω°Ρ|Ή]Ko,°Ϋj iŽ"Ν½Ζίν¦Ηγz՜9ǐŸέuoΰ€Ο[/_Ξ‚—_Ά‘žFl€ ζ1VLΈŒχ Σϋχη ήθ Mσ’¦ΦΦlŠΞ 2χ5E±‘žt§RY1ΘΩ­W‘±χ6³ό³ΎϊΪPgόδZ9U₯:GqςϋQ| ‚8Tš–›SN ΞeιΠςΉςš %{Έ³»Ρ9AΏœ΄‘ylU%>n!ΉΣΈο™gΈεα‡9ΪEΣ4nΌ~vIuο‘R’uu6ΧrhΪ\άfy˜ͺγΠΊΊr\γς€MBΙξn―Ύb«ŽΠΆ5™€-`;v‘Όo*°–@‘λΩ&αHυ΅7@Eο4rέΆ<¦ύ—Θ8]*ƒsjΉΖ@ΛθμΞ²_ΉGΣΌαόΏΘsΓ(₯χδ‡ΧοK΅Ηˆ}γcΖ3sΟ†,]±‚+ώψG>*²vλV~ΰƒΆϋ9Rgξϊ卑α€GG₯O›=~›IΘZZj;wKGG &€β‚&‘‘2 ΎxA@žH0Θ? 8ŽΝ~AξX8Αλ͊H„’pΔγ³εΊαr@Ά Η‰” {!¬Ζf΅Θ=zVιύΐ;{JN?ΖϋΛΡΌ Δ ς’ Œ“ƒJθOϊΠϋόΆά0>Λ%†^%%ξ$Θτπς/&₯œbΙφϊΤ²€ΐ4D.#Ɲͺ¬΄{os3M­ωτΚƒAοmθ PΎmT!˜α_ϋp‚‘»ΕLuu „ .w-βιΝ—κXζΡΠόCAΞ@O„YΰηuΛ4w0Gˆε„ TYIαΔ‰ΆmΊΊ»Ήαώϋyωέwω(Κe7έdλοb£ΩΚρ|VCgjP-/ΟVΦ ΌήQΆλ%‰X,ϋ¬hš>KΠkΞ‘,jšΖ¦]»άŽ:d”£i(ΣΪj-]±β§ϋcS–ˆΗΠ¨ΛW%D­>7Δ‹ X'γΦυηgiι»ΧίΕ…΄­˜ΫhίxΖό&κ―S8y²΄½.w?ρ7=πUY΄lχ>υ”ν;-˜4)§ΨΖό4Ή‰n `¨’Βφ}₯2™ΐ΅U₯₯φ‰Pι΄~~gB$‚ZTd=Y]©Λ–fstfΪΪΌgCΈ„³Aτix"A‘wMB™‘»l>ιι¨*ύ= €ζ ΊZM ΄œ0ΑΛ\Ψζ€hYwή‰7hΦΘ1χUE>˜–Ρlϋη΄+›…GΈ\„ά"‘xΪ4BEEΆ/oΑ+―π½[nα£,™L†Ϋy„m‘…ZZJT"Ω|ˆΎ« αQ ©¦`Ρ²e˜„ γqΚ…?™φv –PDZΕ…α±½pV m˜H&CFΖ ‚ŒƒσΊ3W 1ΜθΚ'†˜μυΖ XŒ€θ– ;q†ζ«ΝZΰA3Fq /Ε-q(¦­±ΗŸίEΰ,mλΚfΟ&Tfw ή^½šo_w7ξ£&ƒ&σζͺU<ςβ‹VΪMAdΘEVŽAφnj*A΅€$ηžή'Νεσ“ bg-“!mbŽ”°e¨ŒΧφ47σΑ¦MyΟ‘( C$Κ±L2I& Ÿƒοxx“HΖ4PŠΒ±ώ™ΙΓ|ΚΣΔγξ=τδYΣνΦQΈαknlL1Bpbz­­hΖ$'­'x™›ΰΐIjγq« ΧCΏΛΔxvX„„ "±ƒo> <²ϋm —xς5hF½ΎΓ­Ο«κ‚ά\i‡–οHΒ+¬ΘMU M£dΪ4’ƒjrφύζuΧ–‰Ύ‡[&MšΔϋοΏΟέwίMyy9ίόζ71b/KLn[°@Οv˜^8Lά«ƒΟ#V IL΅'€φ45ρΧ§ž D𠇙=q’ν΅Ν›ν!€ͺ©©±υο/ϊΰƒ@ξ?ΐ€αΓ «ͺ•ΝJIޏΘ7$χ“hF"ωF‰Ο=0Ϊλ~Ρ(‘²©υΐ’e€}#²knηp/δΥ‹ΔΜo{Ώ‚~ε³f«Λ-ΒϊμWπD@ςhUU™4iwέuK—.₯ΌΌœ?όα >œίώχϋόίΝ7Ϋ€ΊHM !‰ηΞ–p„š¦‘˜:!ΥΧg2ξ{φΩΐεΣU₯₯œ&qj.Τߚ„«ͺ9ςψ·?ϊh s 1‡‘šŸ%“‘[2Ύ€_ΓΰΨ·_4J•??ΐθ1'y½Ρ?³ša\WP-ŸϋοS吿bPsœh½Ÿ‘ΚV χKT<0‹PˆςSO%βΡžLςυ_šη€±ΡG»τλ׏›oΎ™§Ÿ~šOϊΣόϊΧΏfΜ9όΧύϋ|fΰ-_ΏžΫ,°½V0a‚ϋ„fcυ΄ GωτϋλΧσΣŒIΏΰt{“\—œ3ιΑMJ3)½ΆfλVž蹍¨©‘ή|„ “L’r›&ΤΫL€CŠB!ϊω³#Ÿt ΰ―7j½VŽ™HΏ~ΆMΊR)~rηΜˆP-ΪΪΚI'Dii)‹-βG?ϊο½χ^ήύRι4yμ1―ΎRP@LJ™ΩΚΉ₯BŸH}½M)›Z[Ήπκ«IϊM’€¬¨ˆoζ3ΆηΗ9!υοOΨQΔsϋ£ͺ©*³Ž;ލ΄"w z$ŽηΉΎ ΐO‘ι­πͺ½λ­”™-§ž§H“¬pNώΕQ¦ IDAT-€­lΣ=Ξ׀وNπNΣ€λς±φ ˆl+«3T—P~IDϊχΛ9Β_}Ίw4ΊϊUUU\rΙ%άtΣM 8φφvΌRŸ ?eΚN<ρDΟ}§OŸΞζΝ›mwΛΧ―ηή'ŸΜM :Yn€†/₯°Π6!t’ +ΎΑΔ₯•²kλVζ„e³π,‘hΚ›‘X΅e —, TύWsξΜ™VμΠ.% κξχ΄/ 6σ#ρΥe?p,=Εa!ˆ{6^©™χxUΰΉΝ $y–s‘'dq!«0Ι?Βe₯τ›s‘ςr›)ιθμδσ?ψΑQ½ς' fΟžΝƒ>ΘΝ;ωӟώΔ…^ΘΉηž ΐόωσyφΩg)..ζ«_ύ*1iAˆF£Lš4‰ϋW_}•H$Β΄iΣ¬χΐl”ψφΥDB'ηt΄\[¬=α°­'Ώ«»»G’6ŒΟΙs Z1ginαŒ(V–'•NsσΟΫΥON=ξ8ͺJK­η€{ηN}ιtΊxœ°μΐc{c*πθ&* …ˆͺͺ{ωm CσxΑ«Gš‘ηΊh{5#8hΏs )„Ώv*ΏτΩ τŸ;%Ά]ϋήζfΎq”ΗόcƌαŽ;ξΰ™gžaΚ”)όώχΏη’‹.βsŸϋO>ω€uΌςJR©Ÿύμg™9s&Σ§OηΦ[oeι₯̝;—«―ΎšN8;οΌΣ:~s[?ϋΛ_l猍α^δΔZ„`ΗΎ}$Φ„C!.;οnœEΡ%€JΊŒ£οJ‡b;₯²€„ϋ~ςK¦i΄Ύφš d‹™“^όΗ‹/ςg£Š1ˆ|嬳hΒ‡ts3][Άd•Υ“kB;h†Α‡#ΠW—ύ<Ο>ίDH΅Fc»Ηδ~λΉπWvηΉΠPq(ΊΛy܊ƒ4Ώ R"ʏ?ž~³f‘ΖβΆΟמLς‡<Θ·―½–ύG‘gUUΧ^{-·έv΅FΪkˆΑT΄+ΐψkkEM₯ψέο~GSS•••όίύ_ΰ}Σ™ χ>ύ4kΆlΙ~%Ρ¨Uκk~Ώ™tšmΫl€“FŽδΟW\Aia!Š€…T•‘55/fήΌy΄ΆΆ²rεJΚΛΛωήχΎΗo~σ›œψΜ9–Η±mΫ6κλλικκ"‹ρτ72Ζ GΣH[GΗ;οθ«ΎA¦ΠΘ6hNBΗXoYΊ·m£ε•W •q!Γ“(™;Ur-_Ξάο~7πύ^SΓΒ_ώRη0½Β·ή²Πaz,™\Κ9M r*Ο΄ΖγΓα8ζk\τφΫ^{ΌΞ °>hΕƒN(€(~C ςƒ›žΝ=n7ίυ’φr£Ό&Ί†*ϊo%‘Ι'S}κ©’ςΫεwέΕΧωΛ£Jω‹‹‹)++Λ™hsΛ-·°nέ:†ΚΏϋΏΣΪΪΚ―~υ+:::¨――瑇βρΗηςΛ/gθΠ‘΄··³iΣ&>ψΰΦ]K&“aΚ”)άvΫm466ςӟώ€ώο¦Ζ ŠΕbœtIΌψβ‹,\Έ.Ύχ½οQSSC—Δ·%“όΠAŠkhΘίCχφν΄ΎφZ°lΉΟΝϊ>Bn€ŽΪΗΆ›rγ·ΎeSώLk+2½› `g[yƒΆ―8ΰ©Ο^(J5p>.T`ͺΚψ’RŠB‘μͺkσδήρ^ƒ°V}σnaέζFC¨ΒR~ΛƒPη2=°_‡ν·qfc[%’dΔHΝ9ƒ˜Α4#c©t†·VδŸό„Η^}5pjθΓ–9sζπυ――}νkΜ›73f°sηNΆΤY­­­¨ͺʜ9s8α„Έλ»xύυΧY·neee(ŠΒ]»xηwΈώϋΉυΦ[ΉφΪkΉι¦›ψϋίNcc#³fΝ’‘‘εΛ—σΰƒ2gΞ†+_w7?ωΟωΥ―~…‚_όβ\vΩe<φΨc9ΧΊnΫ6FΒ()  –—[υϊfλvf~Ί6l@I$Pβρœš ΝX3­­΄-]ͺ+€œ3kώ… 6z4BJ'ήόσΌΏ~} {ϋƒ .Θηήδ“€ε‰ΐRSˆγε…IΙHϋ+BπGoz?p°+h0ΡrF­”‡Γ|aP-•Ρ(Š‘ ΩβΕRJΕέ‘5Rh`*―"rCS!Q»1]~Ε~.Eβœ“ΟͺθnΏ¦ιηRU55TŽGΌuΦ(™ηFπΚΥάυψγGU¬?i$~σ›ί0eΚ Ε56lΰόσΟηΥW_ Ž 0aΒζϟΟ<#UQQA"‘ “ΙΠΦΦFSSSθ‰DΈγŽ;8όσyα…8ε”SψΚWΎΒ­·ήJWW©TŠΒΒB~πƒpΟ=χ°1ΧT?`Oίx#•‰JϋΫo“\»6Ϋ$]ƒZ^N¨ΛhB uuΡ½gο›n΄Σέ6ώ.9ηΫT ίήwWί}wήϋϋ‰iΣΈύWοω—~m‹Ω\w‘ΙXιhαβk.ŸIΎFΝ/{`†ζωΣi&Ύτiχ}Ά!ΐ›„…BΘPœ.@MσtαsPzG&Nσbδ‘κοEF#ζΈ¦ 4^&° υοΟΐY³¨™=‹˜Λ…έMM\yΫmόϋΟ~vΤ(QQwήy'K–,α”SNαα‡fΨ°aπ©O}Šξξn† ΒΥW_M‘α^oΪ΄‰{ξΉ‡ξξnΞ>ϋl«οή½lΪ΄‰-[ΆΠΨΨθϊ0vuuρό€qšΎ`Α/^L,γ™gžaΐ€\sΝ5y•τΙ½ΏwTήΕFF‰Ηm­ΧVaί>’Λ—ΣΎd m‹ΣΆx1νoΏ­+žbMΣt¦^IώmƌΌΧ8fπ`ϊς—mœιζfέΣΠrηMτ:ΧίΓmT!(φ.ŽςΤg/Γc¨ͺB.TΫYΕΎσ…Pr{ξ)Όΰΐ>ΧΟ“Ζ_ΛΉAY"=λ?c΅gœAΡΰ:[‡™)]Έ³/»Œ??φ(;}Ϊ[$™7oΝΝΝ\tΡE<χάsL˜0σΟ?Ÿ΅kΧΡΡΑ‚ ψΜg>C&“aΦ¬YLŸ>έΪχΦ[oe͚5$ .½τRΒ†lΚ¨>ΐv£1gΧ]ός—ΏdκΤ©œsΞ9μpk…υQΚž{Žχ€:5J2βΩmeŽΖΘ~™ νoΌaΥ)δpf2½ P“°9!ό˜<υΉΗ $„;p ΄\ZΌθΐΘΧλ#Θ£φγŠΗ Yίΐˆ/~ς1£Qyw*ŊωΒ•?δϋ·ά޽{u€)fΠNwuuqέuΧρΛ¬§žzЧŸ~Zwc?ρ λυ–– ΐ;ύτΣ9YnœρC–’Q+dΈι¦›¬Χ~ψa/^ά«Ο±iηNξYΈΠ"L@xΰΐ,?Ÿτ=ˊ%|«>ݍMrΕ }τ·ωl«*ο _ΰ[ηžKia!±H„H(D,aςΘ‘,ΈϊjfžŽ\«θΗ-;Οx:Ώ"‘N Bπ.Œς^Π=v |Ρνςp„ρ%%¨ΞtœŽsŽQYάޞ²CJΚϋ‰μ=0RwΨπ#Ξ—A=©YD(vΰ―pΠ Ξ<Κ Η’„BΖ}Λ¦φΦlΩΒ-=ΔεΏϋ›z[Ώ}˜₯‘‘Ω³gSWWΗΦ­[YΎ|9C‡eβΔ‰ 4ˆωσηη”πjšΖ¨Q£8ρΔyσΝ7yTbΉYΆl't£F"‘H°`ΑRyhΆn»ν6Ξ?|~ψaΊκ*Ο’αžΚ;kΦpΪδΙΤ¬<"B¨*]ۢ٘ƒ5?’—@Ί­¨Τ―ͺ*³Ž=–OžpǏΕ)&πνsΟε»ηΗΗ@Ξ-[¬ή?E~ŠξγχX°s'Ϋέ ˜ΰ~`ePΐ›ePθρ†η Νξv;ίΆj@M’ωγMš$+/gΠμΩԝv… ­ΉK{g'Χύο\όӟrΗ?™{„Š’(̜9“₯K—²jΥ*xΰ.\Θ]»8σΜ3ΉζškhiiဓNβΜ3ΟΜύRC!Œϊ{ο½7ηΎ}ϋί'“Ιπ™Ο|†I“&ε¬ΆͺͺRYYΙΕ_Μξέ»Ήψβ‹ωΧΏώΕWΎςΪb%dw*ΕiΑhm­d5Ηέvς¬¬BΊ7m’ΣeΨGΓ€œ3s&_:㠎9>HL΄ΥlI–Vc7s­!@ sα8nžτ|¨'!€ΠW_o:π}»wο¦ίfΪΖfŠj.-Δ¦‘Pͺ§N‘ώŸ tΨPgšn νUΞΌτR~χΐlurΆΉHii)§Ÿ~:_ϋΪΧw’,‰ΕbLœ8‘‡zˆ—^z‰ςάsΟρΔO°mΫ6ŠŠŠxόρΗœ—^z‰Υ«W³}ϋvvοήΝwάAKK —_~9φoΖή½{ϊg^΄l|δΫsP ρωεx=t‘- AΣh}ύu:œΌ‚9U¦YI~π-/Ό`λ(τUdΏŠ?·Ο ςΜΜφΘ„’πΘ'lΥcK—Ρ<9Bΐ>NΪμ©?ΐ°Ύ…55Œόάη©ϊν½y|塞‘dKςξ؎³Ψٝ…„$Π%‘μkΩ)…φΈ·—ήKi)τΎ₯΄Ώ.ΠΒm‘hΛ…²΅—”})!i =qΗNlΗNΌ―Ϊ₯ωύ1£ΡΜxf4Rœ@ΐ'/Η²–Ρhτœσœεs>gώάή\έΕγqΫΪΈξg?εΏξΎ›–φvG»ώΕ_ΜO<Αλ―ΏΞƒ>Θ]wέEŽ=γˆνψ'œp=τ~ψ!Λ–-γΗ?ώ1't§žz*gœq^x!Ψ<ΐk―½Fcc#UUUά|σ͜qΖόωΟζwΚzάqΗ±nέ:V\ΙuΧ]§&ύ"‘ΏϋέοθμμδΨcεškaΧ]όγ@EΆlΩΒo~σΎς•―pάqΗqχέwΟχ“O²_1.ς¨nομΩΞ^’ή')³~HΟσΟii‘G…ΕbrΛm<.in¦gεJλΧλ•ίΖΨΨ~²5`|n{£'d’θ—O™=0#/+&V₯bu!eEDͺ+*'"š€’8Qyw5 *@Τ‚@p₯βz1yœδ1dχ4l%σ¦dΪτΤq5–ͺi~^X½šG^xžAγA&·ψΩΟ~ΖqΗ§ΫMA.w]vΩe„ΊΟF<?ωΟΉρΖbΜ9477ΫωΎψΕ/ςψγγρxΈρΖ©ͺͺβ–[nappΥ«Wsκ©§βρxψαΘκΥ«;v,7ί|3σηΟ'ςϊλ―«n½ΫνζΡGεͺ«’©©‰yσζ‰D(//g``€΄Ή‘AψϊπΣ―]]cR4JoΘ\ϋ†ΪΎd LjΪΫ’Y­]’d ²’"9”€φξι‘| ―7{?έύ(³%Ψ¬©-‘°ο"4yνΧ7of•΅7v92Θ‘·Ϋ±’~j―‘eΛ‰σ/™|(+’ “±QR²π 0α„₯L=λLЧMv‘h”GžŽ―ό.ξ}ϊ)GΚ?nά8ώώχΏ³fΝ>ωΟS~€σΞ;wήy‡J πHJ4eεΚ•ttt——Ηi§fκφ&Ÿγρx˜?>χί?rζ™gςτΣOSTTΔwάΑoΌΑ_ώς,Xΐ­·ήŠΗγαμ³Ο¦ΆΆ–³Ξ:‹X,Ζ½χήK?γƍγΊλ# ΤΤDOOΟaSώ€‚<ϋφΫ|΄sg Qηρΰ3Η©±=Ά•$ˆΆ΄in&άΤD€©)Υέ—&i§=n’q†Iΐlœλˆ½7Ο$°ό–γ’DB²Βλg¨ωϊ‰ ζ@’d‹°:IŸ(Μ?ŽŠ£ηαRpέ)Ζ!‰vμΰβο}»ςκ 5_3)((ΰφΫo§ΉΉ™σΟO;U‰c=–—_~™ τb―¨¨ΰΜ3Οδή{οε΅Χ^γα‡ζάsΟeΓ† *tφ?ψEEζMšM 4''‡}ϋφρΰƒΠΠΠΐ~τ#ϊ“σο4ς³ŸύŒΣO?ΊΊ:JKKyι₯—Έλ»¨――ηwΏϋ_ώς—Ή›ŸNtwσΘ‹/ ‡U…Ξ?~oπΡnAoJJOpjhΜ’€Κ9™„(NίΟδ΅a{`IdZeR\†a’Πνζ¨Β"ά’`€Ν’ιH•α0„ ϊ4Ψ~QP; U£ "SqiΛ~šΧL^Ύ‚\…τ!ΙυΡήέÏ}„Ÿώαtφυ9η/^Ό˜?ωΟΊώr'ήŠ+¨««cχξέ—~ΏŸϋ·γž{ξαζ›ofΡ’E̘1ƒ… rΕWΰυzyκ©§8όσ)//'σφΫoλŽ‘μΓΟΟΟηώϋοgΓ† |πΑ\zι₯LŸ>X,ΖoΌazvοήΝΛ/ΏLQQ ,ΐγρπ―ύ‹?ώρl³b΄=Μ²eχnV,^L•B».Έ\n7‘ΦVσ!1Ι!šƒγH2;βΈ΄66B˜X)§]΅"έ{KK ζ‰Ι8π8°Λ©ΒV@\²CρHΓΎνθoωο„½ε Q‘4'y•Άxœ•o“ nΉ…ηήy'σέ&K @MM O=υΧ]w]F―»όςΛY»v-Ώύνo)--ε²Λ.cήΌy,\ΈS ±7άp'Nδ駟FE.»μ2¦OŸ«όκWΏbΒ„ ΌυΦ[όιOd`Pr8ηυΧ_―–Μ€‘‘λ―Ώž₯K—rξΉη²ώ8Ύμ|°fq{&LΐSQa™Eǐc§[…Ά³#ΜgOHŽ’€ K’$°nR +:ν8°|AL’ˆ₯cό±‹½Θω€‘Σ^H³γ‚@K{;=ύY-²žžžŒΨp΄RRRΒo~σΎϋέο¦}n~~>}τO>ω$“'OζΖodΦ¬Y<σΜ3μΨ±ƒ 6pρΕσΦ[oαrΉΈρΖωΕ/~AOO³gΟζ’‹.ΰ‚ . ‘‘+―Ό’W^y…sΞ9G£ΏςΚ+¬Z΅Šάά\~όγ۞S0dνΪ΅YώC-›κλωwόύ‹[³νXΛΑ οΟΟ€~on˜+2dŽIƒΦΐrC·σ¬ @"m₯φb8θΣ7Ϋώ¨±dH€¬KΉπδ“™ύdštk³6OΣμρxΈσΞ;ωΑ~`š8LΚΰΰ - ;Ν¦M›xτΡGMQt7έt‰D‚Ε‹γρxT%ΎώϊλΩ΄i+W$ rΓ 7pΦYg6 ΐΈώϋ ƒ\zι₯,uΠμςI–_ί±{ί>UD―1Η€_Κ'8‰ΛG žaPJΗ$dψ\qI’Ο‰ΚΤˆ(?Γ•$!•ŽΜ~[²―HΚtΝzxgnA!ycΖΰ/+CΜΝUAI― iυj70‘b,_<ωd\Y–D£QΫ8ώύχίgk’ξΙFξΈγξ»ο>΅γΞLn½υVΒα0Λ–-γK_ϊ’ιbά΅k;€Κœ9sxμ±ΗΨ΄iΥΥΥΜ™3‡ο~χ»,[ΆL‡Γ7Κ›oΎΙ»οΎ ΐƒ>Hžύ<ωO΄tχχσ«gžΡa7r&OΦ±ϊΨΖηΙ€œ•»ξdΧM§œΙC)₯?ΙBωm§;υ<4―Iλσ²Τg»*@Μ< fΘΦKN\| σ Βƒ΅~ |₯₯TŸpG]vs―ΈœΉ—_ΖΜsΞf̌:·/ΤέMοξΝυψ·³ΟaZ^@,³e΄δŠ+ ΎΎ>ν±½φZ^zι%ΖiΨb΅²uλVξΉη@fΠ1Λξ ‚@RέΨ»w/===<πΐ$ άn7+WTΙ=¬€――OEΦΦΦšΞ΄Γ(/YΓZMrRpΉdpΠH|d~ ™Ό6‹ΓΈη ™›£>ι·/ΟZκsΖ@L’›ΈΒ¦\’UPoβh\{_i)3Ξ8ƒq Α­!lΘ―¬dκΎΐΨyσΤ‹(%΄oήD,œςpDQδ‡Χύ{Ζ1Ϋ†S¦LaλΦ­œώω4;(+.[ΆŒW^yEeΘ1ΚC=Dss3³fΝβ«_ύκ°Η/Ίθ"¦NΚ;οΌ£zΟ=χœΪig·σkεψΥΥΥ*wί‘,έύύόφοΧ υŒΗ„Σ!۝U°Λ!€SήδS‰ψΗ`H$ΝA‚zΠδυmφ€΄Œ=€^LDA„l ν]}Α4I¨‘6rΉ˜Ί|9Ύ²1ß‘δΚηEfΗΪ€ΎΖ=ΊηSSΓ%+VdœIm10Ηj₯΄΄”±cΗ²}ϋv–.]κ(˜?>/Ύψ"Η*p΄’ξΞ?ώρ)Sš]ΚΛΛΉε–[ψΓώ@cc#·ήz«Ϊlsΰΐ{μ1"‘'Ÿ|2§Ÿ~zΪsƒŽ Φ‘"Ο­^Ν;IΜδHοY³ΐfN€n‡Oγz›Ž•·"πtbt¬8+m~™z-φΐRŸν @Ÿ•~‡βρhG2³Piμ‚MqςI'Q8aΌξξ=ϋχŽDT¦Ÿάβ"Ζ̜©»°ϋΧ―'\I!ωΞ—ΏLžM2ΞL:::,wɜœ•;Ώ₯₯…SN9…Χ^{-ν1kjjTθ°ΡΰόνocΫΆmκ8­χgέΊuάuΧ]¬\Ή’K/½”χ’-§ŠόιObΣ¦MδζζrΓ 7Ρq}ΆrΛ…Τ5੨ gόψ΄ρΏ­ ₯σ2Λh&§3,’ΝΤ*Α&4Hνs6ފ({ms0h Z—Ν>ώE,F Mτy©φϋ ―gZ0PςΉ2— 4ŒΌbφ,&°T0κθιε;χέǞΆ6N\0_}¬ ²’žϊzβα0‚ P2 …UUO#ΗγΖ—›Λκ Ζu•––rα…RXXhκ!¬[·NmΎβΥW_₯¦¦†YIΦ ΙΟΟηΤSO%‹©|| —‹‹‹9ωδ“™;w.ηŸ> \}υΥ<πΐ4šTΖb1φμΩΓUW]Euu΅ΪόY’ήΑAAΰ€ωσUepKΖΓ§‚ IDATΈ‘!57ο`8χ1©UYy&UAcx„L&)ΘΑ΄€ ΐvλπn3&}v€eƒw_,FΒ*α—f8hΚΠ?)'?ŸI η»Vξyβ Ά54πΔk―²Y“|D‘jsMϋƍ»ΊΥc‹’Θ9'œΘ¬I“χtvvš>ζv»‡Ε󝝝\rΙ%¬4ŒΈ2“’’"ξΌσNnΏύvEχƒ>HSSn·›W_}•eΛ–ρζ›ošΒw΅qύsΟ=Gnn.ίψΖ7>“^ΐΓ/ΌΐF͚srπ/X0|ΧwBΘ‘yŽ`ΦΌ“F‰uEΪΖ$“@²Γdi΄φΩ‡–Ίl4Ν>of$|‚ΘQ……)b£ ΨΑπ Ώς 'RσJ@ ψπn¦ΐ,JύρΈeC1β7γ‘ γΓςΚΛ)6UΜ*!ŽΌχi½ΧΨΪΚ“―Ώ»―ϊΔerςG©"„zzιΪΎ]χ¦—~α ŽΑAƒƒƒΆ'N΄άm―Ώώzn»ν6۝;)ίϊΦ·xβ‰'Τ?ό0kΦ¬‘¬¬ŒkΉF-ΩΙ–-[8ν΄ΣΨ²e ŸUωΗϋολ‚n7ή™3-wUΙJΑ,Z{ΥΏΣττ[ƒ“­…4aFVb8ΑXŒkPLΡe25€©™ŒEεD ‰Ϊqv"Y/>œό|ݎϊ=%;ϋ$ρ8ΟΌρν¦^—7—ρj†]. vnΫF,Τevύυ―­\ °²Β₯₯₯”””Xzcƌq€ΌηŸ>Ο>ϋ,Σ§O'‘HπύΊŠcΜn£2|3 xθΉηΤ$Α<εεΈ•Ζ![χέ¨ΰv“z2Δζ'›€2ζΜβρ½Α έ†œPt9c°‹Ϊa(‘0Ε˜)“ή0‰«ss?_ίNϋμ?WΡhp©oiαΕΥ«uξ|ιΜΌ₯%j­0ΤΥEO}=šΓΜ™2…kΟ;ΟΡχΠΦΦf ϋ;v¬)ΐ•W^Iww77ί|³c2;Ο?ωO¦M›Ζš5kxZρz’”\£βΐ ψΰ^[·.₯|Ή“&₯cN€ 9€Μ6dσ)Υ‚©‡M8‘ιγ{;tnDΡεŒ @ 6Δ ‘ˆύ Io SwkΉό$Κjfθΐ>ύCCΌ²f q‹ϋΠ³Ο­q³=ώ<ΚζΜQ9ώ% νύχIDτnΡ΅ηΗx±€™466Z_”––RͺPTηεε±lΩ2Φ―_Ογ?n逓 &°uλV/^Μƒ>HWWK–,αŠ+Υn‡ςύ‡Φ)³»¬L^Z‚Žtn·•Ϊ•Ν`ΎI°Y.Θ.!ιΤ0<―%²ξΠ•uΈ%`ΩΫ *‰7¦^ °Βκτ&.\€ϋ{γΞ:Ά€ι«†Γάσ—Ώθή°|ξΡδ₯Jw‰h”Φ΅ktηSZTΔ]rIΪΕ΄kΧ.KŠλάά\ͺ««9σΜ3yμ±Ηxηwt ΊΩŠΧλευΧ_§¦¦†{ο½—ΪΪZʍ€£b)­¬Σδ~\Γ:­(ΐ΄mθ¦Ψ};…΅ΒXpHN•=ƒΌAC `gl{άΣK”ΛώpΘ „$}š|x ΉιΗWZ‚·$ΥȍΕxoσ&’h§^\½štΙ>˜΄\όλΪQː‘ΗΕβc9¦ffΪΐŽϊκ?ψO=υ—80&O<ρ„£ 9>Ÿ»ξΊ‹χή{3Ο<“ϋξ»oT³3ν{φX&σ€ƒ%Αy―Ώ”­‚;ΙŽŒΗΩo_|ν` @­₯Y ‡Σπh}7 ς™3uΓaήΩ°ΑρυψyD1ςωΛΛ)?zή=ϊηΫ:r…ΒBΎzφYΆέ‚mmmτυυY>>qβDS QΆlΩΒ1ΗΓ•W^Ι 'œΆ‰θ…^`ώόωΌυΦ[μ1αͺ{1Ϊ,€(δνŒ€š°³ρœΔ‚ΆΖŸ!ΘΡ@PƒtD"V,@iuΨ‰xΥκP"Α@ΊI0fAšR`©‘ΦήΦΩ™Ρ,Ύ=mmόαΕuyˆ± βΦLΔ υφΉe³n*ιYKO`Ρ¬Ωφ±O–μ@±XŒΪΪZΌςJζΝ›§’8逓†ΑzΓα0kΧεΔOδΌσΞϋTaφ·Τh&όhGpHΛ-Ψ$3ύ$qVΣ€m\yGTΰFΣe―‡―Œh²{°ahH?ͺΙ$Ž’Αz‹ŠuώΑ›λ?Θθ OHΟΏϋ.{5|nŸ±Ϊ˜ΚŽžKσ[«Tχ’ύ£(©™Aކ₯ηκ3Οδ…Υ«©7Ρ:i‘$‰Ÿόηότ§?e``ΐq’©³³“·ήzλcέ-§M˜@ue%γΚΚ¨ͺ¨`μ˜1εεQΰχS όφεζβχzρζδ€”+έ€όH$E"²Β ŠD θξο§³·—Žžš;:hlmeO[ϋ³Ε~Ζη>Η5gŸ:…D‚HS“Ί£JVЍύΝv`+%ΆΈΙ9€–Γ@νʌιr&ΌQIb“έΌYwΪ¬Ύgφ@ §;‘47ΗZχ•ί†?ρx½©Qή‚D,ž Λ&ρf'‘H„=ς(,Έ Q2c][·8p@ž%Σϊή{LΦ Ν,ΘΛγϊ‹.βΫχή›±ΨΈqγ³Οž<™…3grΒΌy5u*eΕΕψ•ήγv“c•ξXlšeDQΔουβχz)5P£%Ÿ™P<…p4J$₯«―ΖΆ6ΆμήΝΊνΫyΗ r9ο„ψωώ'ΙΌ$λμ$šκ’iνMWϊ6…ΨΒ+P= ³Κ@Ί¦€t i0c—=ΑΛΚ‘0–~yL’h Μ €Ρ²ι¦«JΰAφ9pΣe΅u+o­_ϊΕΗͺoU}Κ jŸxII μΩΛ@SΥ©ξΐ‹³dξ\ώe χ¨――'λΊφŒό'MωEQΔγrQ]YΙ’£ŽβόΟžcgΟ¦Ψ†›0k₯ΟΆͺ›γ*IΈ\.|.>9YQRΒ¬I“8sΙ’”‘έ΅‹—ΧελΧSίB$Εγv³xζL=χ\Ξ9ώxύ{Δγ„wο†XΜχ―Α¨˜*­ULnQ)4S¬3E:†*ž·7 Σ>ώo ήFξ ΤI\’hYP\,μ”ΜZυ?‚šqtΛ"?ψ‘S?yμ1–Μ=š<ŸŒ,Μ),€bαB:6lP@λκχ¨Ή|"‚θB’ ίηγkηœΓG΅΅D4΅$=ΨD‹&’Ω³g"”ήουRUQΑ¬I“8nΞΞX²Dn|2mζfjŸΛΌ ±‰x”™R,&'Έ’.u,†€$Ψν Κ$_ž2#29ΐ·AΑεBΜΙ‘o;Ι΄K fΜ`ΑŒάzυΥD’Qϊ)ΚΟΧΕϋZΧ?Όk‘t%Tε:8)ύ9 λtΗΚb——²0¨oΫ‡LocΑœ©kΜ @G$L0Η—–Ž)‚8|+PώNŒpβ@O?ίΌμ2ΥΪT̟OCQ₯  cΓΖ.Z¬σN^΄ˆΧ5xr€;vX€κκjςςςTͺΓ-GM™Β™K–°pΦ,ζMŸNΝ1ΫεΜQbpψΠ‰δO(D"HDVτX )•=‘P ¨-—ΎΡΐˆ"‚Η##ς\.DεoΑγAτzύ~\ΕňyyˆΖK“σΟρx(Σ2k?O8LpγFΒ»w§Oόi0ς0›¨©X–¬β|ΙY㜩†Ϋ'Χ`1 S6Z=Ψ‰Π3A‰ΑtnžzςΓ=γΗφkϊ²χN%^xχ]Ξ>α¦O¬’GJηζ0vΡ"š•€› τxκ4r5Ψύ;Ώρa`λΦ­œzκ©–ξvUU΅΅΅‡Mισ}>.8ι$>γ ¦M˜@IAeˆ’[¬‰ΡHτυΩΏ)•<ωc±^²r­ξO$H$QjΒ u'NzŠQπ”•αVΊωcnΒ"ΆŽ46άΆMΰij¨Μvl …u²ΞΜXΘ–ιΧΙc†ημ …ΨiΏωlΔfΖg&d4QPm|`(§=f‚Ο—’φ†Η}Ζ’_<Υ ΛσϊΘυx§₯‘ΦΞNž]υOΎύ₯+πΈά @α€IδO ΅I’ˆ ΠSWGεqΗͺ^I‘ίΟ―»Žόώχκ±a-ZtΘ @ŽΗΓδΚJώγ‚ ΈxΕ Š­˜’ 1‘@J$ˆχτλκ"ά,ΧΔ‡yΑ”ΔI<pΖ‚“O=ηdΨ‹‰wuA]ΰ*,”G€UVΚΨ~νŠΕˆ47ͺ―'ΡΧ7,i—ΦSΙΖ%·«ί'+VG¦­Αv9ΰ{ Zi€™€&+r;βΌ’"\‚qlxςoIŽ„TΜ ‡t]A;f M0σιδ/½ΘOώ<5UUHˆ99”}΄|lEQ:7o¦€Fi#Vδ+xϊ7ΨππΩm΄΅΅RΈξΈ1c8¦¦†λΞ=—‹₯βV£‡₯ΈΏρώ~β}}ΔΪΫ‰vvΚρ»β†§KΘ™&k­ςF’ΧΞn‡•,Ž›Όοο'ήίOhϋφτ„šγIvŠͺ!λ4=7[Ζkk£ dΣΛο„ͺΜBώaA[gΠΧτ‰c‡οΧ«Έ¦gΫ ¨LΑN%Πέ£o’δrΥH‰v'‰ΒΙ“)¨ͺJ-ŽD‚}οΎ;l·ύ―‹/&GI05™ΰ"‘wάq555:rΟ‘’ ee|χΚ+yόφΫyςŽ;dεΧ.΄€rΕbD`πƒ\³†Α5knή,—Ώ΄LIδ™ρΗμ~γ’4{ž2KZeˆΗm•ΒRωӍω6ώνΤ…7x$‚U†ίΚσ±xφίͺϋώώ;‹*Αž‘!v[—G%EW{G¬ΆJ* Δbς`AΠΗφ’φKΣ·GCAβwΙά£GL‘6ΦΥρΜ›oκLV•aN@πΐ~zjwκNuωΒ…,™+7ƒAzzzyΒΞ /ΌΐτιΣωαΘΰΰΰΑu˜™Θυ_ό"Έχ^nώ—X4s¦ιργCCΆl‘ΥW\³†HS±ξξ”+­{if$ΖΓ:Ε7‹υΣ)²„–ξ͌0Zm9/]!‹¦ΣΠBCύe[ϋΟ ΆwrNοtw›2ri’φ«.wΛε9`0ΝΦmμοcΊRo–L0†—ϋZ[©˜Y£>cΝλlδ—O=ΕKާ(_Ž›]99Œ[Ί”ύkΧͺ €ύZKΡΤ©ˆ9Υ ψ .`}m-‘Pˆ-[ΆΠΡΡΑ}χέΗ;YŒw"Η̘Αίω3««SρΉΖ–’Qβ]]„κλ‰%C€dΌ)I ŠJΟEδ‘h5ζ΄3πμšity»]ί¬ͺ`φ·!Ή(9p³Mϋτ&θŒy!Ρ:˜δΚI$xΏ·ΧŽhPΡUGβΚ໏!C‚Mη\υD£WR‚[Τ0k+qΎŽ-X¨¨©Q.¦ŒE_·mΚ{°ŽF G",›Ώ@)DψΚΚhj" Κ —3αωU՘Ίͺ’‚]ΝΝlkhΰ½χήγ±Η³š­Μ¬ζ–+―δ—ίό&eEEŒ„Ό@β½½D›› |ταϊz6¨/§3—ƒ1ν EΊΦT΅{Ξ‚όΒ6™θ€hΣ2θΤCΡ>/ΙφkՌ“σoωΜ(Δν*&Z―' φί=57ΫΝ|ψγ‘0έΐ•¦ήPμv3ΑλUƒ0άHIε— @¨―ŸκγŽK=GιΰƒΫGLΙZ::XzτΡ”Λη$ŠΈ}^ϊχ¨η `βDά~ŸͺL'̟Ογ―ΎJkrl€’€„.Ή”\σ5NVΘ?“J/R @`Σ&BΫ·miARΖ~'w »φΦΤ΅–Ff`¦!ήM*½ Iͺe”ΘJ‚‰„μΖΛc§l6»­€Ή>Ž]Ν5 ΐ*ΑR8UώLC4£vwuρ¬}’ό›@ύ‘2υΐχ­r‘xœ…Š’‘p' ϊSΖ@PI"Ώ¬ŒΌ1cQΙΝρπξ† ΒαY΄Αp˜X<ΞI ΰrΙ§[\Lπΐ~’ς¬δ"ς'NT½”\‡κ±•ΌΌvνˆ*Ρ₯+Vpί·ΎΕYΗ/‘(?_· A XWΗΰ{οΙε°h4…¬3ΩΕmN¨J†1,&ΠlS#’ΩΩν‚wΈΈm©· Ύ΄‹“ ΗMΨπλ›φ›ζΌ%‡\‚fπ‡ ]˜S£ea”Βρ857Sg]Wg²]Y¬α±ΐ±¦™ύxœιώ<ςGr§I}:@ˆ 0nξ\υ%……μΨ»‡†}ϋFLιvμΩΓ©ΗG…όD·?ώΖFPZZΓ]N™ŠΫοS•ebΕX6Τν€Ei+>™4v,ϊώχωσΟ§€°P?2 ˆυτΠ·j‘¦&Yι4ΧΘj!92# N­M*»ςω‡eǍ»₯ƒέΜ ›₯ς'½ »0 “]ίΰ˜Ίώιv‹Η»J‚#ιτά Ο;‰π“ϊz»ψ!ΰεCmφ_·ͺ?x].¦ωσTΒPAγ€6’ EγΗα+*DQ€€ €UλΧ;βt*›wΧsωNUs9E…„»» χτ¨αIπΐJζΜQΟΥγvS^\ΜΛkΧfέΏ^=v,Χ_t|ο{L¬¨HyC :2ΦΣC`Ϋ6Χ―G G† ŽΡγAp»e\½Iφ7›„Ÿ.g έυ3H ιΈς4₯1ɎwίΙo6€Σ¬cζi@M–eI«9€C9μ8vmΏNc“υφ­­¬²‡^GΠ‘0Cΐy@…ιyK5ωωδΊ\²τ‰@ΩL€…2,Nٌκ‚šP^Nσμt0Λ©tφφβΛυ²P™#ωUUtoΫ†#ρP—ΫƒΏr¬ͺ&Λ+ΨΉw―)g€ψ½^=χ\~tΝ5œΉδx Z6ЉH„‘M›ΪΌ™h{;‚(*±uΚHζVUα›3οŒδTW“S]θυολK•ΔL”κPUοZΪσR’oν”i€GNg$³Aο!Y a2J·“Lμo‘€Œ'άZ[K―υ¦Έψ³Μhσ%Ωμώιά@Ρuu΄XΣwwTΈ²Τ₯dL€)r§'εθ‚"ό.QΉΥ² Y2tuQBAˆβW‰'?OΝ^,_x Ώϊ*A“κDyq1_>ύtώπώ³&MJ₯<•ΟλοgpΫ6zή}—ΔP UQβ{ο€Ι-]ŠwκT9ΦΧΔιΙ`{Oy>ŸϊžξRΒ--HΙJ&δfψώL<4M,Z–'Η»šΣ,&Ri•λ*YΤζm1]βNIΜ’nχ·59υ^LΞ{c_ΏonΆKώ­ξ’‡ΛDrΕ ;F\’πΉ\LV:ΧDΑ`„€Λ+€ΐA@_K3γηΟ—Ηk+†|Ξ”©΄vv°{„Ζ_‡"‚‘_8φXDAT½€Pw‘^>#Ε’N™’0i&πΌΖ#+O;^s ―X[iΝU €” Σ&ϊ?ϊˆpkλ0`”w$Š/Ζ_SƒθΝΥUJ’·7ξΪΕoŸ{Ž{žzŠωΣ§S9fŒό˜Λ…˜—'sίγ3ĜM†<v)ΣP"“bC²/ι8žι‡ ÏSθ­‰‘Q~|RΊŠi`Αiδα¦&>²¦Λ‹*Ω·³9Άλ ti p`: σ@$Μ±EEΈAU4]%="PβΡΑžΚjjTCšγρpܜ9Τ·΄Π”%WΏQv΅΄pμœ9TUŒU Hnq1}uuκ—ννΕ?n<ž‚5\˜P^N]s3»χνγssŽβ‘οήΒΕΛ—3.©”Ε 67ΣρΖ„χνC Gt—ίGΙ²eδϞ« @QZ}vώ­ Έυ·Ώε—ύ+«6l ­«‹m\­α4tλιQϋΰMcY-d8’KνS”#eυΣ•Χ²UdΒΟΰϊ;Nϊ₯{oΝΟE˜κβΨΠΘή@€χμ‘Η:ω7|)ΣδίH€r{°)& &Ix].&ωό*ο_jM&wAi˜1φτΰΙΝ₯pά8υΒyss9σψγ©oi±œœ©¬Ω²…―sͺ+nΏŸX @¨£S-ΣZ[)=ϊhΥχΈ\Lͺ¬δψΉGσΎšΚ²1Έ•jGςKLƒt^M¦Mj’.ωΩE‡ΌšŒ9εάEE*5VςΊΔ >¬«γ«?ύ)χώυ―μή·OrtφυQYZΚόiΣΤyΚΚ9™a -/šPxI†ϋu?#Upκ 8I>jw~ ΉGΖΚoΈ>¦ ‡€‹ϋςύ›”_ιθ`₯ύΖχ(Θ?…Ψ|ΫκΑξH”……x\.}.ΐ4)˜ϊ=ΠڊΏt ώRUN_ς9"Ρ(;φμ1• …BΔq–̝«ρό‰ιΩ±CUάD4Š όxu‘--eΦ€IˆΊ@lpˆΪtΌ΅ŠX_Ÿυ‚ˍ―ΊšNΐ?mš.άkχξ垧žβ¦ϋο§Ν’ΦO$hνθΰμγ'/Ι δρ H±φφ‡ώfœ­6CςΩ‘kΪ(¨cŽ<-"/“lΏΆμfρΈ ακ·νψ³Kd:υj,XZ_O»ύθ― ώΛτ#7Ν3u$ ΏΛE΅Ο§WΑ έ/Sxχ55‘_QŽ―ΈXο$πΉΉs™QUŞΆ6:³€Oʎ={8eρbΖΙη" δ––Π_Ώ;Υ'ΠΥEΑΤ©Έ΄YyΥ­“oυmέJχϊυφξMαε“ήΛΈq”w…sΒε󑍅twq³Οrϋ£ςΞ¦MiΟω@O•₯₯;k–z]ωωDZ[εž;ΟƒQt3”^š€™mBΜˆ“ŽΙΧΨO ˜υλ§#φLσ~B’τ4iδ ΗΧU ¦μgΑ5 IμκβφΉ―Ώ8˜―Φ5ϋΐFδΣ,q8‘`v~>9j‚LPϋD5•¬¨‹3ˆΕθάUGn^>cΗ© >eά8Ύpμq”°iΧ¬½H,F ΐiΙ†$άyyΛ‚‚lŒBΰ7·Χ«τ[χΡφϊλφμ% j0eΛ–QtΜrŠŠt8€δ1ž|γ nΌο>^Y·.-½V6οήΝ_ψ‚Z<€D‚˜•«hUpΒδd$V&%6ε“œ–Φ4ΰ"Α.§ΰΠƒ1ϋ₯mΙ6SΠƒ*:|ξ7·m£Ϋž"ο‹JEξc5A``Κζ1‹QαΝ₯2Χ«ίν΅ύΪP@‹”$zέn ΖU"h’‰Ύά\ΤΤpαηO&‹³³©)+fα}νν,˜1ƒͺбj·  ξέ›JNτmίN¬Ώpg'Αύϋι^ΏžΎΝ["aν(J²2ζMIΥGU IDAT™Lει§“3¦t˜»/I²ηρ΅;οδΡ—^’Χ~°ƒ©ΒaƒAΞ8ξ8υ>Ϙ1„χξE2sΝxιŒJŸm+Sήΐl”ί`œ$c²/“φή4ή‡‘5؏ΞΕwRΑ°H,>ΫΦΖ_ν»ώžΗρη‘61%y©Ωρ$dΖ y…xA“ 0Ο¨'οG ―©‰`w7ΎRrό~΅ΏdΘν‰σηsΕ©§RYZͺΞ«‹'Δ’sΪl$γΛΝεψ£ηβrΙ³Ý] μέ; ―νι%ΈαˆzP“ β?޲%K(œ;WζΔΧe€ΫZyθΉηΈαή_tƒΡΖ]»8uρbΖ—•₯ΎΜ’""IT£vχΟΆ58›lΎq§Χ–υBρΥΏfΣ |Ώγ 9G5ω—IBΣpml•ί&1؍ςέΪZϊ¬3δi]ΝH;RY#rΙVOΈΈrΗ!‰""’h0I£ ¦Πƒ’Ά•πQ1{6γ.ΔνΝEίd”ϊ@m]]Τ·΄°Ώ«‹Ά.ϊ ‰'ˆ’€7'‡ΏŸΚ1c˜PVΞδρ㨩ͺΒ%Ί‰ή;8°ϊ=Ι¨)W"θ:QΑμ—Ž‘dΑςͺ&"(LΔΪ]Ώ§ΏŸ'ίx“'ίψ»G¨Š0Ϊ4ήψίΕ£‘Οz}ΒvCV΅8…MH· “4݁¦σ̐†R¦^E%·hΒ158F$£&)i–νΧ“Ω&ώ¬Κ‹ΊηX·{φpoc#6ν?³οϋ Δ=Bk1<.ΰΥΞŽ.,Δ%Ijΐό L%Ɍ8ΤίGΛϋοΣΉ};γ/¦rΑ½«‘wkc#ΏρEΏΰυœ|σζii1¬2υf$›„L†Fνϊ&η£ν"΄T~§°a›9鑨α…Εwε΄:‘ δ %δΕφv;ε+Ί6"M2\›/e¦>‹bu§εε§Θ*½«ŸΚž ζ%“ΐ‘ύ{χΎy3―_i‰UˆΆ Y—(ΚC0έn*Š‹ωάμΩκuύ~’ϋχ#…BΞ!ΑΖjAYmΗ»ΌM‚O›εΧ6σN?3ωMŽa…φ“]‡L”ίΒ ν…ΈmηNφU­K€=#΅~rd%r§ ©,(,δβρ”ώAŽ₯•dšˆ†νg·ˆύ%IβΦΪΪt|G.ύ˜ΈΑzόpΎ•qΩ18HΓΠΣσσ-­¬lνΣgS“‡R ˆ‚…Ž”αri”Ψ€#QU#$aA¨!)ιςI^xω}ν‡]ωzΉύΡGωΣm·©χεL›F°ŽΈa9^3Ε–,2ξ’SOΓFωՌΌCt]6₯ΎdΧ m€φύ²h"ΚDω?κλK§ό’’[#*C°‡y–š=—$ϊcQζΰN–³² ¨ΤΧΝBt9aXΛρπξC’έ‡ oGS"J&œΖώΝΉζUO΅ O›8ΧΦ­£οc˜\ΧάΜηfΟfJeeΚΊ——6ι0Ϋ₯όN]ύ¬σΙΈZΫ΄d$ Ν&Ρη04Πςϊ Njύv¬FNŒͺ! ΔγόΧΦ­tΩƒ~ξQ<€Δ'έ$‰ ΞŠΜžΠRδvS•(jPJt .θ ΈΓ ‚β,h»ί]€#h’sfσ ’ΙBI;Ύ\@“¬Τ)eH’}}Ξ¬Qηυβq»Y΅a‡¬ίΉ“«N; 2ΪLτzID£Δ sδ$Lϋˆ%+­2υZΆ δξ iϋχ3‰υΣuώ陬‘Λ’ί_ϋ>77σj{»f₯Ήη¦}€ΧŒλ­ΕN` p’U(Π°¨ Ÿ²X‡‘΅`AP›o°Ι :c‘ςt«}o@AεΏΧ&S Δ‡x Ι-M΅O(/ηÝ;i΅ήxΘB_NK•Ρfξ’"MMH9u9#Ε7ƒ+nΎv–^²Τ&dΒ:δPωMŸ§μφ:$aΊŸtI?§ΧΔ’ ±sp»vοfΐ:ρ—~―½ώaOJ’ΔΎŽΎ°h‘ZD‰χχον=Tošv.γ^Cx`ε!hΙ;U»o³Γ:Ϊυ΅ον΄iΓw(I½mϋ-ω;“:ρ`ΝαZ#.―μ@ž+x‚Υ:’ςD‘jΏ_†ζκ”ΡDΉΝΨ…L<c uΜOͺΦoˆƒ —‘ &ΐPšp#IrBPyΟi&PΫΤΔΞζf·τL;–Ε55ͺ!”"’#4hΕRωM²ςR¦r½N›pΤΔή‚Σ^ό,§τf"όzϞtXyΈη}‡s}ˆ~Ή™NάR^ho§v`@u9ΝπV’1+›Ώώ39Ξ°/=Yζ‘, Ό„”@RβM-·›$1ΤΠ@Xν”$‰ΫΎš|Νh―Ú ¨«#’‘—sr²Sn3ε1Ž΄Rƒ€©•KNΚf62v»­Ί(€›t<~NΉόΤΟ‘Ιu±8ηχοη‘τ“7)ΊΑ§έ„η˜wΫωBOΆξ£)Πίi1’IϋΕ¦ϋΒ€„fQjmJ ΕhH ›…’t–‘@[%H θί±Cw¬ cΖpΣe—}, Ύ₯…¨β™¨Œ•›ύh•Sϋ·ζ~I!|Ρ*»#Va©dc4ΓkTΧ?]^"ω]§£ς‚eZλ·Pώ-ύύάέА.›Χ­θDθ³`@.o܁ Ό1σXs έα0’$‘Fy’Υn )Ώ,φxΙ"Lχε&,ζD!‘κ+—€‘; %YyΉtωrϜyψ-n4:άΐΫα’Ά›’a‘Ρ‹0KΨ¨όڝίIχbq|ΖΝ=yŽήH„›wμH7ΨCRt჏C?.πkΰ»'τΖ’<ΣΦJ(WCΣ=>‹ywIW˜›ov[J…"fή@ς(B<ρ8 ‘$:ίyWwΐβΌ|;η|ΩΈΰ!•₯₯ˆ’himn±dςΫjgΞHY’ψz“χ²Kπ›“u:P|Ι†|ΣjηwlΘ,pώΙktӎιΐ>(:πλK ?Nr“ƒ-TnηΠ/μί/j*.9f€sςšΨΫδiΚ=ͺ1‘)1lΞ½.¬yώΤ©δ(]—…Lέu³]=λφ`+OΒ„ΕG²Χ­!(•TO+αΘ•wšΚjη·yI’ΗγόOm-ου€Ω±MΡ>«ΰ4δŽ'KYΣΣΓsϋΫ†)1V‹F»“ζ6C$ΝŸ6 ‰( F~‚$1°e 1₯δ&y>ם}φaKϊss9eαB•#€D‚XΧ•˜Ν‚Ζ|ζ1)MRPΠ$σT†alw|§ρ~¦;ΏUΞΔ ΡD‚»x.=ξ£EYϋ«Έ>`™Lτlδω¦²7"&%˜₯° kέΖa€ Ι€›τ݁˜”“X†γΠΎ^Σ ‚‡0+A‚!ΕcψͺͺΤξΌκ±ciιθ`sCΓ!ΏΈη.]ΚΏŸsne4›‰0Έn$«vΣ€ΜFŠˆ=†ΕυV!†Ϊ콆B§τiŽ/˜$;eΦ$B³Ξa˜|Ζ'φνγώτnΰ*TΓ>+d€PP±ˆ–^ɞ@€|—‹I~pp³° 3Ϊζ! Πa=%Ή0Œs 5Ρ•OPΟ/0άΔϊρVŽΓSA.˜>gίy‡‘Π‘KόN¬δwίώ6₯ s1@hΗ’Z…PΤaτuξzςΆ”ΒςλΩ}†σ&ΝΝ0Γ€KpLΨ R,#ΦΫKnEžβbυ}¦ŒΗGuu4΅ΥΫΔςrΎ}Ι%άύυ―λ« ’ΔΰκΥΔΊ»uŠ £χΦά7ά€‚‘V―λΣO&NΎ§”)šO‹9`q.+χο玺:’ιύGd¨/£ΐZ^UŒΐiΚφI4( Α—‘Η Ωνƒƒδ "““F@ΓέgτνuaΊ`Ε1Θ0²Τ1 ά‚΅χ S~IRO ΦΫ‹―ͺJ%ρΈ\̝<…gί}—=bl˜TWTpί7ΎΑ·/Ίˆ‰εεΈ΄»~"At~ϊί|“¨“‰Δf$!Ϊ–`mfήHθ™œ*¬πθr#NΉΣΉϊو‘KΠ±β§qω’Δ3­­άδΜ£jR’ΫνŸDEϋ€€Aΰoΐ@Ήεw l"1· @)+³ CFΑΘτcS&45€²Γ…°-!!A"Ž㟍’Δ‚λΧ3τΡGΔΫΫS.Ή '9ΥΉΘ†Z…¬BΝΟ°jB–»΅”Ž„4ΓX?«ΙEiΠ}ςΗMπΓΊ:ώκHŸ?.T<€O¬23='έηπΥ‰U(ӁejoAΤ+΄¨Qh19ŒT”³(ˆΖ sŠqQ‘(s‰’zL'ၧ°ˆq—\¬›b 2‹Οξ}ϋX½u+υϋφΡ;8ΘΨ’ζMΚβš¦VVβ5ι%mΫF`σfy"°v7L*¨(ρ:ρLKifœ'”Μcφ g ;²υ€ΑH’ΔP,ΖχjkyέΩΐ—νΐΉB:οϚ@IΎg—LΚtΏŸ/Ÿ@΅ί$Κ¬=’{PEW²σ"&Έ«ΫΚλEez‘Zi°kžzmξΨ *Ξ<—Χ«w­Q3ΐ2…c†ΕΈ±ž†ώυ/bK9¦5‡ˆΏPJgH² “‰lOΖΦεמσ΁ώgηNv :9r32αMσ‘ T#ΘτO'γνžΨ²u`€b›ρΉ^O=X “ -™Ρ§7ɐ`Xœm“H†66βΞ/ΐŸ—":\ŠA“†Ι ό±ΞN‚›61τή{$>†a€YνςΩ(»Εσ%'Σ}²Lτ%Ο_’$Vuuρ?;wΰl¨Κ‡ΐ)ΐΎ#E©Ž$rεοΘ8Ϋp H°₯Ώ ‰šό|}]σ ‘ |8ΐŒPT·6? ©‘†υc)&ΨΨHd<₯₯ͺβ›I΄½ΐGΨ°¨Β7 σd3ώ;[4‘Dr¬ΊnGΞ}—nΧΟζxZ²N'mΔ’ΔcΝΝά^WGŸ3ŒΖί€+ύG’B ™’όψ’“'Ÿ\ZΚ₯γΖ“ηv«Tή""2ŒίΪMQNŒ‰²:‹fCFlΖ’‹š©Χš.Sώv‰Έs½δ«$·Έ!7Wύ ρΎ>bΔδF₯7’L•nΥ_?RΖΑt„ΈUΐΑ΄X‹œŽ.O«ό6η•<φ`4ΚwνβηΝ'A>CGš"©@{α/ΓA[σ¬Ό<.7žiωyͺ[ŸΜ ˆveΌδcΙό€nH©]Ε@˜K7χΠl>a2a)ΰw‰™+kΊηgr<»ζ « B#™7ΘdR“c9ω%IϋϊΈsχn;φ^iQΒ/© δ:Β ΐ)Ώ^»'vF£lθλΓ-Lχω γΖεBΊΡγ’!„Π5 Ω€dx )ΦjΨΔ#ΕΈ84 fŠjFΗeφ˜Ωn™)/SeOVœŽίJ—άs뙍ώΨΒOκλΖϋ}ΐΐ G²ιdΨπVΰtΐoχΔ°$±u`€ΦPˆyδ(»Ίι‘aJ,ι}δΰ^Hφ±Ϊx o%6& 5 Η^Q”“UYξH‹Ο•u6ίΚ Ι Π‰πΝνΫyͺ΅•Ag³Ί‘½Ώ?ΏŽOΣ ›Ό¦$ΣJ‘ΛΕ7§N₯Ɵ—Β$Γl€•‹/*»ΏθJybrHˆ΅`O&jB’2w°@γΕ§Ι˜…Ɵƒφ(΄―wJŠŒκϋ°·—ΫκκΨηœ—‘QΩlv}”ζSΈΕπ.rΦΡg;»ΌœΣΛ*(χζκΖΉ’ށ’¨¦.Ύ:²\LΕσ’•ρH3 ―K$WqΓή¬γω[ρ-rHV2@&ί·%δ™ΦV~ίάμ΄_@BΖ‘,ϋ4)‹λShSΎ¬γ€΄τ»»Ά ΰ`ͺί―cόQ”jiΏLζ Κ­Ζ’~ Ω°ό€}Ε iD+z.ΓΨm5«oή9\Β†=HΚ¦ 7Λ$‘0φφ_ΫΪψΩξέNQ} gχ|υΣ¦,ŸF )§ΥXΜ"Ԋ(ΜΞΛγΪͺ*&z} ™>œDŠ‚Γ¬ΏΛ… @ŠΡΡΖ#p yΚPΤτω=axΉΟ,dЌϋΆtΛΝΈ¬Ϋ %¨5F#΅³[@νy:Θ™χ…BόpΧ.ώΥΣγ€…7Ήfš€―o~•Δυ)6ΐ#ΐ$lΘE΄ίvG$Βͺ.<Ty½r’P4σM¦ ΫΔόhιΘ&&)θuΉδ¨#΄+ %”V]KχάΞ]7ά–4εC²»;‘ •0γΟϋφρίΫΆΡ :Ή- —™Οj?­Jςi6ΰYδ¬ν €)‚L9Άyp€ΝψD‘2—›—˜* i:‡)ΈΎeVχzΰuΉ,ΡYΉvZЌD£₯ώ$ᴏk½ιγPpc\Ÿ!Έ(yΞΡ(―wvςύ;yΎ½ κ }ΐΝΐ(kˆQpdΛϋJrp «=Ρ(τυQ74„O©ςω € ‚NΩ,σ€ZU‚ RτdHnQ$Χ¬Ωη³"vνΐ™πj~ΏΪΡΑ/x|ίΎt£ΉŒ² Έ–ΖδS-Ÿ΅WŒŒΌSΉνHά‚@ίΟW&L`vAΧkr’¨6₯*iϊ”–cπΊέψ\ßΔϋ$)ΌΩoJ/ MH°‘―_νΩΓ¦ώ~Β™a z•iε6£ΰΣ+5ΐCΘl-Ι’".?ž*Ÿ—%5ΜΔ.«2 IΈ .—:ΈγS‡0*Ή‘Ž\»γ;>T*d‘$‰h"AC ΐCMMΌΪΡ‘ΝΩ­ΎΤ}Φα³j’r=πsd‘γkαN*)ayιfεη“οqk<‚T΅@μ;“Πί’δΨ.³ΪΏIΗ‘ πfcΘ-* N%‘H¨‘@4ΚG}}<ΰ―vtΟπ p πΐgU>λ`,π3ΰ‹™„Ή‚ΐ¬ό|N()ayi)ωžΥ­Ρ`Μ BžΫ…Χ.ϋo2ޟdύ9Jmφ·$Ω»χ*Ί1ΆΧήξFy©½tv²₯Ώί %·™»Ώy@ǁΟςβ5)9ΈΈ4ΣΊ”]|Ei)VVRž“+γ4πβdΏ€c'*u»3KώYqϊΩyvl?f3,€“Vίƒ!τt ψΒažΨ·η 3%–έρŸ~…LΥύ™—Q`Ψԁc€?3³9€,/-ε܊ ¦εηγE\I^₯Ρ'yΡ}’‹{ΎEJof(Μβς€aΧγo:(CŒX"A0cΧЏ·ΆςZg'ρμίg'2’o]κ£ ό7pPF–εΏŸ“JK9Ί €ιyy2! )’œά†IΗ‡η[Μ]z£βgιΆg­τ ³Π@,ΖΑA>μο獎6;γβ3“8Π‰ γύυθ’5™J!πmΰbਬβr1ΩοgAAKŠ‹™SP€Χε’ΠΔύΧN8²bχ9E²©”Ν}}¬κξζ£ώ~ꆆθFζ-·!Σtύ/2Ÿδ¨Œ€¬e"p&rΥ`AΆ 0Αλε€R.;–i~Ώ2bάάU·RώOΊQ0ΖπΓz”ίu/8ΐ›]]΄†B„ ’`#rV etιŽ)Ž±AIDAT€‘ΌV~ΰDδ$¬‘8θ$―—cΖpκ˜1Tω|Έέx\.™γΜ`Μ”>ι)nƒ ­Ε›qυοOH‘xœΑxœ¦`Χ;;y»«‹=ΞϋπΣI-rw5ryO]²£ΰPΚΩΐOι€a"r*99ΜΝΟgAASό~Ζϋ|TζδPμρΘ㿌ρy†“η˜…R†pάδνξH„ύ‘­‘{‚A6φχ³e`€ŽΘˆΑλ@=p+πθ’5‡|ψ2ςψ²9#uP Pβρ06'‡Κά\¦ψύLρz™–—ΗTŸ|GνW•τZΏm,ΝY)έ,@«μΏr{(cw ΐξ@€=Α "φ‡ΓτD£NΪo3‘νΐΰqdJΈQ5»L>‡œ0<d$™ζ\‚€Gp‹"99Lρω¨φz™κχ3#/j―ŸΛ…G;W {knkηϊ™uΔw’ E’ˆK Σ RPΠ ±{hˆh”h"ATynbδ―ox9±·Ψ3ΊδF ΐ'QΌΘˆΒKa¦η›{±ΉΉ”{<”x<»έ”εδPβρPδρPθrαwΉπΊ\x”ΧD%‰P"ΑP,Ζ@"Ao,Fo$Bg4JO4Jo4JG4J{8Lδπ·· Γ΅ŠŒΰ .±Q9’d&π0ςΈ¨!δΪ΄4ϊcϊWΡ>εšΝ]>£ΐ§ING¦*[„<ρxςθ%ŝo@ž­χ&2»σ¨Œ€O­ψ«3‘‡šœ‚LbϊY’χe¦[G—Ζ¨ψ¬‰[Ιx‘ϋ.E¦/›ύ)ϋœ;i΅ŸAΖ㇔ŸΨθ5£bώݜxΛ€ω€ΉΊΰR~RΎ? 9KW~GMΘ4lο+.ύ(0gԌΚAΚ8%d¨P Β8 _ω»Pρ" «Β!Pς^δν!d|};0΄) ίΈτm£_Υ¨•Γ#. (2€ΕKπ+·‹”ίΚ}ΉJψβ†‡‘‘uΚOŸς; μκΠt(»ώ¨Œ€Qω‹¨ω1'wwνOBσ3*£2*£2*£2*£2*£2*£2*£2*£2*GΈό?ϊ―9+ΤIENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-failure-32x32.png000066400000000000000000000046051524161175200271540ustar00rootroot00000000000000‰PNG  IHDR szzτ pHYs  šœ 7IDATXݗ{pTΥΗ?ηάsο>’ IΨ<„&€ qΐBΙ&>`ŠΟV«ΦqjGμΤVΗ:ΣvڎNgθθ?N‘£ ŽP‡±_° ±h ¬1aW IΨM6Ιf_χžώ±7! ν™9s~sηžσύžίοw~Α₯‡ΨαPH „QΑ~ –ϋOϊΓΝΝI΄Άλ[[υΨή‹.Έό±X¬¨φz―,1͊₯@Άu<“9σU*uΨόΓ]/9Ύ“@ΈΉYΤπnΦOυxVίTQa-.)‘Κλe²eQ #ΆM<“!’Lςωΐ{z{3§R©w€-ΐžp($\\’€΄»Α <]nY?YMXU^N…ΟB „αn΄ŸZkbι4οχυρ—.έ—Ιlž‡B)—„τwj άΤ$λχν«ώΆ*\ψθμΩ”y½(…!%Z€”y`)ρΪqς$ά5žNσLG;zzk­ο 77·»Zύv Έ7ŸμΌoΪ΄Ϊ»«§S`Yψ•O)L₯ΐ%!ΖVU₯₯˜Α v2IφΜœΑA^ˆDxΊ³σ€ΦzMΈ₯εDύή½z’‡O/vέ=ujΓ­Σ¦a)Ӑ˜¦…2†i‚RH₯PΑ žιΣ±GFΐqJρω”)άώςΛΌxδ{NŸFΤΦ²pΦ,L₯(²δγx|ΩΖνξ«9Ÿΐ¦H`ss0xΣΥΣρš&†X¦…²L”i’”B(…πϋ9πΤΎ},il€`x8oŽ’" KKY0miΫζΉwί₯΅§‡ZZΈvt”―“ΙΚφ‘‘²πΚ•;7uu0\§ΈmŠeύωΑΪZ M %%–i’<†«~eš₯πTWσ§ύϋyχΣOQ₯₯4MŸŽ“N£KJ˜ \^ΞJΏŸ,[ΖΆƒωπ«―Έ§©‰ΖTŠΦΎΎ…O†ΓmαPθ‹M‘ˆ”n PΐΓ«+*ρ(2ο`ڝHmh)‘Jq&ΰΐ±clέ΅‹ΒBΌsζπ›½{YΎa«_y…CSϋϋyυή{9ΥΕύύ—–ςӚ€‡o=|XΆ|wΙ ¬ͺφz›gB’…D.Έ+#%Β0PS¦πΒ‘C %“δl›gφξΕπzωCS;{Œ©Α λ·mγde%3 V66²γΠ!TI Χ——sU Π|lpπϊp($dΟ'Π‚I“ςEζ†"OΒΘr„Γΰ¨ΟΗ‹»wŸχtw8ΐžS§¨d~<ΞKλΦQθσραΓ>u₯₯τΔbhΏŸΣdΩδΙ‘ϊΦV-λ[[KM!šΛΌ>€ΨڍΘKJΑΥWσ»νΫM§ΏўyσM˜5 !v9~œΐ4€ΠΖ2₯ΦΨZΟf`@±žTͺ=–NΟ­ ˆœι%ςvg'λζΝγΊ«β@[¦Rds9fWWsνάΉΜ―«czQΣDkM"¦λμY’ύύΤωύ8§OηAσφΕ±m„ΦK$ΚεΒ@L΅‡B½ ­­ϋzΣ©Ή£££”–—°eχnV͚ΕC«Ws ­l.GMe%Ώ\»–›jkρΗγ8##08˜·«γ€γ°Δ0UU8'OζmlΫq0΄ζŸ±g³ΩVΰk‰γ8@kxh˜xœEεΤVU‘aΓ;οPουrgK w·΄πΞγσΓ+―„L;ΓI§±³Yt.‡cΫΨΆ“N“M$pr9„γδ‹—€αVOΗb­Ζ¦hT§bΩlΛ ―wjΙπ0·¬YCJHϊƒ,ͺ«cU°Œ•σησ―ξnnίΈ‘·;:ΈcιRD"qΞΎŽƒ˜°žnΫγrk_F?ώdΖq ΩΜΪyΎθξζ{S¦pΓΜ™ˆt“;{–γ^χ?ϋ,Γ££τΔbL9“y†™ Ϊ½‘ž ξq9—CΫ6ΪΆIf³όΎ½žtϊΙp(thS$"Œ }x0—kτIΡPγυAr„l,Ž‘Ν ‡ͺšφuvΰί‘·,_Žw``œΐΨΤ.1ξ~ίάΥΕΞήήΏOlŠDœ±x Ρ ΰ‘έ½½έmƒds6Z;Ψ9;oίΆ6~½nέ8Ϋ‘ΡQβ€Ξfσ3——ΙfΩlžˆ«ϊέ==l‰F»G&Vΰη•dνMM‰M‘ΘηG‰Ϋ*=–Uευa 9›†Κ)$‹‹1„`σΠΠΧ‡ΞϋΑ…κΧz\3{NŸζ·νν#9­ο>ΏhcΎb…hΨΏΏxω–ŠΚͺ[«ͺ˜δρΰ7 LePΡΠ€ίγ!’3΄Φ!ΠcAΖqς…¦Φ€2ž‹Fٍ~ ά콬ΖΔ•g―Φ¬­¬dωδΙ(“ΛΔ2Œs%ω˜ ]cΙf_[’Q>K$vΏ"ί‚ρέ­Ω‰+Ĝύϋ5°xpaQQγ’βn,/γŠ@ΰ\g4DϋΠc1>ŠΕψ8 xήmΟώ―!\"P ά<_nY]u>Ÿ^Z\¬o.+Σ7—•ι₯ΕΕz¦ί―ƒ¦Ωε‚ή·75©K]T\ yA‹] LʁRχ[ θΞ§.h흉*Ώpό1¬b96²IENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-failure-72x72.png000066400000000000000000000166601524161175200271700ustar00rootroot00000000000000‰PNG  IHDRHHUν³G pHYs  šœbIDATxΪνœy˜\eο?ο{Nν½oIgι%IΗNθ$d%K'121œ‘ΝλΑ{uƒŠΚ\Η―ˆ+ hŒQ!Q@ ‹’ @BΘF:IwιNoικκΪΞyηsNΥ©κjBGη>χMΓ«λH!r€BΌu)rIRΉ)Γ`Τ0xΏŸ―;ΖιxάΩ•Ύ|ωΏ Rΰ^ΰ}Ξ†+'N䍍u]ΣΡ5‰& €Μ¦€ΤˆqΐΚ’ΐoešΉ0M† Ύzμ[Ϝ!•ύΟΐG‘σ¨φ‰ψgΐε%ΊΞfΎƒ«'MΖ£λHMC €hR"€Μ•0Έ x>ΒύqΞ£TvΏ”ΉOY|ΊΞ₯UUΤ쇡@l–ΟΩυg‘ fΫ”.h.*βΣ3fΠ ‘i]Jt!Π₯D“΄€HΊ›Tžͺ©B7δR«| R…ΆΉ%Κ4912Β?8ΐα‘gλ+ΐ?ΩΞζΫ Πt`3°LKΛΛΉΉ‘‘Ώ]Σ2ΰhRβΡ4 ({=‡€σ₯PoΔEJ!lUJ‘WV"‹ŠΊŽΣ4QΙ$*njF1Βa0Ν¬j*ΕH2Ι-π»η¬{«€co@•ΐƒΐ;°Ί²’ΦΥQαΛ‚£ a#Ρ₯ΘHn«œ£ZΚώΓ=ŽϊŒ”―Ή™ΐ¬Yh%%Ή₯*™$“ξλ#uϊ4Ɏ”‘υΑΙ$_8r„_ž9γlΪό-0πvτ p5ΐ²ςr>Π@ΉΧ‹GΣΡl•ςH™£ZΊ”θΆΪ MΛ¨—ΒΊ¨λ[!‚AΜHεXŸ<Υ^/ςKωϊcρτž=ŒΔγ ]§¦†‹š›Y·d s1c1R==Δ ΩΩ™9ΝH"ΑΏΏώΊ€­ΐίύ©$ύUΰ#ηάΤΨH™Χ‹Τ,.₯DΣ,²•R’I”‘I4])‘šfl9#%ώ–B βŸ6 ί΄i ι³gΗp”xE)nωώχιf8c8#<2BGO»βηΏϋΫφμaΪΤ©L›9Ο€I―—t·¦y5ΉΕŜεx,ζpjxϊ­΄ΞφHυr‡™>ƒ*―©IΛΏ±U)()‘RZ!šύ-έ–Θώx¦NΕ7oοήΝ«'O2sΖ ό'’κξF%“ΦqPι4ΤΦςΰ]€M]Σ2Χv€›6 ΊψΕφντ ±tΞЦLAϊύ–$ AHΧ™ ²xΨ‰ι.^ΆΚσR±Mf3>Χ4“ΩΕΕθΊucΊx€ζR%η·†¦Ϋ$ν"oα΅λ\°€Ο<υ›·mΰοW­β‡Ÿϊ©ή^†ŸyfŒ%^/©¦&šΖΤ`‘H0˜Hpphˆί<ΘcΟ?Ο@$B*me@ή½`w~θCL­ͺ"φKΔφξΝX·m==άρϊλτ[ uΩAuτ|$θΐ»qΕ„‰΄VVZaK&΅Œ΄hRCJΤ5MΨΗٜδV1G*dq1²±‘}η;˜6½ƒƒ4LšΔ¬¦&ΜX sh(Kβ6ΛΎ>‚}}€O"ΥΥ…ήΣCm8LkQ7^v”•qττiF“IŽuwΣήΣÊ . lΚŒώ~ŒH„`z0H_"ΑώHeε€ͺ€Ηή,@KŠk}>644"m0,‘–™uK­DMJ‹ƒ„eޝγς%ΘSSΓoϋϋytηΞΜEG Fb1Φ,YBQe%Ι“'J!„ΐ;y2E­­-^LpΞ‚sηβojB+.ΘxzzXVVΖάeΛxΉ½πΘm]](₯X=>šΧKͺ« •Nƒ”Μ-*bϋΐ€#E³g€Ξ7ΠZ644RνσeH8#Β"c‰ކΜNJ iσ’γ !^/ώ3Έύ‘GhΟZ:ϋϋ™RSΓ‚–PŠt?2B_²„-»wσΫ_dΗ«―r ½‘TŠ©3gRΤ܌πz1‡‡1#&G£ΜΊθ"v;F$cχλ―³β‚ ˜ήάLͺ»sxΨΚϋJ‰_Jž$₯”ΗNπύό\]l‘E KKΉ€ͺΪ¬νΟh€ζ¦iQ;i€iΦGfAu‡ Zy9'ΚΚΈkΛ©TΞΕ Σ€ημYV/X@ε„ €zzΠJKyͺ«‹ wέŎW_eηώύ<½gΨΏŸ€²΄”¦–τ²2R½½¨xœ)ι4ΤΧσβΡ£€ ƒ£§Osύ»ήJ‘:}Ϊς‘„ 9⹁Ί­ΌT`'ΠξΞxζ“φ{€ ΈΈͺ)ΫΑC Θ:}J€)ν`S λίRX§q{ʐU3]Η[WΗ½Oct―”cŽ;έίΟ/ΌΐGΏώu~ψΔxjk .\RbŽŽςΥΥ΄44πr[{ΪΪπ54 |Ύβ64 [λ`ƒΫxεKΠvβ«zAi)K+*ρhΊ% ‚LΰιH†°UH“ZAΞ‘Ά Ϋ…”ΘR:ͺ«ωζ–- F"γZˆWγς+˜0e ιξn&Α»W―fύκΥάpρΕ̝>γ== F"DγqvΎφ³iž=#Ζ ‡ρ€R TT°§­ Γ4)ΈtΡ"νν¨h4τNυϋyn`€KŠj'€ήB4˜0―΄ _ΞS³₯Λς KZ”­:Κώ­€°ε-λ=+[κBsηςΓ'ŸΓ=Ξβυz­”ΰΘίΪ²…„ψgΜΐ;4DK8Μ’‘!fŸ=Λ5EElΫ°Λ—/GA4ηΣχάƒašζΞ΅¬₯a°lβDJC!Άοίo•X**Ζ€\>\Wη«ήSHΕJm³ΞΤ@€ZΏ/;8•q;ƒE€ΦV%΅’Φ…Yσξ«―ggo/Ομή @CC7ήx#wήy'Ώώυ―9tθwί}7Α`€ŸmΫΖώγΗρΦΧ£WW# …X 52‚μθΰž΅kiš2€žΑA~²mzy9²¨L“¦` ίΐΡΣ§­A―ήθ’ΚJͺμ‡cΗh₯ωM° š‘eΊ'#1`PφIJ`ο·₯DaoS ¬ TIi'%Z @jΦ,6>ώ8'{z¬tδg?Λ·Ώύm>ρ‰O°nέ:š››yϋίΟΊuλ2ΧύΤwΏK"™Δί‚°*\ƒKχυρ±Λ.³jA†ΑS»wƒθεε”yz”Ÿ=υzy9ήΊ:”=Xa»*‘`icc&ϋxŽ»„Ο—σΡ&)Α4Η€\‚Λ'Lp+v¨ΖΘkΗ$Tω|Tyωο°χhn°•‘ ₯‹1:q"ός—ωΓ«―Ž9K>@;wξdΣ¦M Ίžξm·έFm~cρ8_ή΄ Σ4 Μ›gqP ΐ½Ώ=»[ιε’PˆΧ%ώΪkhœ./η`Ϊyκ«W―&qβf:]°'„ Ή¨ΘY-B@AΐλΧ4Šμr°ΚQRεβ•%dΧ1™ϋkk©ZΉ’^ΏŸυŸϋ»(\ΌŸ=έΉ–RμίΏŸƒ²qγΖΜ1555άrΛ-™υ}Gςύ­[ΡΛΚπ͜‰™HΠvκT&½qηM7Q”Hμμ€xιR~όΤSh·’†Ώ_Ή’ΪRR§N!ςKEΣΤ,Ÿ  Ψ6GΤx₯$€iΉR£\Υ†Ι1s5Z @uλ ͺ[[ωύρ\yλ­ξθ(œΰ¬€ͺͺ*³Fικκ"™LςΠCqόψρΜΎ 6°xρβŒύΗΆmœμιΑ7}:BΧΉeΡ">wν΅ότφΫyοœ9D_z  ±u~~όδ“™ίφώχ“hk³²•ωΐΈΦύRΘ:ΘSœ_ε^!πK-—€σ}!‡”2ΌsηPΕε55ά±y3ύκWθqίHή2kΦ,в’Μ—”νΪ΅‹M›6e¬‘¦i|γίΘΖiG²ρ±ΗΑ ώ™3 υτ°‘Όœ•gΟ2ς‡?XyνTŠΧObΤnlψΪ† Τz<ΔΫΪΐIδSΔΤ„ 2λ0NΥmK;ŸK^ Ζ°S)MžΔ”ΦVR/οΩΓW7o¦»ΏάΕ΅ζζ€Βα0λΧ―gΑ‚̚5‹E‹εT;–/_ΞuΧ]ΗζΝ›ψՎ¬]Ί”Eθ&ξξFΉΚΡf4Κ₯S¦πβόω,hjβš+H½ς Ζΰ`nα P‰Iˆ Ν5Ί«,“ΖP6ʝρTYKζ ™ΪΊΤ:^νhηΎGαΉW^yΣΆY³fQ\\œY_»v-kΦ¬AΧut]·œΤΌ&‡/~ρ‹<πΐ€R)Ϊ:;yπ™g˜σαγ›6τ™3Ωϊ™SΔλκbγͺUHΏŸΤΣOηψ>η*σΈbΠ ΄ΟΙ©Vš*ΗΚ|+‘ βο KΣψζ/~Αu_ψΒyΠΨؘ1ίN€ ρz½cΐΙΈω΅΅άqΗ™υ_<σ ;χοΗSSƒΧΆtω7έΧG²£ӝV± γΦί#»/!]ύ4VΧ€©\•ή,Pz0HΥ¬Ω„jkID"<Ύc›žx#ί"œ«'―Ά–šlΜƒRЁvξάΙ½χήΛΝ7ίLkk+Bn½υV6x<nΈαšššŽFωΑ―~E8Η[_pyΣ(e…ξΈλ Ιι}PŠ‘¬pΦ‘ ~€΄©H(3ηI(h’ϊU+iΌτR¦­YCjt”χ4̀ȎΊΟgihhΘ±`νννTUUΡΪΪΚM7έΔ=χάΓN;^»λ»8uκ”ˆ¦R„B!nΊι¦ΜŸά΅‹§^|­Ίο€IΉ@Ό` μ3‘,@§₯«u€i2j³|FvTNŸΞ‘XŒŸύφIRΊFYc#Ύώ>>~ε•o  κκκ¬o³oίΈΗ¦R)Ύτ₯/ρ›ίό†{ο½—Ϋn»G}4G=}Σ&†’QόMMVŠ#ΏαͺPΛΜ–2M³iΰ“I§„iLЦΣ`œs·΄p£π»={˜7£‰9Νο`δd+'Ldvc#OΌωvΐ¦¦&*μ„U:ζωηŸΓγ7nܘγ<ζ/νgΞπέ_ώ’½ώzτςr’αpΑ·J Gύ Κ}Ή ’ƒŽ%€ΆΈi2”Jε8„ΑŠrŽ=ΛχοG)ΕΆ>Jά0¨jiaτψqώeύϊσ’ S§N±qγFnΏύvnΎωf|πΑ?Ήpσ“O’LαρlίΛ€ΰ₯x¦iςz4S;Œ1έΠ^`U8•ΒDαψΣΎŠ žξ$n“εο_~™δβω :zO$Β{W­bλφνη6‘B°eΛ~ϊӟ’rU3ŸG AIQ+*(+.Ζo;lΙTŠp$Bοΰ ΓΓΩ”†ύ=ێ³g³­/ω4N?QnsšΙkvIΘΆ;;€&“„S)ͺμά²!%}ξτπ₯Y2{6“—-§να‡ωΗeΛyvχn"ηπ5”RD"„„ό~BK[ZXuα…,œ>¦²2Ό‰F4ŠJ$P©”5`»t-½^ Ÿc‘{»»Ωqΰ~―—ΫoΌ‘Ρ}ϋH lΩso.S―άϋlkΌ+Ϋϊ20€»ξνO&Bƒ©U^/JA*>Š–η–wυχσ“'žΰcλ―’ͺ₯…π‰\·v-ί{ψα7ξχ“’IUU,ž5‹υ«WsΩμΩ€:;­bήλ―“Βκͺ"ϋ΄έOέζŽ:₯¨3MήΫ‚ 1Άo'‹‘ #§q*cξσΧΟλ1κM&9‘}ΘہQ7@½ΐ+ΓhνΗ™ ‚d§5Žμ}Ώό%λV¬ qΡ"†ŽaUέTž©«γΘΙ“Α©›0χ,]ΚΦ¬‘H΄΅1ςμ³Ωμ`>7ŒρތμΐLΛQ±ζȈ5h·?ζVΉΎE!Π]Η?έΧ—1 ΐώόΊX krΙ%^!¨ρk•NSZίΐΛǏsu΅›JΡΩΣΓε+Zρ••‘zuΑ¦&^8t(“\uΙ%|κͺ«ΈfΞόmm€»»-QO§sžpNbήΟgŒDΈ9Ζ,Oš„½Oε[/{νΩnƒvσΖH~αΠώ!’JygSαυ"…$€ ΤΔ‰cΜygo/Σ'O¦₯₯…Ρή^&h:m££œΆŸΔ»/ζ3ΧΌλζ_HυΩ³}ύ¦O'4>ήΪZƒƒ˜££9Όΐ8OYευζ€γ&ζ|Λeοy`ηœί0822Β·ΫΫέ-Γͺ¬+0½LΧ™ ’K‰₯Ή©‰“±hNC)Ε‰.ή»j%₯΅“ˆξΫGYc#1Γΰ“ο{Χ.YBc4Šθ'0iΕση³γd{Oœ qΪ4ΌΊNͺ§'W=ΞΥiο€=ψΰ\ ε¨–k½τόίcǜNΨpΦ™1%ντλΊp*)gS€λH ±rα"F4-SZΖ4MV-Z„™JQήΧΗe+–3S)΄3gπWΧPB—άrΟ=|kΛ~³k³d “λλIbΈ’υ’ G­ΰά Έ>Β j>9›&=‰·9β©»€;m, vw΄7͚2]§.²ϊ}”B °¬ž‹W΄()Α0 |^/%Α KfΟFυχοθ@τυα-.¦rαBS§rχ#ς‰o}‹'NL₯H₯Στ‡Γ¬]Ίo @κΜ‹άΘ#Ϋ 0…€&_BάΔμβ"§‡ZεYΕ―;Ζ>ΛIcM€yα\-xΎ4νζΖiV/΄”hšŽ.~ŸŸ`i ‘κjό “τp„δΩtSαρϋ©]ΩΚKCC|ξΎϋθ(άi»εŽ;ΈtαB’»v‘ΗϊšŸ‘ή@’ά ΫςΉU.Η‘4MG"άΈw―½f—{"ηj Ϊ\Rͺ&a\P\š™Z €ΣΐŒΗIž=Λh_/‰ώ~ΜθBYm00ρ””rΓ}χ“ηdΊ—ζϊΛ.#P]MβΔ «σ«QηKL?Gε%ά•’λw9ΒAKΕSXsέ^z³=Š{€t'r’ΟΗŸίκ*s:VV³” ³ Π°«ͺΡ(-‹σψž=γ^ <2BΠηcΕόω¨tšToοΈςq ω:ΞqILŽΉψjKw7??}š€uώ=ΐ:Ÿ&ΞΣΐ4ΰΒφXŒ₯₯€–νQ֟₯=™$Hf:͌ΪZN€Σο:ι ‡qυΕS=mρ#G,.o†Ϋ·Ι3νŽdT+78φϊα‘ξ:~œ.Λ·‹ΧΨcΞ-&ž#ΎόΠ>”NσσΞNΚt‰¬•LSφoλ&Tf›0 †β―Y“i?)΄$R)Ύψ“Ÿ€RVG†aδ~άΰpˆRδsγmŽ l$•βώŽYͺ₯€» έίΉX ŒΆΕ’lνξ"mͺ,NZΦΞB*{;Κ΄nΚ0¨κξζc—_ώ†xΩ.K«dr¬ Ω)ΓΘuMλΚή/ #k±ήœ”a°Ή³“_gλωώuΌ{{3ud‚±kpύ}6˜–gζ™YΣΜ>ΥDηiΦΥΧӜνΰ³ό΅kIZΡΈkΰΚmέ`9Reš·tŽa<ήΣΓ7³ΑI\“ίκ\ °ζVπ“±˜(ρθLρΡ„²Ϊ€m€f“ΆΔ!rπ†ΓLY΄ˆί؝eΞ2ΉͺŠ»oΎ™+››IμޝiΟ₯@<樌0͌g, ™x7?Ή]Γ4yΊ―[vjΒgυΐ· Gύ)₯V‹FEP“LφН^ιŒuΓ^·^ “I‘jβD^ikCΑ'Χ―η[ό ο‡IΏφ†;Ušη;αBŽ Ή}£|_)Ο»NΫ’σ™Γ‡)šQΰ:»qόœu²σ]Ύ|: „ψ› xwu5ΕΊ]·ηk83…ΐ£eg κR#PS Σ(*.Β‰8vΜ*θ9_ηpέΫΤx© wΜfšŒ€Σό¬³“»²jnp‚Ρs-oeΞκΣ@" —‘αTŠ)?%š²&ρΊΥΜ‘"@E£ψ{{Vx‘HŒ ήΘ)T‘γlIk‹FΉλψq~œ36€5MόΡ7;Ψ·ΐ€Cΐε'γq½md―”4ƒ6eω'3ΑΠ„$`Ηuœ ˜B@ε'ΰ 9ΆϊmνξζλǏ³#›}8aςSη3Π?uή|½ Φ”Mγ‚βbώqςκƒAkΦ‘έ=ͺIKΝ‚šFPΣΖ$ςs¦b(ψ©ρ }@>ς½φvž bΥψ”oΏθ8ίΎ]―¦xX/@/σxXY^Α5“&Qεσ‘K‘α‘rgά·0œΧ’ŸoΒΙ$χttπλžΞ¦R¨Cΐύΐ-oυRoη»;V?°ϋ‹e±¦qqe%WΧΦ25$ iξ,HŽΥs’t΅κŠΗωΡ©SΚ=Š5 $•bΔ0L₯θI$²Ν_v€ω€m(φΩήρΫΊόW½ΓΜƒ5d"ΦϋΛZm°š°šΨίΜ·Cž½ΆUΪkΧςzμdןeωKΌOÚΈζ³?₯X3ύj°šI‹Ιv½…mNa½θ-aFν՟}ωO&G䆑sDνIENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-info-128x128.ico000066400000000000000000002040761524161175200266260ustar00rootroot00000000000000€€ ((€ !8KVdpwwpdVK8! Gƒ³δνπσυχψϊϋϋϊψχυσπν䳃G  J™γγ™J =qΈόόΈq=,‰ΔξξΔ‰,DΒυύ # *#0(6-90;1906-1)*##  ύυΒD BΡ7.WIq_%ƒn+x/™2£‰6«8°”:Ά™<Ήœ=Ήœ=Έ›=Ά™<±•:«8£‰6š‚3x/ƒn+q_%WI7.ΡB RΏ3+q_%Ÿ†4­‘9±•:Ά™<Ήœ>Ήœ>Ήœ>Ήœ>Ί>Ί>Ί>Ήœ>Ί>Ί>Ί>Ί>Ί>Ήœ>Ί>Ήœ>Ήœ>Ήœ>Ήœ>Ά™<±•:¬‘9ž…4r`%3+ΏRNΎ .'xe'΅˜<Έ›=Έ›=Ήœ=Ήœ>Ήœ>Ί>Ί>»ž>Ί>Ί>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>Ί>Ί>Ί>Ί>Ήœ>Ήœ>Ήœ=Ήœ=Έ›=΅˜<{g(1) ΎNΆϋ -%]N”|0¨6²•:Ήœ=Ήœ>Ί>Ί>Ί>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>Ί>Ί>Ήœ>Ήœ>Έ›=₯‹6iX"3+ ϋΆ]ω OB~i( †3¦‹6§Œ7ͺŽ7­’8»ž>»ž>»ž>»ž>»ž>»ž>»ž>»Ÿ?ΌŸ?ΌŸ?½Ÿ?½Ÿ?½Ÿ?½Ÿ?½Ÿ?½Ÿ?½Ÿ?½ ?½Ÿ?½Ÿ?½Ÿ?½Ÿ?ΌŸ?½Ÿ?»Ÿ?ΌŸ?»ž>Όž>»ž>»ž>»ž>»ž>»ž>»ž>»ž>Ήœ>Ήœ>²–;‘z0[Mω]*₯ [L–|0Ÿ…3£ˆ5₯‹6§Œ6©Ž7«8¬‘8·š<»ž>ΌŸ?ΌŸ?½Ÿ?½Ÿ?½Ÿ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?½Ÿ?½Ÿ?½Ÿ?»Ÿ?»ž>»ž>»ž>»ž>»ž>»ž>Ί>Ί>Ί>΅™<ͺ8jY# ₯*eέ E:–}/Ÿ„3 †4£ˆ5€‰5¦Œ6¨6ͺŽ7«8’9²–;½Ÿ?½Ÿ?½Ÿ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?½Ÿ?ΌŸ?ΌŸ?ΌŸ?»ž>»ž>»ž>»ž>Ί>Ί>Ί>Ήœ=±•:RE άe•ψ 9/‚l)›1ž„3 …4’ˆ4€‰5₯‹5§Œ6¨7«8­‘8―’9±”:ΌŸ>Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?½Ÿ?½Ÿ?½Ÿ?ΌŸ?Όž>»ž>»ž>»ž>»ž>Ί>Ί>Ήœ=Ÿ†4E: χ•­& p]#˜0›2œƒ2Ÿ…3‘‡4’ˆ4€‰5¦‹6©7ͺŽ7­8’9°“9²•:Ή›=Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@ΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Bΐ’Bΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’AΏ‘@Ώ‘@Ώ‘@Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?½Ÿ?ΌŸ?ΌŸ?»ž>»ž>»ž>Ί>Ί>Έ›<‹u.1)­.ΝUF’y.—~1š€1œ‚2ž„3Ÿ…3‘‡4€‰5¦Š6¨6ͺŽ7¬7­‘8―’9±•:³–;·š=Ύ ?ΐ’Aΐ’Aΐ’Aΐ’Aΐ’Bΐ’Bΐ’Bΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Bΐ£Bΐ’Bΐ’Aΐ’Aΐ’Aΐ’Aΐ’AΏ‘@Ώ‘@Ώ‘@Ώ‘@Ύ ?Ύ ?Ύ ?Ύ ?Ύ ?½Ÿ?½Ÿ?»ž>»ž>»ž>»ž>»ž>Ήœ>²–;o]$Μ.Hγ{f&”{/—~0™€1›‚1ƒ2ž„3 …3£‡4€‰5§‹6©6ͺŽ7¬7’9°“:±•:³–;·š=Ύ @ΐ’Bΐ’Bΐ£Bΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Dΐ£CΑ€Dΐ£Cΐ£Cΐ£Cΐ£Cΐ£Dΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Bΐ£Bΐ’Bΐ£Bΐ’Aΐ’AΏ‘@Ώ‘@Ώ‘@Ώ‘@Ύ ?Ύ ?Ύ ?Ύ ?½Ÿ?Ύ ?½Ÿ?»ž>»ž>»ž>Ί>Ί>Ήœ>”}1 αHGρ5,ˆq*“z.—}0˜0™€1›‚1ƒ2 …3’†4€‰5¦Š5§‹6©6«8­‘9―“:±•;³–;΄—=Άš=Ό @Α€FΑ₯Hΐ£Cΐ£Dΐ£DΑ€EΑ€EΑ€EΑ€EΑ€EΑ€EΑ€EΑ€FΑ€EΑ€FΑ€FΑ€FΑ€EΑ€EΑ€EΑ€EΑ€Eΐ£Dΐ£Dΐ£Cΐ£Cΐ£Cΐ£Cΐ’Bΐ’Bΐ’Aΐ’Aΐ’AΏ‘@Ώ‘@Ώ‘@Ύ ?Ύ ?Ύ ?Ύ ?½Ÿ?ΌŸ?»ž>»ž>»ž>Ί>Ί>©Ž8C8ξG8ϋUFu-“z/•|/—~0˜0š1‚2Ÿ…3‘†3£‡4€‰5§‹6¨7ͺŽ7¬9’9―”;±–<³—=΄™>Β«_Σΐ„ΩȎΪɐΨΖ‹Ξ·mΑ€FΑ€FΑ€FΑ€GΒ₯GΒ₯GΒ₯GΒ₯GΒ₯GΒ₯GΒ₯GΒ₯GΒ₯GΒ₯GΒ₯GΒ₯GΑ€FΑ€FΑ€FΑ€FΑ€EΑ€EΑ€DΑ€Dΐ£Cΐ£Cΐ£Cΐ£Bΐ’Bΐ’Bΐ’Aΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?Ύ ?Ύ ?ΌŸ?Όž>Όž>»ž>»ž>Ί>·š=o^%ϊ8Fϋ n["w-‘y.”{/•|/˜~0™1œ2žƒ2 …3‘†3£‰5₯Š6¨Œ7©Ž8«8¬‘:“<°”<±–=΄˜?ΪΝ₯ΔΉ• —y΅ͺˆΪΜ₯αΤ¨ήϞʲbΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯GΑ€FΑ€FΑ€FΑ€FΑ€EΑ€EΑ€Dΐ£Cΐ£Cΐ£Cΐ£Bΐ’Aΐ’Aΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?Ύ ?½Ÿ?½Ÿ?Όž>»ž>»ž>Ί>Ήœ=‰t- ϊFEρr^$Žv-w.’z.”{/–}/™~0š€1‚2ž„2 …3’ˆ4₯Š6¦‹6¨Œ7©Ž8«:­’;“<°•<³—?ŽʾšRM>#!“‹oΣΗ γΦ¬ΧΕ‰ΕͺSΒ₯HΒ¦IΒ₯HΒ₯HΒ₯HΒ¦IΒ₯HΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯GΑ€GΑ€FΑ€FΑ€FΑ€FΑ€Dΐ£Cΐ£Cΐ£Cΐ’Bΐ’Aΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?Ύ ?Ύ ?½Ÿ?ΌŸ?»ž>»ž>»ž>Ί>“|1νE,θs_#u,Žv-‘y.’z.”|/—}0™0›1‚2ž„2‘†4£ˆ5₯Š6§‹7¨Ž8ͺ:«:“<―”=±–?Β¬fΩΝ₯WQB<9-›’vγΦ¬ΰΣ₯Ρ»vΕͺSΒ¦JΒ¦JΒ¦KΒ¦JΓ¦JΓ¦JΒ¦JΒ¦JΒ¦JΒ¦JΒ¦IΒ¦IΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯GΒ₯GΑ€GΑ€FΑ€FΑ€EΑ€Dΐ£Dΐ£Cΐ£Cΐ’Aΐ’Aΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?Ύ ?½Ÿ?½Ÿ?»ž>»ž>»ž>Ί>Ÿ†4% ί,Υ  ze%Œt,Žv,w-’y.”{/–|/˜~0š€1œ‚2ž„3 †4’‡5£ˆ6₯‹8§Œ8¨Ž:«;­’<“>°”?΄›IΤΕ”ͺ‘‚FB5ΝΑœγΦ­ά̘ʰ`Γ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦JΓ¦JΒ¦JΒ¦IΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΒ₯GΒ₯GΑ€FΑ€FΑ€DΑ€Cΐ£Cΐ£Cΐ’Aΐ’Aΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?Ύ ?½Ÿ?ΌŸ?»ž>»ž>Ί>€Š7/(Ε΅ |g&Šr+u,Žv-x-“y.–|/—}/˜~0›2œ‚2Ÿ„3 †4’ˆ6€Š7₯‹8¨9©;«<­’>―”?±•@Δ―mέΠ©#!­€…γΧάΛ—Ε«TΓ§LΓ§LΓ§LΓ§LΓ§LΓ§LΓ§LΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΒ¦JΒ¦IΒ¦IΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΑ€FΑ€FΑ€FΑ€Dΐ£Cΐ£Cΐ£Cΐ’Aΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?Ύ ?½Ÿ?½Ÿ?»ž>»ž>Ί>€Š7$ ¦•r^#‰q+Œt+u,v-‘x-“y.–|/—}/™€1›2žƒ3Ÿ„3‘‡6’ˆ7€Š7¦Œ:¨;ͺ<«‘=“?―”@΄›IΩΛ xq\ ͝γΧΞΈnΓ¨MΓ¨MΓ¨MΓ¨MΓ¨MΓ§MΓ¨MΓ§MΓ§LΓ§LΓ§LΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦JΓ¦JΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΑ€GΑ€FΑ€FΑ€EΑ€Dΐ£Cΐ£Cΐ’Bΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?Ύ ?½Ÿ?½ž>»ž>»ž>Ί>„4[ό dSˆp*Šr+Œt+u,w-“y.”{.–|/˜~1š€2œ‚2ž„4Ÿ†5‘‡6€Š8₯‹:§Œ:¨Ž<ͺ>¬‘>“?―”@ΚΈ~Ό²’ ~waγΧ―ΨƌéPΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨MΓ¨NΓ§MΓ§MΓ§LΓ§LΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΒ¦JΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΒ₯GΑ€FΑ€EΑ€Eΐ£Cΐ£Cΐ’Bΐ’Aΐ’AΏ‘@Ύ ?Ύ ?Ύ ?ΎŸ?»ž>»ž>»ž>Ί>’{0 φZ#ν]L†o*‰q*Šs+Œt,v-‘x-“y.•|/–}0™1š€2œƒ3ž„5 ‡6’ˆ8€Š9₯‹:¨;©Ž=«>¬’?”@Ί’ZάΠͺTPAidQγΧ―ΫΛ–ΔͺRΔ©NΓ¨NΓ¨NΔ©OΔ©NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨MΓ¨MΓ§KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦JΒ¦IΒ¦IΒ₯HΒ₯HΒ₯HΒ₯GΑ€FΑ€EΑ€Dΐ£Cΐ£Cΐ’Bΐ’Aΐ’AΏ‘@Ύ ?Ύ ?½ ?Όž?»ž>»ž>Ί>†q,Χ#ΎRB…n)ˆp*‰q*‹s+t,w-’x-“{/•|/˜~0™1›‚4ƒ4ž…6‘‡8’ˆ8€Š:¦Œ<¨<©=«‘?­“?―”BΤƘ§ž ‰‚jδΨ°άΜ—Ζ¬VΕͺQΕͺQΕͺQΕͺQΕͺQΔ©PΔ©PΔ©PΔ©OΔ©NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨LΓ§LΓ§LΓ¦KΓ¦KΓ¦KΓ¦JΒ¦IΒ¦IΒ₯HΒ₯HΒ₯GΑ€GΑ€EΑ€Dΐ£Cΐ£Cΐ’Bΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?Ύ ?½Ÿ?»ž>»ž>Ήœ=l[$še2)j(†o)ˆp*‰r+Œs+v,w-’y.”{/—}0˜~1š3›‚4ž„5Ÿ†7‘‡8£‰:₯‹;¦Œ<¨=ͺ>«’@“AΒoΤΘ’JF9ΨΜ§δΨ°ΨǍūTΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺQΔ©QΔ©OΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ§LΓ¦KΓ§LΓ¦KΓ¦KΓ¦JΒ¦JΒ₯HΒ₯HΒ₯HΒ₯GΑ€FΑ€FΑ€Dΐ£Cΐ£Cΐ’Bΐ’AΏ‘@Ύ ?Ύ ?Ύ ?½Ÿ?Όž>»ž>»ž>Ά™«?¬’@³™LΤΕ™›“yš’wδΨ°δΨ°ΟΉqΕͺRΕͺRΕͺSΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺQΔ©QΔ©OΓ¨OΓ¨NΓ¨NΓ¨NΓ¨NΓ¨MΓ§MΓ§LΓ¦KΓ¦KΓ¦KΒ¦JΒ¦IΒ₯HΒ₯HΒ₯HΑ€GΑ€FΑ€Eΐ£Cΐ£Cΐ’Bΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?ΎŸ?Όž>»ž>»ž>§Œ7ςΎ]L‚k(…n)‡o*‰q*Œs+t,w-‘x.“z/•|0—~2˜3›‚5ƒ6Ÿ…8 ‡9’ˆ:£Š;₯Œ<¨Ž>©?«@­’AΔ°uΫΟͺEA5εΩ±εΩ±έ͚ǭWΖ¬TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«SΖ«SΕͺRΖ«SΕͺRΕͺRΕͺRΕͺRΕͺRΕͺQΕͺQΔ©OΓ¨NΓ¨NΓ¨NΓ¨NΓ¨MΓ¨MΓ§KΓ¦KΓ¦KΓ¦JΒ¦JΒ¦IΒ₯HΒ₯HΒ₯GΑ€FΑ€EΑ€Dΐ£Cΐ’Bΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?½Ÿ?»Ÿ?»ž>»ž>Žx/’A7-j(ƒl(…n)ˆp*Šq*Œs+Žv-w-’y.”{/•}1—~2š4›‚5„7Ÿ…9 ‡9’‰:₯‹<¦Œ=¨Ž>ͺ?«‘A±™MΩΝ§faOΙΎœεΩ²δΨ―ΠΉrΖ¬VΖ¬VΖ¬UΖ¬VΖ¬UΖ¬UΖ¬UΖ¬UΖ¬UΖ¬UΖ¬UΖ«SΖ«SΖ«SΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΔ©PΔ©OΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ§KΓ¦KΓ¦KΓ¦KΒ¦IΒ¦IΒ₯HΒ₯HΒ₯GΑ€FΑ€EΑ€Dΐ£Cΐ’Bΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?½Ÿ?Όž>»ž>Ί>eU!ϊ=γ €i'‚k(„m(†n)ˆp*‹r+Œt,Žv-‘x.’y.”{1•}2˜€4š5œƒ6ž„8Ÿ†9‘‡:£Š<₯‹=§>¨Ž>ͺ@¬’BΚ»‡΅¬ oiVβΦ―εΩ²έ̘ǬVΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬VΖ¬VΖ¬UΖ¬UΖ¬UΖ¬UΖ¬TΖ«TΖ«TΕͺRΕͺRΕͺRΕͺRΕͺRΕͺQΔ©PΓ¨NΓ¨NΓ¨NΓ¨NΓ¨MΓ§MΓ¦KΓ¦KΓ¦KΒ¦IΒ¦IΒ₯HΒ₯HΑ€HΑ€FΑ€Eΐ£Cΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ύ ?Ύ ?ΌŸ?»ž>»ž>΄˜<'! ²q`N€i'‚k(„m)‡n)‰q*‹r+u,v-‘x.“z/”|1—~3˜€4š6œƒ8ž„8Ÿ†9’ˆ;€Š<₯‹=§>¨Aͺ‘A΅ŸYΥΙ¦EA6!ΖΌ›εΩ³δΨ°Κ°`Η¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬VΖ¬VΖ¬UΖ¬UΖ¬UΖ«TΕͺSΕͺRΕͺRΕͺRΕͺRΔ©QΔ©OΓ¨NΓ¨NΓ¨NΓ¨NΓ¨MΓ¦KΓ¦KΓ¦KΒ¦IΒ¦IΒ₯HΒ₯HΑ€HΑ€FΑ€Eΐ£Cΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ύ ?Ύ ?ΌŸ?»ž>»ž>„p,ϊEη2)}g&j'ƒl(…m)ˆp)‰q*‹s,Žu,v-‘y/“z0š„=¦‘S­™]°œa°œa―›]©“N’ˆ<€Š=₯Œ=§@©Aͺ‘BΓ±xΪΞͺ ˜}zt_εΪ³εΪ³ΣΏ~Η­XΗYΘYΘYΘYΘYΘYΘYΘYΗ¬WΗ­XΗ­XΗ­XΗ¬WΗ¬WΗ¬WΗ¬WΗ¬VΗ¬VΖ¬UΖ¬UΖͺSΖ«SΕͺRΕͺRΕͺRΕͺRΔ©PΓ¨NΓ¨NΓ¨NΓ¨NΓ¨LΓ¦KΓ¦KΓ¦KΓ¦KΒ¦HΒ₯HΒ₯HΒ₯GΑ€FΑ€Eΐ£Cΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?Όž>»ž>·š=>4°h cR€i'j'ƒl(‡n)ˆp)‰q*Œt,Žu-x.ͺ—_ΓΆΚΎšΚΎœΚΏΛΐΜΑŸΞΓ‘ΡΔ ΜΎ”­–Q₯Œ?§A©A«’B΅ŸWΪΟ«ΫΠ¬άΡ¬nhVεΩ³ζΪ΄ίΡ‘Ι°^ΘYΘYΘYΘYΘYΘYΘYΘYΘYΘYΘYΘYΘYΗ­XΗ­XΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΖ¬UΖ¬UΖ«TΖ«TΕͺSΕͺRΕͺRΕͺRΕͺQΓ¨NΓ¨NΓ¨NΓ¨NΓ¨MΓ§LΓ¦KΓ¦KΓ¦JΒ¦IΒ₯HΒ₯HΒ₯GΑ€FΑ€Dΐ£Cΐ£Cΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?½Ÿ?»ž>»ž>”}10ι1(~h&€i'‚k(…l(‡n)ˆp*Šr+v/£‘VΙ½™»±“œ”{oiWFC70-%*(!41)UPCͺ …ΥΙ§Α±}§ŽB¨A©B¬“C•FΑ­qΩΜ¦άΠ¬έΡ­Ό²“GC7 ₯ζΪ΄εΪ³ΡΌvΘYΘYΘYΘYΘZΘYΘYΘZΘYΘYΘYΘYΘYΘYΘYΘYΘYΗ­XΗ­XΗ¬WΗ¬WΗ¬WΗ¬VΖ¬UΖ«TΖ«TΖ«SΕͺRΕͺRΕͺRΕͺQΓ¨NΓ¨NΓ¨NΓ¨NΓ§MΓ§LΓ¦KΓ¦KΒ¦IΒ¦IΒ₯HΒ₯HΑ€FΑ€FΑ€Dΐ£Cΐ’Bΐ’AΏ‘@Ύ ?Ύ ?Ύ ?ΌŸ?»ž>Ί>B8ΊPgU~h&€i'ƒk(…m(‡o)ˆp*•€>Ή©}ΗΌœpjX.,$ Ή―‘ΥΚ§Ζ·ˆ§C¨A±šRΉ£`΄U°—HΆRΜ»„ΫΞ§ήΣ―ίΤ―•{@<2RM@ζΪ΄ζΪ΄έ͚ɯ]Θ―[Θ―[Θ―\Θ―\Θ―\Θ―\Θ―\Θ―[Θ―[Θ―[Θ―[Θ[ΘZΘZΘYΘYΘYΘYΘYΗ­XΗ¬WΗ¬WΗ¬WΗ¬WΖ¬WΖ¬UΖ«TΖ«SΕͺRΕͺRΕͺRΕͺQΓ¨OΓ¨NΓ¨NΓ¨NΓ§MΓ§LΓ¦KΓ¦KΒ¦IΒ¦IΒ₯HΒ₯HΑ€FΑ€EΑ€Dΐ£Cΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?½Ÿ?Όž>»ž>ͺ8'ζ-%}g&h'j'ƒk(†n(‡o)›‡LΒ΅¨ …(%xdΤΙ§ΥΚ¨Ή¦j¦Ž@΅ ^ΣǞΨΝ¨ΣƜƴ|΄šL΄œLΏ¨bΡΐΰΤ°ΰΥ°ΣΘ¦•u ΛΑŸζΪ΅εΩ³ΠΉqΙ°\Ι°]Ι°]Ι°^Ι°]Ι°^Ι°^Ι°^Ι°]Ι°]Ι°]Ι°]Ι°]Ι―\Ι―[Θ―ZΘ―ZΘYΘYΘYΘYΘYΗ­XΗ­XΗ¬WΗ¬WΗ¬WΖ¬UΖ¬UΖ«TΕͺSΕͺRΕͺRΕͺRΔ©PΓ¨NΓ¨NΓ¨NΓ¨LΓ¦KΓ¦KΓ¦KΒ¦IΒ₯HΒ₯HΒ₯GΑ€FΑ€Eΐ£Cΐ£Cΐ’AΏ‘@Ώ‘@Ύ ?Ύ ?ΌŸ?»ž>»ž>XK₯9o[!}g&h'j'„l(†n)–CΕΉ–Ÿ•|&$ΤΘ§ΥΚ§Λ½“©’H§BΤΘ‘‘ŠrnhVΥΚ¨ΫΠ¬Χɟ¬j·OΊ RΔhάΟ€αΥ±γΧ²ΪΟ«mhV }vbδΩ³ζΫ΅ΫΛ–Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°]Ι―\Θ―\Θ―[Θ―YΘYΘYΘYΘYΗ­XΗ¬WΗ¬WΗ¬WΖ¬VΖ¬UΖ«TΖ«SΕͺRΕͺRΕͺRΓ¨OΓ¨NΓ¨NΓ¨NΓ§MΓ¦KΓ¦KΓ¦KΒ¦IΒ₯HΒ₯HΒ₯GΑ€Fΐ£Dΐ£Cΐ’Bΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?Όž>»ž>§7τΝ+# |f%~g&€i'ƒk'…m(‹t2»†­€ˆ¬£‡ΤΙ§ΣΘ₯·€h₯AΑ°zΜΑ‘2/'ˆpΟΕ£ή―Β‘½€Z»’Q½€SΡΏ…αΥ―γΧ²δΩ΄έΣ―“ŒtΝΓ’ζΫΆεΪ³Ι±`Ι°^Ι°^Ι°^Ι°^Ι°^Κ±_Κ±_Ι°_Κ±_Ι°_Κ±_Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°^Ι°]Ι―\Θ―[Θ[ΘYΘYΘYΘYΗ­XΗ­XΗ¬WΗ¬WΖ¬VΖ¬UΖ«TΕͺSΕͺRΕͺRΕͺRΓ¨PΓ¨NΓ¨NΓ¨MΓ§LΓ¦KΓ¦KΓ¦JΒ₯HΒ₯HΒ₯HΑ€FΑ€Fΐ£Cΐ£Cΐ’BΏ‘@Ύ ?Ύ ?Ύ ?½Ÿ?»ž>Άš»ž>‰s-»‹ v`#}f&~g&‚j'ƒk(‰s1Γ·”gaQ¬£ˆΣΘ§ΤΙ§©“M₯AΖΆ†ΜΑ‘ >;1Α·˜ΰΥ±άΣϽ€Β©XΔ«ZΚ΅kΥΒ…ίΡ’αΣ§ΨnjʲaΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ±`Κ±_Κ±_Ι°^Ι°^Ι°^Ι°]Ι°]Θ―[Θ―ZΘYΘYΗ­XΗ­XΗ¬WΗ¬WΗ¬VΖ¬UΖ«TΖ«RΕͺRΕͺRΕͺQΔ©NΓ¨NΓ¨NΓ¨MΓ§KΓ¦KΓ¦JΒ¦IΒ₯HΒ₯HΑ€GΑ€FΑ€Cΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?»ž>­’9)# ξ.ΠH;zd$}f&h&‚j'ƒk(¦•bΌ²” _ZKΣΘ§ΣΘ§»ͺt€‹@²]ΥΚ§hbRztaΪΟ¬βΧ²ή§ΜΆqΖ­\Θ―^Λ³dΛ³dΚ²bΚ²aΚ²aΚ²aΚ²aΚ²aΛ²bΛ²bΛ²bΛ²bΛ²bΛ²bΛ²bΛ²bΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ²aΚ±`Κ±`Κ±^Ι°^Ι°^Ι°^Ι°\Θ―[Θ―ZΘYΘYΘYΗ­XΗ¬WΗ¬WΖ¬UΖ¬UΖ«TΖ«RΕͺRΕͺRΔ©QΔ©NΓ¨NΓ¨NΓ§MΓ§KΓ¦KΓ¦JΒ¦IΒ₯HΑ€GΑ€FΑ€Eΐ£Cΐ’Bΐ’AΏ‘@Ώ‘@Ύ ?½Ÿ?ΌŸ?»ž>YK^3υ aP{e%}g&h&‚j'„l(ΔΉ—rlYΔΊ›ΣΘ§ΝΑ›£ŒD¦ŽFΘΉŒΈ―’(& £›ΩΞ¬δΩ΅ΩΙ“Λ΄gΚ²bΚ²aΚ²aΚ²aΚ²aΚ²bΛ²bΛ²bΛ²cΛ³cΛ³cΛ³cΛ³cΛ³cΛ³cΛ³cΛ²bΛ²bΛ²bΛ²bΛ²bΚ²aΚ²aΚ²aΚ²aΚ±`Ι°^Ι°^Ι°^Ι°^Ι―]Θ―[Θ―[ΘYΘYΘYΗ­XΗ¬WΗ¬WΖ¬UΖ¬UΖ«SΕͺRΕͺRΕͺRΔ©PΓ¨NΓ¨NΓ¨NΓ§LΓ¦KΓ¦KΒ¦IΒ₯HΒ₯HΑ€FΑ€FΑ€Dΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?ΌŸ?•~1 ¬e! u_#|e%~g&i'‚k'’~CΗ½žA=3‰‚lΗ§ΣΘ§΄‘f£‹@΄ dΦΜͺ20(HE9ͺ£‡εΪΆεΩ²ΪΚ’ΡΌwΜ΅gΛ²bΛ²cΛ³cΛ³dΛ³dΛ³dΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΛ³dΛ³dΛ³eΛ³dΛ²cΛ²cΛ²bΛ²bΚ²aΚ²aΚ²aΚ²aΚ±`Ι°^Ι°^Ι°^Ι°^Ι―\ΘYΘYΘYΗ­XΗ­XΗ¬WΗ¬WΖ¬UΖ¬UΖ«SΕͺRΕͺRΕͺQΓ¨NΓ¨NΓ¨NΓ¨MΓ§LΓ¦KΓ¦JΒ¦IΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ’Cΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?Όž>»ž>  κͺ<1zd%|f%h&i'ƒl(₯“b΅¬*(!;7.ΡΗ¦Η§ΗΉŽ£ŒD€DΖ’ƒ}h >;1ͺ’‡ηά·ζΪ΅αΣ¦ΪΙ‘½xΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΛ³dΛ³cΛ²bΛ²bΚ²aΚ²aΚ²aΚ²`Ι°_Ι°^Ι°^Ι°^Θ―\Θ―ZΘYΘYΗ­XΗ­XΗ¬WΗ¬WΖ¬UΖ«TΕͺSΕͺRΕͺRΔ©PΓ¨NΓ¨NΓ¨NΓ§LΓ¦KΓ¦KΒ¦IΒ₯HΒ₯HΒ₯GΑ€Fΐ£Cΐ£Cΐ£Bΐ’AΎ ?Ύ ?Ύ ?ΌŸ?»ž>`Q (ν]Lzd%}f%€h&j'ƒl(±’v¨ …΅¬ΡΗ¦ΡΖ€°œ`‘‰@Β³‚ΒΈ™,*##!‰rέ°εΪΆζΫ΅δΨ―έ͘Կ~Μ΄gΜ΄gΜ΄gΜ΄gΜ΄gΜ΄gΜ΄gΜ΄gΜ΄gΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΜ΄eΛ³dΛ³cΛ²cΚ²aΚ²aΚ²aΚ²aΚ±_Ι°_ΡΌwΦΔ‡ΦĈ½yΘYΘYΗ­XΗ­XΗ¬WΗ¬VΖ¬UΖ«TΕͺRΕͺRΕͺQΔ©OΓ¨NΓ¨NΓ§MΓ§LΓ¦KΓ¦KΒ₯IΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?»ž>₯Œ6w# zd%{d%}f&€h&j'ƒl(΄¦|£ša\MΞΓ€ΡΗ¦ΔΆ‹ ˆ>­˜XΗ₯|ubSOB•}ΒΉœΦΜ¬θέΊθέΊγΦ«έ̘ΧΔ‡ΌwΟΈnΞ·mΞ·lΞ·lΝΆjΝ΅iΝ΅iΝ΅iΝ΅hΝ΅hΝΆiΜ΅hΜ΅iΝ΅jΟΉpΥΒ‚ΫΛ”αΣ§ζΫΆΧΝͺΚΐŸΟΔ£ζΪ΅δΧ½yΘYΗ­XΗ¬WΗ¬WΖ¬VΖ¬UΖ«SΕͺRΕͺRΔ©PΓ¨NΓ¨NΓ¨NΓ§LΓ¦KΓ¦KΒ¦IΒ₯HΒ₯HΒ₯GΑ€Eΐ£Cΐ£Cΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?Όž>»ž>ΐn)! zd%{e%~g&€h&j'ƒl)΅¦|§ž„²ͺΡΗ¦ΡΖ€£ŒH ˆ@ΕΆŠΒΈš<:0b]N…k§Ÿ†ΖΌŸίΥ³θέΊθέΊζΫ΅γΦ­βΤ§α€ΰΡ’ίϟίΠ ίΠ ΰ’αΣ₯γΧ¬εΩ²ηάΉεΪΆΚΐ ¬£ˆ†ohbRA=3%$1/'ojXΪΞ«ζΪ΄Τΐ~Η­XΗ­XΗ¬WΗ¬WΖ¬UΖ«TΕͺSΕͺRΕͺRΔ©OΓ¨NΓ¨NΓ§LΓ§LΓ¦KΒ¦JΒ₯HΒ₯HΒ₯GΑ€Fΐ£Dΐ£Cΐ’Bΐ’AΏ‘@Ύ ?Ύ ?ΌŸ?»ž>SFϋ½L?zd%|e%g&€i&‚k(„m)²£x³©Ž'%keUΡΕ₯ΡΗ¦΄‘jž‡=°œ`ΣΘ§TPB ! 0.&GD9a]Nto]ƒ~jŽˆr–x–x•Žwˆs‡lxr`d_OPL?20($" ]YIεΩ³γΦ¬Κ±_ΘYΗ¬WΗ¬WΗ¬VΖ¬UΖ«SΕͺRΕͺRΕͺPΓ¨NΓ¨NΓ¨MΓ§LΓ¦KΓ¦JΒ¦IΒ₯HΒ₯HΑ€FΑ€Dΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?»ž>‰t-ϊlX!zd%|e%h&€i&‚k(…m)©™iΖ»<8/ΞΓ£ΡΖ¦ΖΉ‘ ‰B ‰CΙ»’¬£ˆ***Ό²”ζΪ΄ΣΎzΘYΗ­XΗ¬WΗ¬WΖ¬UΖ«TΕͺRΕͺRΕͺRΔ©OΓ¨NΓ¨NΓ¨MΓ§LΓ¦KΒ¦IΒ¦IΒ₯HΑ€FΑ€EΑ€Dΐ£Cΐ’AΏ‘@Ώ‘@Ύ ?½Ÿ?»ž>¨Ž7Qzd%zd%~f%h&i'‚l(…m*™†NΙΏŸfaQ’ŠsΠΕ₯ΡΖ¦­™^…=² gΤΙ©&$333ηηηΒΒΒͺͺͺlllJJJ ­€ˆζΪ΄ΨΕ‰ΘYΗ­XΗ¬WΗ¬WΖ¬UΖ¬UΕͺSΕͺRΕͺRΔ©PΓ¨NΓ¨NΓ¨MΓ§LΓ¦KΓ¦JΒ¦IΒ₯HΑ€GΑ€FΑ€Dΐ£Cΐ’BΏ‘@Ύ ?Ύ ?½Ÿ?ΌŸ?°•: ‘Q zd%zd%~f%h&j(‚l(…n*ˆq.ΘΎž―¦‹ C@5ΟΔ€ΠΕ₯Α³ˆœ…>‘ŠEΡΗ₯ys`ωωωσσσΪΪΪΑΈ™ζΪ΅ΧΕ‰ΘYΘYΗ­XΗ¬WΗ¬WΖ¬UΖ«TΕͺRΕͺRΕͺQΓ¨NΓ¨NΓ¨NΓ§LΓ¦KΓ¦KΒ¦IΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ’Bΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?΅™<>4Θ„7-zd%zd%~g%h&j(„l(…o*‡p,° rΚΐ UQD ΅¬ΟΔ€ΟΔ£§“U›„<Β³‡»±”%#ΪΪΪ_[KζΫΆζΪ΅½yΘYΘYΗ­XΗ­XΗ¬WΖ¬VΖ«TΕͺSΕͺRΕͺRΔ©OΓ¨NΓ¨NΓ§LΓ¦KΓ¦KΒ¦JΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?Έ›=ZLν―L>zd%|d%~g&€h&k(„l(…o*‡p,‘{;Κΐ‘ΛΑ’mgVΝΒ’ΟΔ₯½™‚:«˜[ΞΓ‘rlZ’’’;;;***GGG444&&&ΡΖ€ζΫΆβΥ©Κ²bΘ―[ΘYΘYΗ­XΗ¬WΗ¬WΖ«UΖ«SΕͺRΕͺRΔ©PΓ¨NΓ¨NΓ¨MΓ¦KΓ¦KΓ¦JΒ¦IΒ₯HΑ€FΑ€FΑ€Dΐ£Cΐ’Aΐ’AΏ‘@Ύ ?½Ÿ?»ž>r`%ωά`Nzd%|d%~g&€h&‚k(„l)†o+ˆq,‹t0―žoΛΑ’© †0-&²©ΟΔ€ΞΓ’šƒ=›ƒ=ΔΆŒΊ°” jjjqqq «««χχχέέέΑΑΑ§§§‹‹‹``` ‚|gηΫ·ζΫΆΤΑΙ―\Θ―ZΘYΘYΗ­XΗ¬WΗ¬WΖ¬UΖ«TΕͺRΕͺRΔ©QΓ¨NΓ¨NΓ¨NΓ§LΓ¦KΓ¦JΒ¦IΒ₯HΒ₯GΑ€FΑ€Dΐ£Cΐ’Aΐ’AΏ‘@Ύ ?½Ÿ?»ž>m+όξkX zd%|e%g&€h&‚k(„m)†o+‰q,Šs.•~@ΓΆ‘ΜΒ’½΄–ΞΓ£ΟΔ€­›c˜:­š_Η¦GD9777£££888ϋϋϋ«««0.&ηά·ηΫ·ΰΡ£Λ³eΙ°]Θ―[ΘYΘYΗ­XΗ¬WΗ¬WΖ¬VΖ«TΕͺRΕͺRΕͺRΓ¨NΓ¨NΓ¨NΓ§LΓ¦KΓ¦KΒ¦IΒ₯HΒ₯GΑ€FΑ€Eΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ύ ?ΌŸ?Œv. ώςp\"zd%|e%g&€j'‚k(…m*†o+‰q,Šs.Œu0ŸŠPΛΑ ΝΓ£ΞΓ£Α³Š™‚<›ƒ=ΙΌ––x!!!ςςςήήή ―――ψψψ,,,Ί±“ηά·ζΫΆΤΏ}Ι°^Ι°^Ι°[Θ―ZΘYΘYΗ­XΗ¬WΖ¬VΖ¬UΖ«SΕͺRΕͺRΔ©OΓ¨NΓ¨NΓ§MΓ¦KΓ¦KΒ¦JΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Ύ ?ΌŸ?–1ώ9υ r]"{d%|e%h&€j'ƒl(…m*†o+‰r-‹s.Œu1Žv2˜ƒC³£r°žjš…A—€9³‘lΗ§ΜΜΜφφφΗΗΗ›››lllAAA+++ 555’’’a]NαΦ³ηά·ΰ’Κ±`Ι°^Ι°^Ι―\Θ―[ΘYΘYΗ­XΗ¬WΖ¬VΖ¬UΖ«SΕͺRΕͺRΔ©OΓ¨NΓ¨NΓ§MΓ¦KΓ¦KΓ¦JΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?œ„4ώQ+φs^"zd%}e%h&j(ƒl(…m*†o,‰r-‹t.u0Žx2y4“z5”|5–8—€9ΟΔ£†p ¦¦¦ψψψΪΪΪΌΌΌžžžyyyΠΠΠεεε Ύ΅—ηάΈηά·Ν·lΚ±`Ι°^Ι°^Ι°]Θ―[ΘYΘYΗ­XΗ¬WΖ¬VΖ¬UΖ«TΕͺRΕͺRΔ©PΓ¨NΓ¨NΓ§NΓ¦KΓ¦KΓ¦JΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?£Š6! ώd9ωs^"{d%~g%h&j(„l(…n*ˆq,‰r.‹t/u1x2‘y4“{5”|5–8˜€9ΝŸΡΖ¦™‘yD@6TTTkfUθέΉηάΈΨnjʲaΚ²aΙ°_Ι°^Ι°]Θ―[ΘYΘYΘYΗ¬WΗ¬WΖ¬UΖ«TΕͺRΕͺRΕͺQΓ¨NΓ¨NΓ¨MΓ§KΓ¦KΓ¦JΒ₯HΒ₯HΒ₯GΑ€Fΐ£Dΐ£Cΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?¨7% rEωt_#{d%~g&€h&j(„l(…o*ˆq,Šr.‹t.u1x2‘y4“{5•}7–8˜9°žgΗ§ΣΘ¨ΚΏ ”u```όόόΌΌΌΪΠθέΉβΥͺΜ΄gΚ²aΚ²aΙ°_Ι°^Ι°^Ι―\ΘZΘYΘYΗ¬WΗ¬WΖ¬UΖ«TΕͺRΕͺRΕͺQΓ¨NΓ¨NΓ¨MΓ§LΓ¦KΓ¦JΒ¦IΒ₯HΒ₯GΑ€Fΐ£Dΐ£Cΐ’AΏ‘@Ύ ?Ύ ?½Ÿ?¬8)" zJϊ t_#|d%~g&€h&k(„l(†n*ˆq,Šs.Œt0v1x2‘y4“{5•}7–9˜:™‚;¦‘PΘ»“ΤΙ©ΥΚ©Θ¨hcS@@@λλλύύύφφφχχχRRR•ŽwθέΉθέΉΥΑΛ²bΚ²aΚ²aΚ±_Ι°^Ι°^Ι―\ΘZΘYΘYΗ­YΤΐ€ΫΛ–ΫΛ–Τΐ~Ε«TΕͺQΓ¨OΓ¨NΓ¨MΓ§LΓ¦KΓ¦KΒ₯IΒ₯HΒ₯GΑ€Fΐ£Dΐ£Cΐ’Bΐ’AΎ ?Ύ ?½Ÿ?­’:)# vHωt_#|e%~g&€i'‚k(„m)†o*ˆq,Šs.Œt/Žv1x2’z4”{5•}7—9˜:šƒ:…>Ÿ‡?Ά€lΡΖ’ΥΛ©ΧΜ¬Ό²•A>3 ΫΫΫεεεiii‚‚‚ŸŸŸΉΉΉΤΤΤνννΈΈΈ85-θέΊθέΉΰ£Μ΅hΛ²bΚ²aΚ²aΚ±_Ι°^Ι°^Ι°\ΘZΘYΜ΄fβΥ©ΠΕ’ΑΆ–ΜŸγΧ°αΤ§Κ²cΔ©OΓ¨NΓ¨MΓ§LΓ¦KΓ¦JΒ¦IΒ₯HΒ₯HΑ€Fΐ£Dΐ£Cΐ’Aΐ’AΎ ?Ύ ?½Ÿ?ͺ8& k>ψs^"|e%g&€i&‚k(„m)†o*‰r,Šs.Œu0Žv1y2’z4”{5•}7—€9˜:šƒ;…>Ÿ‡?‘‰@§KΑ±€ΣΗ£ΨΝ¬ΩΞ¬•Žv:7.ΚΚΚβββ !!!111AAAΈΈΈώώώ888ΐ·šθέΊηά·Τΐ~Λ³dΛ²bΚ²aΚ²aΚ±`Ι°^Ι°^Ι°\ΘZΘYࣴ«42) $"˜vδΨ°βΥͺΘ―[Γ¨NΓ¨MΓ§LΓ¦KΓ¦JΒ¦HΒ₯HΒ₯HΑ€Fΐ£Dΐ£Cΐ’Bΐ’AΎ ?Ύ ?½Ÿ?¦Œ7# Y1υ s]"{e%g&€i&‚k(…m)†o*‰r,Šs.Œt/Žv1y3’z4”|5•}7—€9™:šƒ;…>Ÿ‡@‘‰@£‹C₯ŽE±›[ΖΆ‡ΩΞ¬ΪΟ­ΛΒ’‘Šs¨¨¨ξξξEEEψψψŸŸŸmhWεΪ·θέΊίϟ̴eΛ³dΛ²bΚ²aΚ²aΚ±`Ι°^Ι°^Ι°\ΘZΠΊqγΧ±HE8Ή―ŽδΨ°έΝ›Γ©PΓ¨NΓ§LΓ¦KΓ¦KΒ¦IΒ₯HΒ₯GΑ€Fΐ£Dΐ£Cΐ’Aΐ’AΎ ?Ύ ?½Ÿ?Ÿ‡5 ώF$ςq]"}e%h&€i&ƒk(…m)†o+‰r,‹s.Œu0Žw2y3’z4”|5–}7—€9™‚:›ƒ;ž†>Ÿ‡@‘‰@£‹C₯ŽD¨Gͺ’JΆ‘`ƝΪΠάΡ―ΠΖ¦TPDuuuώώώϊϊϊeee™™™τττ ΒΉœθέΊθέΊΝΆkΜ΄eΛ³dΨΖ‹βΦ«δΨ―ΰ£ΡΌvΙ°^Ι°\ΘZΥΑ€ΨΝ©75+ΰΤ­δΧΤΑ‚Γ¨NΓ§LΓ¦KΓ¦KΒ¦IΒ₯HΒ₯HΑ€FΑ€Dΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ύ ?š‚3ώ$ρmY!}e%h&j(ƒl(…m)‡p+‰r,‹t.Œu0Žw2y3’{4”|5–~7˜€9™‚:›ƒ;ž†?Ÿˆ@‘‰A€ŒC¦ŽD¨Gͺ’H«“I–KΔ΄}ΩΝ¨έ°έΣ±‹…oCCCύύύƒƒƒ///iiito^θή»θέΊΧŊ̴eΟΈnβΤ¨ΧΝ«Ί±“Ό³•ΫΠ­ζΫΆά̘ɰ\ΘZΤΐ~ζΪ΄c^MSN@δΨ―ΰ€Ι±_Γ§LΓ¦KΓ¦KΒ¦IΒ₯HΒ₯HΑ€Fΐ£Dΐ£Cΐ£Bΐ’AΎ ?Ύ ?½Ÿ?‘z0 ώμfS}f%h&i'ƒl(…m)‡p+ˆq,‹t.u/w2y3“{4”|5–~7˜€9™‚:›„;ž†> ˆ@‘‰A€ŒC¦ŽD¨Gͺ‘H¬”J–K―—LΈ’]ΞΎάΡήΤ²·―“96- ύύύ’’’ΆΆΆΘΘΘ"!έΣ³θέΊγΥͺΜ΅iΜ΄fΪɐΩΟ­>;1LI<έΣ―ζΫΆΨΕ‰ΘZΜ΄eζΪ΄ΦΜ§/-%¨ŸγΧ―ΥčMΓ¦KΓ¦JΒ¦IΒ₯HΒ₯GΑ€FΑ€Eΐ£Cΐ’Aΐ’AΎ ?Ύ ?½Ÿ?‡r-ϊζTE}f&€h&j'ƒl(…n)‡p+Šr-‹t.v0w2y3“{5•|6–~6˜9™‚:›„;ž†= ˆ@’ŠA€ŒC¦D¨Gͺ’H¬”J–L°˜M²šO΄RΓ°sΫΟ©ίΥ³ΞΕ₯JF;βββΏΏΏJJJρρρωωω___§ ˆθή»ηάΈΦΒƒΜ΄fΝΆiδΧ‚|hJG;ζΫΆδΨ―ΟΈnΘYΨƌεΪ³ΚΏD@4άΡͺγΦ«Ζ¬VΓ¦KΓ¦JΒ¦IΒ₯HΑ€GΑ€Fΐ£Dΐ£Cΐ’Aΐ’AΎ ?Ύ ?½ ?zg)υΎ?4|f&€h&j'ƒl(†n)‡p+Šs-‹t/u0w2‘z3’{5•}6—~6˜9š‚:›„;ž†= ˆ@’ŠA€ŒB¦E©Gͺ’H¬”J–L°˜L³šO΄QΆžT½¦aΫΞ§ΰΦ΄αΦ΄NK?¨¨¨ήήή ͺͺͺΖΖΖIF<θήΌθέ»ΰ£Μ΄hΜ΄fΞΆkηΫΆlgV‹…nζΪ΅ά̘ȯ[Ι°^ήϞεΩ²‘‰q ¬£…γΧΤΐ€Γ¦KΓ¦JΒ₯HΒ₯HΑ€GΑ€Fΐ£Dΐ£Bΐ’AΏ‘@Ύ ?Ύ ?»>dT!ؘ(! }f&i&j'ƒl(†n)ˆo+‰r-Œt.Žv/w2‘z3“{5•}6—~7˜9šƒ:œ„;ž†= ˆ@’ŠA₯C¦E©‘G«’H¬”J―—L°˜M²›N΅œQΆŸTΈ UΊ£WΨʞαΧ΄βΨ΅]YJlllόόό---@@@ϋϋϋύύύIII ΜΓ¦θή»ηΫΆΤΏ}Μ΄gΜ΄eΝΆjεΩ³ΑΈš0.&ΤΙ§ζΪ΄ΝΆjΗ­XΝΆlγΦ¬άΡ«-+#oiUΰΤ«γΥ«Γ¦KΓ¦JΒ₯HΒ₯HΒ₯GΑ€Fΐ£Dΐ£Cΐ’Aΐ’AΎ ?Ύ ?Έ›=J>¬f }f&€i&‚j'„m)†n)ˆp+‰s-Œt/Žv/x2‘y3“{4•}6—7™9šƒ;œ„;ž‡=‘‰@’ŠA₯C¦ŽD©‘G«’I­”J–K°˜M²›N΅œQΆŸTΈ UΊ£VΌ₯YΤΕ“βΧ΅γΩΆe`Q---IIIΏΏΏ¬¬¬up_ζάΊθή»ήΜ͡hΜ΄fΜ΄eΜ΅gέΞ›ηάΈŠ„mœ”zζΪ΄έΝ™Η­XΗ¬WΧŊδΨ°Œ„l#!ΧΜ₯γΧΞ·mΓ¦JΒ₯HΒ₯HΑ€GΑ€Eΐ£Cΐ’Bΐ’AΏ‘@Ύ ?Ύ ?΄˜<,%f2xb%h&‚j'„m)†n)ˆp+Šr-Œu/Žv0x1‘y3“|4•}6—7™€8›ƒ;œ„;ž†=‘‰@’ŠA€ŒC§D©‘G«’I­”I–K±™M²šN΄œQ·ŸTΈ U»£V½₯XΏ¨\ΤĐγΨΆέΤ±TPCqqqOOOϋϋϋ%#ΛΓ¦θήΌζΫ΅Ξ·lΝ΅hΜ΄fΜ΄eΜ΄eΡ½wεΩ²ήΤ°C?5\XHΰΤ―εΪ³Θ―]Η¬WΙ°_γΧ­Α·•!½³‘γΦ­ΪΙ’Β¦IΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ’Bΐ’AΏ‘@Ύ ?Ύ ?―“: 2[K€h'‚k'„m)†o)ˆp*Šr-Œu.Žv0x1’y3“|4•}6—7™9›ƒ;œ„;ž†=‘‰?£‹A€ŒB§Dͺ‘G«“I­”J―–K±™M²šN΄P·ŸTΉ‘UΊ’W½₯YΏ§Zΐ©[ΨΘ–δΪ·δΪ·0.&ΞΞΞηηηuuuygθήΌθέ»ΦΒƒΝ΅hΜ΄gΜ΄eΜ΄eΛ³dΛ²cΩȎζΫΆ™‘y$#ΩΞͺεΪ³Ρ»vΗ¬WΖ¬VΪΙ’εΩ±FB6}v`γΦ­α₯Β¦IΒ₯HΒ₯HΑ€FΑ€Eΐ£Cΐ’Bΐ’AΏ‘@Ύ ?½Ÿ?„4Ρ8.€h'‚k'„l(†o)ˆp*Šs-‹t.Žw0x1’y3“|4–~6˜7™9›ƒ:…<ž†<‘‰?£‹A₯ŒB¦ŽD©‘F«“I­”J―–K±™M³›N΅O·žSΉ‘UΊ’V½₯XΏ§Zΐ©[Γ«]Ϋ˜εΪ·ήΤ²šššΦΦΦ'%ηέΌθή»αΣ₯ΝΆjΜ΄gΜ΄fΜ΄eΜ΄eΛ³dΛ²bΛ΄fεΩ±½³•ΣΘ₯εΪ³ΨΕ‹Η¬WΖ¬UΞΆlδΨ°vp[EA5γΦ­βΤ¨Θ―[Β₯HΒ₯HΑ€FΑ€Dΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ύ ?jY#ά‹€h'ƒk'„m)†o*‰p*Šs-Œt.Žw0x1’z3“|4•~6˜7™9›„;…<ž†<‘‰?£‹B₯B¦ŽD©‘F¬“H­•J―–K±™M³›N΅œOΆžQΉ‘U»£VΌ€XΏ§ZΑ©[Β«\Ε­`έΟ ζΫΈΐΆ™aaaϋϋϋkkk―¨θή»ηάΈΤΐΝ΅hΜ΄gΜ΄eΜ΄eΛ³dΛ³cΚ²aΚ²aίΠ ΡΖ€/-%ΓΊ™εΪ³άΛ—Η¬VΖ¬UΖ«TδΨ°‘™|γΦ­βΥͺΝΆiΒ₯HΒ₯GΑ€FΑ€Dΐ£Bΐ’Aΐ’AΎ ?Ύ ?½Ÿ?0)”>p\"ƒk'„l(†o*‰p*Šs,Œt.w0x1’z3”{4•~6˜7š8›ƒ:…<Ÿ‡=‘‰>£‹B₯B§ŽC©’F«“H•J―–K±˜L³›N΅œO·ŸRΉ‘T»£V½₯WΎ¦ZΑ©\Γ«]Δ­^Κ΄lβΥͺηάΉ”v444ΣΣΣ \XKθή»θέ»ίϟ͡iΜ΄gΜ΄fΜ΄eΜ΄eΛ²cΛ²bΚ²aΚ²aΨΖ‹ΦΜ©63*Ό³“εΩ³ά̘ǬVΖ«TΕͺRέΞ›ΑΆ• γΦ­βΥ«Π»tΒ₯HΑ€GΑ€EΑ€Dΐ’Bΐ’AΏ‘@Ύ ?Ύ ?·š=FχM@ƒk'…m(‡o*‰q+‹s,Œu.w/x1’z3”|4–~5—7š8›ƒ:…;Ÿ‡=‘‰>£‹A₯B§ŽC©‘F«“G•I°—K±™L³›N΅O·žPΉ S»£V½₯WΏ¦XΑͺ[Γ«]Δ­^Ζ_Ο»wζΫΆζΫ·SOB!!!κκκύύύWWW ΣΙͺθέΊηά·ΡΌvΜ΄hΜ΄fΜ΄eΜ΄eΛ³cΛ²bΚ²aΚ²aΚ±`ΧŊΠΖ€/-%ΕΌšεΩ³ά̘ƬUΖ«UΕͺRΧňΣΗ’ ΫΟ§γΦ«½zΒ₯GΑ€FΑ€Eΐ£Cΐ’Bΐ’AΏ‘@Ώ‘@Ύ ?|i)ϋ Ή,# ‚k(…m(‡n)ˆq*‹r,Œu.Žv/‘y1“z2”|4–}5—€7š8œƒ:…<Ÿ‡=‘‰>£‹@₯C§Cͺ‘D¬”G­•I°—K±™L³šM΅O·žPΊ‘S»’U½₯WΏ§XΑ©[Γ«]Ε­^Ζ―_Ι±bΨΖ‹ηάΉΣΙ¨ΘΘΘΒΒΒ‚{hζΫΈθέΊάΜ—Ν΅hΜ΄fΜ΄eΜ΄eΜ΄eΛ²cΚ²aΚ²aΚ²aΚ±_ήΝΌ³•Η€εΩ³ΫΚ”Ζ¬UΖ«SΕͺRΤΐΫΠ© ΤΘ’γΦ«ΣΎ{Α€GΑ€FΑ€Dΐ£Cΐ’Bΐ’AΏ‘@Ύ ?½Ÿ?8/Κ sq]"…m(‡n)ˆq*‹r+u-Žv/‘y1’z2”|4–~5˜€6š8œƒ:…;Ÿ‡=‘‰>€‹>¦C§D©D¬“G­•H°—K²˜L³›MΆO·žPΉ‘RΌ£T½₯WΏ§XΑ¨YΓ«\Ε­^Ζ―_Θ°`Λ²bήϝθέΉ‘š•••όόό&&&+)#ΡΗ§θέΊεΩ²Μ΄hΜ΄fΜ΄eΜ΄eΜ΄eΛ²cΚ²aΚ²aΚ²aΚ±`Λ³eδΨ±–w)' ΩΝ©εΩ³ΦΔ‡Ζ«TΕͺRΕͺRΤΐ~ΦΛ€ ΧΛ€γΥ«ΣΎ{Α€GΑ€FΑ€Dΐ£Cΐ’Aΐ’AΏ‘@Ύ ?’: >υYJ…m(‡o)‰q+‹s+u-Žw/‘y0“z2•|4–~5˜6›‚8œƒ9ž…; ‡=‘‰>£Š>₯B§D©‘D¬“F­•H―—J²˜L³šM΅œN·ŸPΉ Q»£SΎ€WΏ§XΑ¨YΒͺ[Ε¬]Η―_Ι±`Λ²bΝ΅jγΦ¬θέΉ`[L***HHHvvv₯₯₯‚‚‚†qθέΊθέΊΤΐ~Μ΄fΜ΄eΜ΄eΛ³dΛ²cΛ²bΚ²aΚ²aΚ±`Ι°_ΩȐαΦ±<9/c^MΰΤ―εΩ²ΟΉqΖ«SΕͺRΕͺRΦΔ†ΔΊ— γΦ¬γΥ«ΎzΑ€FΑ€Eΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Ύ ?r`&ϋ>Ν3)€i'‡o)‰p*‹s,t,w/x0“{2”|3–~5˜6›‚8œƒ9ž…: ‡<‘‰>£‹?₯Œ?§D©‘E«’E•H―–I²™K΄šM΅œN·žOΉ Q»’R½€UΏ¦XΑ©YΓͺZΕ¬[Η_Ι±`Κ²aΚ²aΤΐηά·ΥΛͺ!!!SSS.,%θέΊθέΊήϟ͡hΜ΄eΜ΄eΛ³eΛ²cΛ²bΚ²aΚ²aΚ±`Κ±_ΥΑζΪ΅Œ…n’šεΩ³εΩ²Ζ«TΖ«SΕͺRΕͺRάΜ—¨Ÿ  γΦ¬γΥ«Ρ»uΑ€FΑ€Dΐ£Cΐ£BΏ‘@Ώ‘@Ύ ?»ž>;1Ψ‘xc%‡o)‰p*‹s+t,w/x0“{2•|3—~5˜€6›‚7„9ž…: ‡<‘‰>£‹?₯@§B©‘E«’E­”G°–I²™K΄šMΆœN·žOΊ‘Q»’R½₯TΏ¦VΑ©YΓͺ[Ε¬[Η]Ι±_Κ²aΚ²aΚ²bήϝζΫΈ‡lΌ³–θέΊζΪ΄½xΜ΄eΜ΄eΛ³cΛ³dΛ²cΚ²bΚ²bΚ²bΛ³dΨƌζΫΆ’š64*ΣΘ₯εΩ²ΩȏƫTΕͺRΕͺRΕͺQβΥͺxb63)γΦ¬βΥ©Ξ·kΑ€Eΐ£Cΐ£Cΐ’BΏ‘@Ύ ?Ύ ?š3‘:ψK>‡o)‰q*‹s,Žu,v.‘y0“{1•|3—~4˜€6š7„9Ÿ…: †;‘ˆ<£‹?₯@¨Aͺ‘E«“E­”F°—I±™J΄›MΆœN·žOΉ P»’R½€SΏ§UΑ¨XΓ«[Ε¬\Η\Θ―^Κ²`Κ²aΚ²aΠΊrζΪ΄Θ§-+$gbSθέΊθέΉέΝ™Μ΄eΜ΄eΠ»tΨΖ‹άΛ–ΫΛ”ΨnjΨŊά̘δΨ±ΠΖ€rmZ—vεΩ²εΩ²Λ³dΕͺRΕͺRΕͺRΚ²bγΧ―PK=icPγΦ¬αΣ§Θ―]Α€Eΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Όž>iY#ϊ:Ξ ‡n)‰q*‹s,u,v-‘y/”{1•}3—~3™€6š7œ„8Ÿ…: ‡;’‰=€‹?₯@§Ž@©‘C¬“F­”F―–G²˜J³›KΆœN·žOΉ Q»’Q½€SΏ¦TΑ¨WΓͺYΕ¬\Η]Θ―]Κ±_Κ±`Κ²aΚ²aΪΙ‘ηά·}wdΣΙ¨θέΉεΪ΄ΞΈoΛ³dΨŊδΨ°ΡΗ₯¬£ˆΚΐ ΪΞ¬ΞΔ£¨ …e`O$"[VGεΩ³εΩ²ΩȏūUΕͺRΕͺRΔ©QΧŊȽš(&€„γΦ¬ΰ₯Β¦Jΐ£Dΐ£Cΐ’Aΐ’AΎ ?Ύ ?΄˜< ΞYO@‰q*‹r+u,v-‘x/“{1•}2—~4™€5›‚7œ„8Ÿ…: ‡;’‰=€Š>₯@§ŽAͺ‘B¬“F•G―–G²˜I³›K΅œLΈžOΊ P»’R½£RΏ¦TΑ§UΓ©WΔ¬YΗ]Ι°^Ι°^Ι°^Κ±`Κ²aΚ²bζΫ΅αΦ²Œ…pζΫΈηάΉΨΖ‹Λ²bΟΊqδΨ±œ•| <9/εΩ³εΩ²βΥͺΜ΅hΕͺRΕͺRΕͺRΖ¬UαΤ§˜uƟγΦ¬ΫΛ–Α€Eΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Ύ ?‚m+Yλ ‡o*‹r+u,w-’x.“{1•}1—4™€4›‚7œƒ8ž†9‘‡;’‰<€‹>¦?§ŽA©A«’D•G―–G±˜H΄šKΆL·žNΊ P»‘Q½€SΏ₯SΑ¨UΓ©VΔ¬YΗ­[Ι°]Ι°^Ι°^Ι°^Κ±_Κ²aΦΔ†ηά·zt`52*ΣΙ©ηάΈγΦ­Λ²bΛ²bΪΚ’ΪΟ­(& D@5ήΣεΩ²δذՁΕͺRΕͺRΕͺRΔ©PΤΑΪΟ¦D@4YSCάΠ§γΦ¬ΟΉrΑ€Dΐ£Bΐ’Bΐ’AΏ‘@Ύ ?Ύ ? μ^N@Œs+t+w-’x.“{0•|1—2™€4›‚6ƒ8ž†9 ‡;£‰<€‹>¦Œ>§A©B«’B­”F°–H±˜H΄šIΆœL·žMΊ O»‘Q½£RΏ₯SΑ¨UΓ©VΕ«WΖ­YΙ―[Ι°]Ι°^Ι°^Ι°^Ι°^Λ³dδΨ―γΨ΄ ˜yηάΈηάΈΡΌvΚ²aΚ²aέΞ›ΧΝͺ pjWάΠ«εΩ²εΩ²ΩǎΕͺSΕͺRΕͺRΕͺQΙ°`ΰ€ —{ž•xγΦ¬γΥ«Γ§KΑ€Cΐ£Bΐ’Aΐ’AΏ‘@Ύ ?ta&_Ϋ~g't,v,’y.“z/•|1—~2™€4›‚5ƒ8Ÿ…9 ˆ:£‰<€Š=¦Œ>¨@©B«’B­”C°–G²˜H³šI΅›J·žMΉ NΌ’PΎ£RΏ₯SΑ§UΓͺVΕ«WΖ¬XΘYΘ―[Ι―]Ι°^Ι°^Ι°^Ι°^ΧΕ‰ηΫ·wq^>;1ηά·ηά·άΜ—Κ²bΚ±`Κ±`έΝ™ίΤ±PL?]XIΈ―δΨ²εΩ³εΩ²ΥΒƒΕ«UΕͺRΕͺRΕͺRΓ¨OΫΛ•ΦΛ₯2/&ΤΘ‘γΦ¬ΦΔ‡Α€Dΐ£Cΐ’Bΐ’AΏ‘@Ύ ?²–;ίjQCŒs+v,‘x.”z/•}1—~2˜4›‚5„7Ÿ…9 ‡9£‰<€Š=¦Œ>¨Ž?©‘A«’B­”C―•E²˜H³šI΅›J·LΉŸN»’O½€QΏ₯SΑ¦TΒ©UΔͺVΗ­XΘYΘYΘ―ZΘ―\Ι°]Ι°^Ι°^ΟΈpγΧΡΗ₯ΝΓ’ηά·δΧ―ΠΊrΚ±`Κ±_Κ±_ΥΑζΪ΄ΡΗ₯_[K =:0Š‚lΘ€βΦ°εΩ³εΩ²γΧ­ΠΊsΕͺSΕͺRΕͺRΕͺRΓ¨OΨǍγΧ―52)‰‚hγΦ¬γΥ«Ζ¬Uΐ£Cΐ£Bΐ’AΏ‘@Ώ‘@Ύ ?]OjΧ k(v,‘x.”z/•|0—~2™€3›‚5„7Ÿ†8 ‡9’‰:₯‹=§Œ>¨Ž>ͺ‘A«’C­”C―•D°—F΄šI΅œJ·KΉŸM»‘N½€PΏ₯RΑ§TΓ¨UΕ«WΖ¬WΘYΘYΘYΘZΘ―[Ι―\Ι°]Ι°^έΞ›ίΤ±SOBsm[ηΫΆζΫΆΫΚ’Ι°^Ι°^Ι°^Ι°^Ι°^αΤ§ζΫΆζΪ΅ζΪ΅ζΪ΄αΥ°ζΪ΄ζΪ΄εΪ³εΪ³εΩ³εΩ²δΧΫΚ“Θ°]ΕͺSΕͺRΕͺRΕͺRΔ©PΨǎαΥ­a[J0-$γΦ¬γΦ¬ΧΔ‡ΐ€Eΐ£Cΐ’Aΐ’AΏ‘@Ύ ?Ÿ†4Ψuώ9.v,‘x-“z/–|0—1™€3›‚4„6Ÿ…8‘‡:’‰:€‹;§>¨Ž>ͺ@¬“A­”C―–D°—E²˜G΅œJ·KΉŸL»‘N½£OΏ₯Qΐ§SΓ¨UΕͺVΖ¬WΗ­XΘYΘYΘYΘYΘ―[Θ―[Ι°\Τΐ~εΪ³š’z$#ΩΞ¬ζΫΆγΧ­Μ΄gΙ°^Ι°^Ι°^Ι°^Ι°]Μ΄eάΛ–βΦͺεΪ³ζΪ΄ζΪ΄ζΪ΄εΪ³εΩ³βΥͺίϟΨΖ‹Λ΄eΖ«TΕͺSΕͺRΕͺRΕͺRΘ―]ΪΙ’ΰΤ¬jeR ΑΆ’γΦ¬αΣ₯Ι―\ΐ£Cΐ£Bΐ’AΏ‘@Ύ ?Όž>REώuΧdR‘x-“z/–|0—~0™€3›‚4„6Ÿ…7‘‡9£‰:€Š;¦=©Ž?ͺ?¬’A­“B―–D±—E²™F΅›HΈžKΉŸLΊ MΌ’NΏ€PΑ§RΒ©SΔͺVΗ¬WΗ¬WΗ­XΗ­XΘYΘYΘYΘYΘZΙ°]δΧΒΈ™!“‹tζΫΆζΫΆΤΑΙ°]Ι°]Ι―]Θ―\Θ―\Θ―[ΘZΘYΝΆiΠ»s½y½z½zΡ»wΠΉrΝ΅jΙ―]Ζ¬UΖ«TΕͺSΕͺRΕͺRΕͺRΡ½yίΡ‘ΨΜ¦HD7”wγΦ¬γΥ«ΥΒƒΐ£Cΐ£Cΐ’Aΐ’AΏ‘@Ύ ?y/Χ4 †o*“z.•|/˜~1™1›‚4œƒ4Ÿ…6‘‡8£‰:€Š;¦Œ<¨Ž=ͺ?¬‘@­“B―•D±—E³™F΄šGΆœIΉŸL»‘M½’NΎ€OΑ¦PΓ©SΔͺTΗ¬WΗ¬WΗ¬WΗ¬WΗ­XΗ­XΘYΘYΘYΘYΫΚ’εΩ³FB7:7.ΪΞ«ζΪ΅ΰΡ‘Ι―\Ι―\Θ―[Θ―[Θ―ZΘYΘYΘYΘYΘYΘYΗ­XΗ¬WΗ¬WΗ¬WΗ¬VΖ¬UΖ¬UΖ«TΕͺSΕͺRΖ¬VΣΎ|ΰ€γΦ­₯œxq[ΰΣ©γΥ«ίΠ Γ§Kΐ£Cΐ’Aΐ’AΏ‘@Ύ ?»ž>4‘3+‘x-•{.—~0™1›‚3ƒ4Ÿ†6‘‡8’ˆ8€Š;¦Œ<¨Ž=«@¬‘@“A―•C±—D³™F΄›GΆœHΈžI»‘M½£NΎ€OΑ¦PΓ¨RΔͺSΖ¬UΗ¬UΗ¬WΗ¬WΗ¬WΗ¬WΗ­XΗ­XΘYΘYΟΉpζΪ΄xr^Ÿ—|ζΪ΅ζΪ΄Ν΅iΘ―ZΘZΘYΘYΘYΘYΘYΘYΗ­XΗ­XΘ\ΣΎ}ΩƍΨŊ½yΝΆkΚ²bΛ²cΞΈnΤΑέΞ›δΨ°ΧΜ¦Ί±KG:ZUDήΡ¨γΥ¬γΥ«ΘZΐ£Cΐ’Bΐ’AΏ‘@Ώ‘@Ύ ?=4‘ Ψ\L•{/—~0™1›2ƒ5ž…5‘‡6£‰9₯‹9§Œ<¨Ž=ͺ>¬’@“A°•B±—D³™D΄›GΆœHΈžHΉŸJ½£MΏ€Oΐ¦OΒ§PΔͺRΖ«TΖ¬TΖ¬VΗ¬VΗ¬WΗ¬WΗ¬WΗ­XΗ­XΗ­XΘYεΩ²ΝΒ RM@ζΪ΄ζΪ΄ΩǍΘZΘYΘYΘYΘYΘYΗ­XΗ­XΗ­XΗ­XΟΉqβΥͺΗ€ΈΙΎœΫΟͺεΩ±εΩ°βΦΥΚ€ΑΆ•£›~zt^?;0e_LαΤͺγΦ¬βΤ©ΝΆjΐ£Cΐ£Cΐ’Aΐ’AΏ‘@Ύ ?o^%Ψ Kχ {f'—}/™1›2ƒ3Ÿ…5 ‡6£‰8€Š9¦Œ:¨Ž=ͺ=«‘?­“A°•B±–C³˜D΅šFΆœHΈžIΊŸI»‘KΎ£Mΐ¦PΒ§PΔ©QΕͺRΕͺSΖ«TΖ«TΖ¬UΖ¬VΗ¬VΗ¬WΗ¬WΗ¬WΗ¬WΦΓ…εΪ³Ό³“FC7 mhUΧΝ¨εΪ³βΥͺΜ΄gΗ­XΗ­XΗ­XΗ­XΗ­XΗ¬WΗ¬WΗ¬WΗ¬WΘ\βΥͺ€›&$$"0-%74*96,52),*"  ”ŒpαΤͺγΦ¬βΤ©ΞΈnΐ£Cΐ£Cΐ’Bΐ’AΎ ?Ύ ?•}1 χK• ’y.™0›2ƒ3Ÿ„3 ‡5’ˆ7₯‹9¦Œ:¨Ž<ͺ=¬‘>”@―•@²–C³˜D΅šE·œFΈžIΊŸI»‘J½£Kΐ₯MΒ¨PΔ©QΕͺRΕͺRΕͺRΖ«SΖ«TΖ«TΖ¬UΖ¬UΗ¬VΗ¬WΗ¬WΙ―]αΣ§εΩ³ΰΤ―άΡ¬έ­βΧ°εΩ³εΩ³ΦΔ‡Η¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬VΣΏ}ΰΤ0.%ΑΆ’βΥ«γΦ¬βΥͺΝΆkΐ£Cΐ£Cΐ’Bΐ’AΏ‘@Ύ ?΅˜< •Υ —}0š€1œ‚2Ÿ…3‘†5’ˆ7€Š7¦Œ:¨Ž;ͺ>¬‘>“?―•A²—B΄˜D΅šD·›EΈGΊ JΌ‘J½£KΏ€LΑ¦NΓ¨PΕͺRΕͺRΕͺRΕͺRΕͺRΖ«TΖ«TΖ¬UΖ¬UΖ¬UΗ¬VΗ¬VΛ³eίΠ δΨ°εΩ²εΩ²εΨ±δΧ―ΧĈǬWΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬WΗ¬VΖ¬VΖ¬UΖ¬UΨnjù— x`γΦ¬γΦ¬γΦ¬ΰ€Ι°`Α€Cΐ£Cΐ£Bΐ’AΏ‘@Ύ ?½Ÿ>4+Υ"μ8.–}/ƒ2ž„3‘†4’‡6€Š7§Œ9¨Ž;ͺ<¬‘>“?―”@±—A΄˜B΅šD·›EΉFΊ IΌ’J½£KΏ€LΑ¦MΒ§MΔ©OΔ©QΕͺRΕͺRΕͺRΕͺRΕͺRΕͺSΖ«TΖ«TΖ¬TΖ¬UΖ¬UΖ¬UΡ»uΧňΨŊΥΑΚ±aΗ¬VΗ¬VΗ¬VΖ¬VΗ¬VΖ¬UΖ¬UΖ¬UΖ¬UΖ¬UΖ«TΖ«SΧňγΧ―*(! @<0ybΦΚ’γΦ¬γΦ¬βΥ©ΧĈĨOΑ€Cΐ£Cΐ£Bΐ’Aΐ’AΏ‘@Ίœ=G<μ"?χG;œ‚1žƒ2‘†4£ˆ5€‰6¦Œ8©Ž:ͺ;¬‘=­’>°•@±–A³˜B΅šC·›DΉF»žGΌ HΎ’JΏ₯KΑ¦MΒ§MΓ¨NΓ¨NΓ¨OΔ©PΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺSΕͺSΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«TΖ«SΕͺSΕͺSΕͺRΕͺRΞ·mδΨ°ΑΆ•RM?'%UPAކl·¬‹ΦΚ£γΦ¬γΦ¬γΦ¬ΪɐʱbΑ€Fΐ£Dΐ£Cΐ£Cΐ’AΏ‘@Ώ‘@Όž>TGχ?aώNAž„2 …3’‡4€‰6¦‹6¨9ͺ:¬‘<“=―”?²–A³˜A΅™BΆ›EΉE»žGΌ HΎ‘Hΐ€JΑ₯KΓ¨MΓ¨NΓ¨NΓ¨NΓ¨NΓ¨OΔ©PΕͺQΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺSΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΖ¬VέΚδΨ°ίΣ«ΫΠ©ΥΚ€ΖΌ™·­²©‰Ί°ŽΜΑœΪΝ¦άΠ¨ίͺβΥ¬γΦ¬γΦ¬γΦ¬ΫΚ”Θ―]Β₯IΑ€Fΐ£Dΐ£Cΐ£Cΐ’AΏ‘@Ώ‘@Ύ ?cT!ώa‹ώL? …3’ˆ4€‰5§‹6¨8ͺ:«:“=―”>±–?³˜@΅™B·›CΈD»ŸF½ HΎ’Iΐ£IΒ¦KΓ§LΓ¨MΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΔ©OΔ©PΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺRΕͺQΔ©PΔ©PΗ[ά̘βΦ«γΧ­γΧ―γΧ―γΧγΧγΧγΦ­γΦ­γΦ­γΥͺαΤ§ΰΡ£ΥΒƒΗ­YΑ€FΑ€FΑ€FΑ€Cΐ£Cΐ£Bΐ’Aΐ’AΏ‘@Όž>kZ#ώ‹ŸD9›1€‰5§‹6¨7ͺŽ9«:­’;°”<±–?΄˜A΅™A·›CΈœDΊŸEΌ FΎ’Hΐ£IΒ₯JΓ¦KΓ§LΓ§LΓ¨MΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΔ©NΔ©OΔ©PΕͺQΕͺQΕͺQΕͺQΕͺQΕͺQΕͺQΕͺRΕͺRΕͺQΕͺQΕͺQΔ©PΔ©PΔ©PΔ©OΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΛ΄hΎ{ΧňΩǍΩȐΪȐΪȐΩǎΧΕ‰Σΐ~ΟΈoΘ[Β₯HΒ₯HΑ€FΑ€FΑ€EΑ€Cΐ£Cΐ’Bΐ’Aΐ’AΎ ?΅™·›?ΈœAΊžCΌŸCΎ‘Eΐ£GΑ€GΒ₯HΒ¦IΒ¦JΓ¦JΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ§LΓ§LΓ§LΓ§LΓ¨MΓ¨NΓ¨MΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨NΓ¨MΓ¨MΓ§LΓ§MΓ§LΓ§LΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΒ¦IΒ¦IΒ¦IΒ₯HΒ₯HΒ₯HΑ€GΑ€FΑ€FΑ€Eΐ£Cΐ£Cΐ£Bΐ’Aΐ’AΏ‘@Ύ ?—2 ύ˜„ύ[L£ˆ5­‘9―“:±•;³–;΄™=Άš?·›?ΊžBΌŸC½ DΏ’FΑ€GΒ₯HΒ₯HΒ₯HΒ¦IΒ¦JΒ¦JΓ¦JΓ¦KΓ¦KΓ¦KΓ¦KΓ§LΓ§LΓ§LΓ§LΓ§LΓ§LΓ§KΓ§LΓ§LΓ¨MΓ¨MΓ§LΓ§LΓ§LΓ§KΓ§LΓ§LΓ§LΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦JΒ¦HΒ¦IΒ¦IΒ₯HΒ₯HΒ₯GΑ€GΑ€FΑ€FΑ€EΑ€Dΐ£Cΐ£Cΐ’Aΐ’Aΐ’AΏ‘@΅™Έœ?Ή@»ŸB½‘CΏ’DΑ€FΑ€FΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ¦IΒ¦IΒ¦IΒ¦JΒ¦JΓ¦JΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦KΓ¦JΓ¦JΒ¦JΒ¦JΒ¦IΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΒ₯GΑ€GΑ€FΑ€FΑ€Eΐ£Dΐ£Cΐ£Cΐ’Bΐ’Aΐ’AΏ‘@Ύ ?ž…44,σU1γ \M±”:²•;΄—;Ά™=Έ›>Ή?»ŸA½ AΏ£CΑ€DΑ€FΑ€FΑ€FΑ€GΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ¦IΒ¦IΒ¦IΒ¦JΒ¦JΓ¦JΒ¦JΓ¦JΓ¦JΓ¦KΓ¦KΓ¦JΓ¦KΓ¦JΓ¦JΒ¦JΒ¦IΒ¦JΒ¦IΒ¦IΒ¦IΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΑ€GΑ€FΑ€FΑ€FΑ€Eΐ£Dΐ£Cΐ£Cΐ£Cΐ’Aΐ’Aΐ’AΏ‘@Ύ ?dT!γ1Εώ'" “{0°“:Ά™<Έ›>Ίœ>»ž?½ @Ύ’Bΐ£CΑ€DΑ€EΑ€FΑ€FΑ€FΑ€FΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ¦IΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΑ€GΑ€FΑ€FΑ€FΑ€EΑ€Dΐ£Cΐ£Cΐ£Cΐ£Bΐ’Aΐ’AΏ‘@»>š‚3(" ώΕ|σLA’z0·š=Ί>Όž?½Ÿ@Ώ‘@ΐ£Bΐ£Cΐ£Cΐ£DΑ€DΑ€EΑ€EΑ€FΒ₯GΑ€GΒ₯GΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯HΒ₯GΒ₯GΒ₯GΑ€FΑ€FΑ€FΑ€EΑ€Dΐ£Dΐ£Cΐ£Cΐ£Cΐ£Bΐ’Aΐ’AΏ‘@Ώ‘@š‚3SFσ|5ΘH<—1Ίœ=Όž?Ύ ?ΐ’Aΐ’Aΐ’Bΐ£Cΐ£Cΐ£Cΐ£CΑ€DΑ€EΑ€EΑ€FΑ€FΑ€FΑ€FΑ€GΑ€GΑ€GΑ€GΒ₯GΒ₯GΒ₯GΑ€GΑ€GΒ₯GΑ€GΑ€GΑ€GΑ€GΑ€GΑ€FΑ€FΑ€FΑ€EΑ€EΑ€EΑ€Dΐ£Dΐ£Cΐ£Cΐ£Cΐ’Bΐ’Bΐ’Aΐ’AΏ‘@Ώ‘@›ƒ3K?Θ5cώ8/š2Ύ ?Ώ‘@Ώ‘@Ώ‘@ΐ’Aΐ’Aΐ’Aΐ£Cΐ£Cΐ£Cΐ£Cΐ£DΑ€DΑ€DΑ€EΑ€EΑ€FΑ€FΑ€FΑ€FΑ€FΑ€FΑ€FΑ€FΑ€FΑ€FΑ€FΑ€FΑ€FΑ€EΑ€EΑ€EΑ€DΑ€Dΐ£Cΐ£Cΐ£Cΐ£Cΐ£Bΐ’Bΐ’Aΐ’Aΐ’AΏ‘@Ώ‘@…4=3ώc±ύ0(Œv.΅˜<Όž>ΐ’Aΐ’Aΐ’Aΐ’Aΐ£Bΐ£Bΐ£Cΐ£Cΐ£Cΐ£Cΐ£CΑ€Cΐ£CΑ€DΑ€DΑ€DΑ€DΑ€DΑ€DΑ€Dΐ£DΑ€Dΐ£CΑ€Dΐ£Dΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Bΐ’Aΐ’Aΐ’Aΐ’Aΐ’AΌž>³—<Œv/0(ύ±HΚόZK‰t.©Ž8Ώ‘@ΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ£Bΐ£Bΐ£Bΐ£Bΐ£Bΐ£Bΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Cΐ£Bΐ£Bΐ£Bΐ£Bΐ£Bΐ£Bΐ£Bΐ’Aΐ’Aΐ’Aΐ’AΏ‘@ͺ8‹u.ZK όΚHKΎ# D9q_%‘‡5Ώ‘@Ώ‘@Ώ‘@Ώ‘@ΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Bΐ’Aΐ’Bΐ’Bΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’Aΐ’AΏ‘@Ώ‘@Ώ‘@Ώ‘@‘‡5p^%D9# ΎK-± =4m\$™3»>Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@Ώ‘@ΐ’AΏ‘@ΐ’AΏ‘@Ώ‘@Ώ‘@ΐ’Aΐ’AΏ‘@Ώ‘@Ώ‘@»>™3n\$?5 ±-‘σώ0(K?dT!wd'†q,”|1…4’‰6…4”|1†q,wd'dT!L@0(ώσ‘VΎνύύνΎVS”ωω”S 8…ΛυυΛ…8 ,n‘ΖέκςωόύόωςκέΖ‘n,€πόπΐ?ψΰΐ?όψπΐ€?ώόψπΰΐ€??ώόψψππΰΐΐ€€??ώώόόψψψππππππΰΰΰΰΰΰΐΐΐΐΐΐΐΐΐΐΐΰΰΰΰΰΰπππππππψψόόώ?ώ?€ΐΐΰππψόόώ?€ΐΰΰπψώ?€ΐπψόΐΰψΐψώ?ώ?caronc-apprise-182f859/apprise/assets/themes/default/apprise-info-128x128.png000066400000000000000000000404371524161175200266370ustar00rootroot00000000000000‰PNG  IHDR€€Γ>aΛ pHYs  šœ IDATxΪνw|υ™ίί™ν»κΝ’,Ϋ’-WάmŒ1565„BzςΛ…—vιΈp@ξΒ—€@H$‘'@6˜:ξEΆdΙ²¬ήVZmοݝ™•dcξŽ/―Eλi;3Οσ}ΚηyΎΟοχΗϋγύρώψ?:Δςη;ψ°hͺ€ΐxΥxH1 „.  xxΨϊ>Ό7οίo΅ψp:0…ߋہΏZ f3˜θ}x—F °88 ˜xώχvΟΟ/ο+–£3\ΐG qάgˆlωϊ€ŒϋzΪΈOΧϋΰȌ"ΰϋΐη€ΰ‘ή³§ΈŒΐ”:|U΅ψ+ͺρ—α.(Β,DυϋQ\n΄d‚ΤΨ‰Ρaα!βƒ}D{;λl'r°ψP‘ή³F_ ½Ο‡6<†Xΰά Bu‘ΈέΈ4Μ‘xξŠ!8΅Εε<…H)3ΝΫΛ6-™d΄½™‘¦m νΪDΈe'‰‘a΄D™JNζyΎg¨‹ψϋ xuΐ7 ύžw(nκ:΅υΝ^Hρά%k¦ε9ίCKΣΓOΖz33ΐhGƒ;ήb¨i+£ν-DΆ!“‰‰.σ p°ήπ8ήgΣ8ΣυΛ χΜqψͺj©Xq2%󨙁―¬‘Πyh?©–γ2B.cDϋ{ˆt΄2°ύ-z_ޱφρ.Ÿή4TΓ_ήg(~ œ(y ΉK˜~ξ')l˜‡βρ’ΪD{šΰΒYήƒ!ξΗ₯¬I- e^FŒJ&Ρbc„[vΡϊθoάωφxΟ­> ώ_d—ρπΏΘK7·—’ωK™qαη(˜>ΛB@σwE8<”±_Όƒ•ίνͺ@“κb€½™}ήE–ΧΠβΡρ~ζRc$―0ΐJΰ`•ΣNwa %‹VQ»φΫ…‚P"M|ƒ„Γ1ώ£ˆΓ|R)Ηχ rˆξΔ &FΠ’IF΄°ϋ&άΌ3―½ |πέ° ή-XjψΒSν;T_€šΣ.€ξŒ‹M3]A(:ρӌ ¨ B(+QŒηκ §‡΄:€f7PXfΉtΨ–Ÿ!ΑΆM“Άγ₯€νΟΏ£υOΏE‹9ΪνςΦtΈΨ@ΔrD~ΡμEL;οS„κfW3DŠ>Ϋ³ί³6€2Ϋg&ψ‘x@iϋ&σΉ„6U`g)­φ”’ΡϋΨ{οm l}=ŸJψπΗ£Eυ(?€› €'K—‹Ί³?Ζτσ>£ϋρ‘E«αϊ6!„…ψBθ€:©υ#³eφΩ>˜Ύ?›Ήž}ΏΩ½φ„@ڏ3ώ!ΜΖ©ι! ‰clχSΎό\ΫήpBD/2ώ>ύ?I¨ΐO χΖβΫ+³α;5.Π‰«ͺΆ™.ͺŠ"Lβ?ν/M'xώžλ!δ’“υRZUA>›@3Λ ι¬$ IιhH) ·μbΣυί$8aΏΎdIοi ΰn0nΦςΎ}•5συλTMΝκwC€§ «*ŠN|! ±o"zϊ{f6ڈ,²ΔΆJ‡Cηvar7ΝRΑ"),ΗgMS‹:2Ο~ΫΏV)α).£ϊδ2°γ-»»(€聱G/8 ΰ2ܘ―Ωw”³’ΩŸϋ6.0cΡ#ͺ" KߐB €1γ ΡnP2ΝfODτΌ„uϊŒγ>:26Ζ0« ‘•vcΥTΩ~Pυϊ(_qўN"ϋμ·qœaKm0€Β{Nά |ΦΎ±rυ:jΟΌ—ΟŸ5τL.žPTTE (ͺa€UAΦψΒ€"„U΄ηϋ‰ Ό»7ΧΧwύ5!e/^7nEAΩ«iR’Π4FI†’Iz#1ϊΖβ™}•6M’¦i€bcμ{ψnڟψ½Σ-ίόΏχά€Ε³\sΚΙηP{ϊ‡3ΔŠͺz†NW  m*ΒμͺFΙΊ‚bόn]G–·ΝLt«œ8­‚²€GWWŽ dŠHHIdJ²oh”ν=Γf ­Τ [Α‚€RX{χ§ΫΉ ψφ{Eό?ΰ9Δ?υ\jΦ]ΡqŠ0‰ς΄0ώώ.„@>zΆ‹ hFΝ\œ6ΣφΔ‘ζώ΄q« ¨―’*θCK&I%βDΗ" φvΣέΎŸήƒ"5 EQ‘RC.EΑ­*TΌΜ―(€ΆΐΟ`4N4©ε¬Υ‘(Μ‡βr1Έύ-ϋν¬Ϊ€·ήΰdτ°¦Ο"φלAνλ„TΥ¬_―*(f5 „Ύί$¬bίP ͺ~<&§‰’LBΌ‹Γ4•ΣD©-π³ΆΎ’Ν{ωΓν7³υΥ—ιλ:˜WΥ͚ΝΜ‹Xpμj¦ΟžΗ”iΣρ‡ ²hOxŒΝƒ΄‡ΗΠLƒ&³GK₯hώύOiς~ϋODΡΓθΟώ½ x(3o,]tuη}Υγ5«»uΒ ~Ϊ΄Ί~ Bdχ EAU…Iο›±tΜ0o·©ŠEA]l#K¦Ψ78Κh"e#Ήΐ)œΦΡ+kJ˜SβœAΛΞν‡τ²BEΕΜZΈ„e'žΚ)\Lΐ`„””΄ Œςr{#Ι”£:H%μΎλ&Ί^x~Ω>ΰX ωέf° =;35Әύ—λώ½’>½ξΞ‘(:ΈcόΕ ͺ’€™D%λώ©ͺ)`Βξ£S,ΐŠκ(ςΊXSWN±Ο9NJέΫΥζ­ΞΑ π€,K¬ͺ-%8άΓ7.<“±ΡΡΓ{qn7Ε₯œχ—qΞ§?ŸΞΙλ›»Ψ?΅0A'R²εΖa`[jΈX|8ξα;±nΞΆg.u ³( xiŠΣ΄\7ΣtO#ρ$M}#€\ΌΕεDT/-αΫ{†ΩΦ3DS(=q LΡΐŠU«9vνιœψΑσθg^ηIΪ΄ω-zΊXrΒ)ΈT•ΕAφ1šHζάOAύFΫ›‰t΄™w•τ/G‹VΏ΄θύ`3>|)ͺΧ—qνΘ0AχlŸ΄ȊvΤτρ±Ÿ ¨BΙ°b‹ͺŠ)φΊyθ—?₯½yOΞΓ€RI{{8ώτ³ρx<”Ό΄ Œš˜.WF¦C}cq„Η88e š`4‘"–ˆ₯4Β±$]#QšϊGΨΦ3Œ¦Έ™V[ΓIgŸΛΌ₯+ΨτσD#9Aš·oΑλχ3{Ιr„P˜V`w)Γ(4?_Ŋ“8Έρ1R±1σ%V‘'ήtipO\–S?τ)όS¦fpz†·}f›ΐΜ$i£/ύucOΝ} @?6-E”q ―/‚U5₯tνkζΡ»IxΘΩοξhgΪ¬Ω4Μ›OΠ­M¦ˆ&²ΜG6Ύ‘x>7₯~ε~/₯~₯~E^7! ―ͺ Iέ΅ΛΒΒΠ‰±£7L<•’±qη~όΣ΄7οα€cnωΫ Μ\°Ϊ†™ΈŸKaίPΔJNΏ€‚†Ήt½3αO~6™xΑ‘0ΐ7šηDΙ’ΥT{ͺiF+&b “QgHƒ ρ1‘&β£»~a λLƒα)(’Ϋ³ƒu&4©+ς³υ•—yόΎίŒϋ`ϋ››X{ή‡q{u‚Ά EHj2ηwn«jJYPYHciˆiEAκŠΤ˜V Ύ8ΘΤB?S ”ψ=D’Ι€―o,Nϋp„’P€3Ο=d"Ξ·ίΘQOolάΐq§ŸMaI …^7#1FβΙ‰δ-­ !άΌΓΌΉΜ°^:R 0έΐž+²’Ώ€iη}ΕγΝFιLβ\1AΎ/RMGς’ °1Δ~Fη›@+TlΆ-¬P0™λ(ΐΒΚ"hΜ΅8žΗ­Cυ1„y­LP:νZ―NZΒ₯ŠPH'Ψ[B―i‘#υ‹Iۏ΄ ˜qRŸ‡2Ώ‡‘π0Ο=ώθ€8όž[n’·³C‡o ό”ΌΥ5³$DΗΝ@o·]s9Ÿ\³„sfΧpώ‚ιœ3»†‹—ΞζΚΟ~”'ﻇ‘>΄T ·"˜^ΰό9΅Μ(Z&―"ΟΆφ°oxŒKΏχ}ζ-[™sOϋvncύύΏ`VIˆbŸΗΡΣ ΦL§jυZrΞ1θ&ή©(2ΒΌ₯ζĎͺ5g‘z}ϊ Vlb9Χ7λxΕj’@˜ >3v`¨ ]μ§·₯SΔȐj:]̌O—™Zΰα»~Α[/>7i·oχNΦ^p1.UA‚ƒ#cW^HPhάzΥwyκίŒΪflœΞύmΌϊΜS<ό«Ÿ1ΨΫCiU%ε•Έ…ιΕA„€žΡh†ρ!Ψ78JeΘΗι:— άGllΜ χχsάιgγρωπͺ -CGŸ74½‘ξΏm 5fΉ―νbο„.ώΑό£ε+O%4cv֐CΙ1θ2³GΑ`Ε$Ύ³@Kdν!” 3("‹χgkB³‘?λΝzT…ΥSΛRγšΛ>K">ωΕΈένΤΥΟdΖμΉ”ψωlόU΅γοPΰcv·―lΕ)z*­)L&Δ5h.LO'€Θκ}IφX{†―~°q=S­Μ20[ϋ6}šήΆΈͺ˜D<Ξ#wύβ°0ξνoΎΖΛλŸ) y\Μ+Χ#v-ƒ£Έό>ωΟί" ιξͺΛ•ω(j~aϊΪΖυ\σ…OgΰλyeT½–ϋίΪ3Œp{9υόηžτ“Μ`–Α"ŽΫπ‘΄o*3θxX `Ι8Q}J—;3E’ΝxΏ)TιsAW3̚!.i†IY’§u~ϊΌ¬ͺяY\UŒ"ώϊ†ϊ9άqον?f°Ώ„`^y•p,ΙΎV¬=“_<υ"ΧώϊόrύKόώΥνά΅ρUώύ—χpήg/ejΓ,Ηknzωn½ς;ΐηR™SV`€6DJcP„Ζ…K˜>{nŽ—’Ζ ŠΌn܊°IΙμ…J,#P3}\:NΦ8ψ–9ΰSyόιψ«§™rݍ ^EΨP;kτ/ν"eν=&q‘ »‰¦ύ―°$Υ SˆWH«ν 0RΔMΏΉ ’—€Ÿ|Œ=ΫΆΌγ4§~υ3φlߊ0²€ΚόΊ 6K²³/Μ›Όupν=ΓμμζՎ~ώΤtN||ς«ίβʟήεώσ=w6€Σ‚ŠB‹ˆ&Hh’Ζ…‹sκμz[_4’(‚BŸΗ63€Bρά₯xK+Μ§» zNZT'˜7Μ:Εγ› Œh'¬sf‚Π‹°ΣͺΑtΌ4έ΄ωχλ Τψΰw·ώpD――η’‹.’€€Δq/FΖ³˜SBM[ά·3ΓΞ$5Ι–ža^λΰΨSΦρΥΌ1皻7½ΕξΝo!€™%!+³Hατ9σpΉέ6L`{FE\jNζ“ω:ΑΊzBΣgΫϊ 37Μ³?BͺŸpΉ³ΖYf’ΛΜ§¬z3‡JIFοIρΣ³άβ)€A²Λz2γ)€­Ώͺ²°²EξώΡ τχt[~ΏΌΌœωσηsζ™gςο|‡{ξΉ‡Ν›7#₯€ΉΉ™ϋ￟―ύλŽ/aοŽm<ρϋ{¨- PτZ$\¨2I+ y`„¦ώ0k/Έ˜Ε«OΘAψή|αY’‰„ΑΌ~ΛώhœΚΪ©¨ͺU˜“OάͺΘ™„pLQ)[Ό a½ΖLƒ2€‚^ΐ!SiΣST†·lŠIό›W9š]>iςϊ€ΓvΣzi^±cΚαK3“0y–H\ϊ&τνΛkJ(πΊωΫ†'ωσ=V±λυzΉζškΨΆmO<ρΧ_=ψΗYΈp‘εΈ+Έ‚Ω³g;Ί…ώζWτuwι‘Πκ‹τΙ†¦ν‘IΑΆžaPTΞόΘΗsƒ=―ΌD<C€•©€hR£ €ΤΘ¦²Ž¨ΑḘΕ)_v‚ΤYƒy§ΪiξΔ>τr«Yp₯l žΒ‡™-ssK€u»Ε]3˜Aš¦‹0cΖf)€°@ΚNίΌς¦ψθναΦ«.ΟyΒΒB–/_>qHTUΉγŽ;ςJgόš¦αs©,žRœƒw"°θ@zφc^ΐ(s‰/XΓ±‚†#X%CFŸšDŽ N˜VIUΠG<εͺό4½=y ΊzυκCb—ΛΕ—Ύτ₯ΌRγGW~!%ͺ"˜]VΑ6,F˜1£έŠ Δοa¨―7[°¨§B}RΔ“™σ§}ΐΘΞΤ3η/Μd ΗγJWs@Ω’γν?}ζD °ΘΒu3σθh¬ Ω,T—&g@Ϊτ”΄zΆsΝ’Γr¬« ΆΘHΎυρ ιάί6.AW¬XαΈύΞ;ο€³Σ)œ7o^^)Πή²—?ήρSέzx©ω—” `ny!nEΠήΌ‡ξŽ–ύSgΞΒνΡΑœžH,sΞμ²Bš6Ώ£¬Τ!ψ”&ι‹;€Βα}–,Θ1‚gŽΗΛμGͺκςΈ}δΦΞφ›΄0ƒ4XΜM AJ€!ϊ+kΛ™Q$qγwΎΖξΝoO¨Σ—.]κΈoΛ–-\vΩeyΟ½φΪk₯@*™δ‰?όŽξϋ–Uγ2­HΟΎς ‡Y%!β±(λόCΞuŽYy\Ζ.Hηψ]*΅…~Ϊ›χ8f /=ρ0 ΛI!π•’ϊsμšeωΐ2e\‘BόqZX)€ š•Ήζ€ΐŠ `υrDWΊ‹΅ ΙͺΊ Λ Aΐ]7ώ€ ί?α³/X° ン;wςΘ#°qγΖΌͺΰφΫowάΧΆ·‰'ψš¦αQ–TΫΒΥzφŽGUΨππύΌφμ† }Ω §ΰφxθGI * Q…`Η―q°΅ΕrNmύLΚ«kšϊGrΌq'‚ΫMpj=ωθ<πUΤX ^i.…)-ޞէ—6ΠV\ΧΤ‘ΐόU½OkœJ}iΈυκ+xh’aή“O>Ωq{*•’½]/ζ|Ν5אL:/§»ΰ‚ 8묳χέχΣ[ι5άΚ?,˜£ Α” Ά=»Ήνκ+rΞm\Έ˜…†8ίΤ5h‹JB$bQžΈχΧΉQΉ3ΟΑγσ“ y`4g–›SθrPAΥE zZ^;ΟΞ8Μ[VePZδΞ~‘;/Bζβψ2ŸΥgrQ,ϋ9οRd$KΘγζόυT†| αΏΎωeΏχ7“–~'œp‚σ nk#Φ}πW_}•‡z(― ΉςΚ+ρz\Έθ·ύϋ•:h£]w“……UEaύC$ec.—ΗΓ™—|‚Β’RΪ‡# ΕxT…eΥ%xT…—ώς{m¬@A!KŽ?UUΩΦ3δh7Ω•°₯’β―¬ΙΡBωΐˆφ”T8išώLDO:ΪV/ έF˜^Zΐ™σ¦ςΊθηΊ―ύΟ>φ(‡2Ž?ώxΗν­­­ŒŒθj$α»ξbxxΨρΨεΛ—σя~Τqί }‚ΧŸ{„ξξΥκΘΉ†$šLQZQ™sΞͺSΧqζG>A\ΣΨήF“°¨²ˆŠ —žŽά|Ε7rΞ™ΏόX-!%a{ο°³AN7’• Ύ²œ{©ΛΗ–ΖKξΒ,±€=X§ε{i_]δΈ{6n•9Ό„”’BŸ‡λkXS_ί­²oχN~πυ/ρβ_mΡkAA΅΅΅Žϋφοߟ‘?ώ8/Ύψ’㱏‡ϊ§’΄΄Τq­Χό+ΡΡQ!2 )M6α΄‹.aιš“²ι¬sψ֍·θΑΎz#1BsΛ β_½4ΗςwΉέ|θ3ŸΗνρ²³w˜±DΚΡΠsƒΜDq μ!·Ԙw¨―9ŽγόΓζ`ΣJiΟ΄²lZ\-­«ΰƒ κ©//BUy€+?)ΆΌϊ2‡:Ž=φΨΌϋZ[[‰Ω’:/ΏόςΌΗ―Z΅*/D|`_ Όγv„ΠW Υ‘θz:αφsŏΖ-ό•Ÿόi=_Ώξ‡x}~Ϊ†"l1DyuHχϋχγ›hΪ’›·yΒYη²hυ Œ&Rμ±dXΪ'W&³ΚT_Υη·#‚5v˜―­Έ½SR‚f­sκLoι¬ϋ-5υM8TΉpΙ,–N­ΔλR‰Œ„ΉεΚαΗίϋƒΚdΖ²eΛgD"‘ ­-;Ψ΄i?ωΟσ^ο†n  9¨_Ιϊ‡ο§΅i7K¦”ΰQτυ-]μ‹ ¦4Ξ₯¦q.ΕΝΖΦ^jﳴFΓΓ4οΘνIYRQΙW½ )υπςΐX|B—ΟL3€ϊόvHΣΫΒ˜RďΧΨ­Ω­:kQeι”χa3τdΎ/ϊ9qΦTΞY8“bΏ‘α!^xς1ΎrΑ™¬ψ~Η`ΘdΗςεΛ`dd„}ϋφ9žσνo;G2€G0δ–[nqάΧΡ„ψ­ IDATΊ§ϊ#)C|―¨Ξͺ‹½aίΣΙ£»;ΨΠM爡(τP,/€ Ψiυψ|\ωσί Ί\τEbΌΩ9χYeΰ%klK·ŝΣV±abpL=•NŒ—uϊ€σ±ιo>·‹53§rζ1 4Vι/λ•gžβΖo37}ηŸίQ._ζΙςDGFFhjjΚXω%%%ΤΧΧ³tιR–/_žψΜg>ΓqΗηΈοΑ_ύ<ήL)πeDϋD£s$Κp"ΕΕ_ψ2σ–­ΐ0΅a_ϋΑh˜·€xJcc[^0 »­εŒG,ΐ‹βφΪ+Kλgΰ2Η€e.t›ι±“ΝΧ#*Άήψ1΅ΫP‹ΧνB ]ΪωՍɖW_&r$ƌ3ς¦x©ͺΚYgEuu5uuuΤΤΤP[[Kuuu^CΟ ΰ\}υ՜sΞ99ΨA"γ–ϋ.7έχ0Š4–†θOΙ<Σ&;žoλeνόE\~ΛŒ α)©¨ΥHs—Ύ 4F"Η“ζcU"7m½ΤΞ–wζiκt‘/dϋ– m s₯}=©£4δgνό* C:Σ€Rάϋ‹[xΰ—?εH3fP\\μΈoΚ”)άtΣMψύώΓΊφΙ'ŸΜΉηžΛƒ>˜ /Ώφ76<ς8ο"*ƒ>ͺC~Z‡"ΘmΗ“<ΌσsΚ ¨¬¨a hιb{Ο©<έ©Δ8f˜c(ͺκΔA;$œUŠΜ™Υ  ‰*Eήg\8΅ŠSζ5 „`,aΫ›―ς›ώνm1žPε°‰ΰσωψβΏΘΣO?Νΰ`n½_\w «>pΑ‚Bζ•Π#IηΰˆihR·Άυgͺ—7£ε8"ΐ©¬­¦I§c]vΐRWF¦’™ I'F07Ή”eγfΥ!₯dΣ+/qΫ5WpΓ7Ώ|Ԉz¦―ͺ½6kΧeέΊuŽϋz{ΈχΆ›(τΊi, 9yX­Ζε8ΑΆqΟK%:œFν 0Œ©½–ˆeV³™ Π4Kηδ܁ΎΝ₯(ΌΊq=·^υ]^έΈž£9‡|^,£©©‰ 6pηwrΕW°iSήυ”ά|σ͎™CRJž~δšξEέͺ%αΙι[δ$Χ’O έqΪΥΘqμ ™J YνiΠΫ’FΜΧΠβQSΞ^φ&sτ$!AψX<£–h"ŎƒέD)^ΩΫΚSOγž[n"<8pT  :fχšΗΨΨ›6mbλΦ­lΪ΄‰mΫΆΡέέΝθθ(αp˜‘‘b±λΧ―ηΥW_uΌFMM Χ]w_ύκWsφυuuςΔ}ΏαΛ―ΖερplmO·tOzζΚI|?Τ‘Εc¦ ½U»ΧP΄ξ BU©ΨWP}£Z§ŠPΣΛΌU£Τ[ΆςχŒŠ2Ξ_uŒ^ΑΑΑ0Ύ΅!>Άzm[ήΰ_βQe€ϊϊz6mΪDAAAΞΎ––/^œ‰γb Š’πΐpώωηη•+V¬`λΦ­Žϋoyδ―ΜZ°)%/·χΣjTχH₯g­₯μ[V-˜k§d6+M‚X"3ίΣυ…5‡Κ’ϊΑΓϋv³νζ+ΝM($°xΩ¬0••©š­rΊΥ]\ϋύœzΜ,!2ŘjJ ™S]NJKρJs;‹Ž;ΥλΞ8ͺ PYYιH|€_|‘p8l)»:ξ¬Ρ4ΏώϊΌαb―ΧΛUW]•χό[όŽa• f—†πͺJΆ©”ψfι`ιSΗΜΌη{€ΤX„Tt,Γr Y–Τ&ΣU0d~#P‚Ε3j( ϊhoήˏ.&έzΌύδΉυ¨ŠBsW?Γ|βΛίΜH>cΌ5oΎωζ!_oΣ¦M<ό°^°!άάΜsΟ=Η}χέǍ7ήΘc=–—αvo~›‡ά…²€‡©…ώρmqτD~~^V6=56j/%§9Ω[0εŒ%{bΙ2υ“’ ΧΓ²=ςvύ7ΎΔP/―<ύη|⳨ŠΒΊ3yrλήj;Θι frώg/εw·ώχQa€|HWŸOd/\uΥUάqΗτυυ1<<Μΰΰ CCCyacσΈχ'?βΔ³Ο£΄’‚Ήε…΄Eˆ₯ςxr|ύŸ―_αdF<ω$―Ώώ:»w僚»{RΔ}]½?Ρ™=δvιM&€“x—–ήγ»|27oιMhUo2•"Φ›―˜Ρ?σ°4‹υΜ#g4c‡ΰ˜iSθι8ΐ OfcφΡ±Hf–+B°|F Š<½£…’ΚJ֞aΗ’οοdΈέξ0gπ³ρχnŽ7ž{&'(φΉuΆtΠν‡’ΖΫ!΅‘Ξvϋζ& d―X §ΣI­dάΏ%!xι©'ˆΩŒŒΧ6nΰ­—žgٚ“˜VV̌ςZzynW+g}δ<ύΘ΄νΩ}Δ^τ’E‹θμ쀩©‰ΞΞN:::Ψ³gΝΝΝlίΎMΣώ. 04ΠΟ@o/΅ YΰάdόiΆJ)“1ώ¬ψ fδQ’%NέΗ^ΙΗ$μΠ’q’#ΓΈB…†  Ί_ο˜Y[VŒ–LΡΌc;αίuΣ΅,Y}ͺͺ²|F5νƒaφtυ³ ΆŠ/^u-ίύΤ‡Ψ‹niiaνΪ΅τφφΧΧ— Οώ½GAa‘Β"b‰|ΆΪ€|~‰CΧrΫ9Β(+ŸfˆT,Jδ`NaωΰyΰιDξ£°q‘ ύΡQπ3ΤίΗ`Ÿσκœώžn½ϋ—œΩ/PQdξ”rΆttσfλAΞ8f8Γ<=‰οɌώώ~ϊϋ' €¨*ώ@0ˆΧη#XXDYeΥ5•–QPT„/ΤΧα/1‹Eb ―—ΑΎ^ΊΪΫfl,B4!6q,I·όδ0}Ξ<’ΙŒκ£ωDΪ<—Άξ’NΉ8NϊίΙ0Œt΄:Y―ŒΗ˜`΄m―Αi¨Wη0!%.U%2:L$Oη ©il|μaŽ;ν ͺλ¦s|cΝ=μλd0½μ«ΌωόΖΓΞό™μ(,)₯aή|¦ΝœM]ΓLjg4P]7ςκjΌF¦LBӈ&RΔ5IJ“hR›)R₯T!πΊ<Š‚WUHiέθάίΖΑύ­μίΣDkΣ.ΊΆ†XΊζ$.ϊό—B°Ή{ˆX2•3£₯ΝΔ·ͺgγOζ3"Œ‚ώΝ9ήOS%q'x ½α³nΠuξ·ϊ"ΆœώT2™“ώl=ΨψθC\ς_Aq©¬_ϟ6οαΝΆN>΄Έ‘ ?wΏΊα?Ž8Ρέ'žυ!֜~6Σg5R\VNaIiFχEβlˆ2 3–LYf’&s-m]Όf—Ί —B©?HιάΕ·d%gψ܌ 1γrΉ)­¬"–xq/ϋŒΠ°”Vqnεš@5^φ]N—r“ώOν{3'αΥ²Α‰z @¨ •ˆ1ΦΉ”Ίœœx2…ίγƝ›ndϊ희=ΓTΦN₯ΊΈ€†ςbφυ ±·wUkOηΩΗfοφ­G„πSλgς‘ΛΎΒIg}Ο‡‚xJ£k4ΖζΆ^ΊF'N<Ιg.JMf01 ±TŠ‘X‚–ΑQέ½“’)!Ε>}ΐv αsυ·ω“Lν“Lh$ΪOˆφwΝuŸ°¨D'ό½€˜αG&‰h±ήΊρ«½C#B &Fχ~uΓ΅€ΎrfQ].UαΉέmψ ‹9ν’KrͺbΚ„ XΊζ$»ϋόβ©X{ΑGˆM£<ίΦΛγ{:yγΰG£hΗϋ8.š”NoλŽoOKƒ#QΆυ ³Ήg8‡ψζΩοϋζΆ«Νz v{AΛ—h:ͺϋΕ§Θ£βΗe€8zcˆ +Žu@K$,w%₯€΅§’’RJ+ͺ&$ζW^δ₯§τΖΥE!*JHj’ηv·ρσ.’ρ˜E‡EόΥ§Ι•·’Όσ^¬ZCS˜ηΪzxΆ­‡M]CτŒΖ2’u’@fΏρWӌOΖ³Fς₯4ˆo–Ro+m€OŽΫgαΪ$\?μFŸΜ˜Εχ«s42pp"xΘδ3%ϊ{ˆυ’]#¨Aϊ‡‰%SΜ^ΌtRIχώδ‡Œ„υΥ-+gTγΠΦ?L[ί0_ΌκΪC"όŒΩsΉζΏα›ΧίΜ’cgοΐ5uπvη έ‘ρ”>o4cM£&ΙΞΰ̌Ά~€‰€ι$šl‡•πiCΡ²MΚΌM'ΝΔΧΖ ϊ`²ό-†"Φ¦Σγ©*! Όo7±Ύ.ϋη Ÿω¨Φ \LIγή²)xΛ«3mίΣeΫUUeΥ²%lόσC$&€H£‘QR©‹VΗ₯"ύ#Œ%’,™5™ΨρΦλγ^Γγυςι― _ώώυLm˜EΑσ­=F_p©Κ£Η_O;^N–Ή6CšτRΪ%ŠπΙo΄<˜@>ΰΗNΆή ΡUlΓ# ξάd—!Χ“{BЌ PfLΔ(hX€b,1Jχθ ²¬±EJvΎύΖ„3w §›ˏ₯Έ¬œšβvwυΡ;₯4θgεΚ•<Δ£Ž } ]Ν•·έΙΚSΦ2š”lοδΕΆ^"ΙΕ~7υΕ!U1«$„Ο­fŠ/ΌΣaυÝcΪ[½:ϊϋ2Ϋ.>½=eϋ·f ΉHξofΎΌ­ξ₯$12LΫcΏ#ήoΑgΠ|wM†‘―₯ΥDrd˜‚ϊΉΈ‚‘L‘D„BR79cέZΆ½ρJN‘FϋˆŒŒ ¨ ‹W­A( •…Avμc(㘺)ςϊ³O[aήUΗσ©―ύ Ÿόκ7 ±₯s€ΧΪ{ιQΰq³¬ΆŒ…•%L+ p»πΉU*^FβIΖβy2͝U>HΫLs4Θe–@šΣL•Φ@S²§ž6²μœύώ ƒI«ν`7„α¦mψλH-ewnΆ °ρ œd δk‰8‘†y–bΡBFǘVYΚςγVσφKΟ™ π²wϋVVžΊŽβ²r7‘D’φ0^·‹%ση³γ­Χιλ:Hqy—ύΫςα/|‰†Ήσiα©έΨ?!)%ΛjΚX3½’2ΏK!ράγRP\B T@uF¦­9o.S§SΙ5γ―&εΈ1{i#Όœ$ρsτΌ¦Ωόxλοh8„f1ώ€£ψΧ’ <υ α½;μ‚γ'v `"=? Σ-$>ΠCΡά₯zWp²Ν ’š€7aιœYΜYΈ„Ν―ΌHt,2ξ…›ΆΌΝi^‚PB7Ν}C΄φ³dF-…E…Τ͜ͷoΌ•sζ• Ο·t²Ή£ ¨*pΖμ©ΤQ…XtŒ{oύoϋκxξ±G(©¨bΞε(ŠBΘν’=<φΞT@†@2#¦­sINˆΧΜ|ι‘΄œTφsΙγφ9&•˜˜!>4ΐξ;oBZc!ƒŽ‘Ce€vΰ1ς΄h„PύάL1B‘ׁg$£/<Κ1sYyΒ‰μέΆ•πΠ@^·k¨Ώ’ς ζΞ'θυ0Σ=‘4Κ±Λ–°θΨγI€ΨΩ5ΐ3{:ŠΕ¨X>΅‚cλ*ρΈ"α0―<ύWώΒ§yγΉg2•΅vo~›SΞ½`¨B―›Ρ£&6ίB&'έ―™όp§τzϋ¬Ο1ψ€έM”ΨΏυ\)s±ώρCΕΩ#φ?~/C;s2šο«kL&~/zΏ@έ%"8½54ϊ(™ζOƒ£c΄φ2΅Ί–s/Ύ0Ht,Bw—γ…·Ώωǟ~6ΑΒB’–Ύ!†’qφŒ°p”]ύμγs»X9½Šu•T„| ΰυgŸζξώάq[NΞD<ΖP_ǟq6B|.…Ά‘ˆP\΅ ν(έΈΜ!ΕjζYο σΗCϋ4‡j*V¨K€ΟξχΗ‡ΨqΫΏg“7³γbŒ,ΰΓa€}ΐ0–ΙT ©i„fΜ1'Ξ6z‹'iκκe(γ§œΒΚOaΩ 'S3½žPQPE₯₯TΧMgΑςc©­ŸIIy%C#΄υ‡AbΙΓρρ”ΖόͺRN›3ͺ‚.EαΐΎn»ϊ ϊΥΟςφΰΨ·{s–,ΣΧλ’?g(–ΜΙ‘Χ쳋 rμf|ΪbΧ€σ,•†Ψwς,œάD»ΟŸ›<κΐΖRtχέΏΧ~ΛO?:<§8;.2e³\ώ5g]‚Ώͺ.Ϋ6NqΎ…ΎM(UaιτM―¦ΐη5šDaκψ‘ …ΚH<Α†­τG’(ͺb€‘+Tψπ’ΖŒΊωΓν?ζΑ;n'™LLꦫκ¦qΗϊ—υΞ\c16΄tgͺrš(ρyΒε„gˆŸφX]>iΒΏφλΧ·μbΣυί²·”½4lޚz“]C΅ψ,zgj΄d§ΝΚΰB™Z<€π(›χw±³³—ƒƒ#₯}p„Φώaφτςv{―νλd,‘B¦ΫΞ%؏©.§Ί0ΔζW^βκΟ‚Χ6n8€Μž±Ράσ—―ΔοV‰Δ“τΕ-έΞAmόš‡f¬i–j'f}oΗμ5r%΄fŠ―H›Ϋ‡έ#°ί`ˆd’½Ώ™Σμψ)¦tΓe€¨qο™ΔώXοA΅ Έ ŠMͺΐhKΆ`Ί€n"₯1Σ;‘7‘otŒώΡ‘D2kG€ΫΚR"₯IΛ ΉηζhΪΊι°fnχvVž²–Pa1SB>φτ’He [8}ςGΧeŽ—n£}φZfΎf3ψlH‘΄Ε4ιμσ›ΥΐΦΧhϋΣ=vΏ?aΰ8γζέJfζν†WΟ<œσ‚°ε€eΈVš Kαžd.Lι Ρ:<Κ%_ϊ>ΰ°\Έƒ­-<υΐοI¦R(BpάΤ² —YetΊι#Νž)`'‰&₯eEYδkZ~ΥAši&C|©e₯CγΣλ·YύHIϋSυβ_ν?™0άχζ#Ιiΰd ή¬ |•SριmND¦'°ALSΛWL-M„±†0ΣΦΖi=ΠτJ’gžq:ϊ£cœ`2ͺΰ`[+'s>Bό.•–‘ˆw'g­ύ ;n&‰ψΦκ9Δ7―π5ΑΟNFŸYv5meηΟ―szδίaΚκ:’ pπ/™s₯$ΦΫEAύ½•Ρ9#[₯ΚΠ2Šξ.¦=ij>N˜J½α@ΐp4Α΄’fΝnδ…'ώtXR c_3ΣgΟeκΜF‚ύcq£‰Cr³q€¬ŽΧœ/ΰέ<ΡB›ΨΟO|«4ˆυχ²νΦ«Ν-γΝώšq’Εο˜R@'¦Ζ©±QRρ‘ι³ͺšmξdΈsζΞΰi!α3"ίΔŠΘšDQ‹ζΞ¦³mνγψγΒΟ›ίfέEΕνρ’’α±I-»6}ΌXfκ‚#m³^sΘζΝ"ΉΔΟMςΘ²¦–H°η773άδ˜Fw1°c²οδpKiΌήWΘ’ ά₯ψ*«³DN2N‹ϋLgiύ‹’du½J¦•\š!±)=#QfO)£zΚ^ίΈxμΠ‹JΕ’cTΤΤ2sΑ"b)Φ‘© Š.h6"勝K™ί˜tb&‰}¦Ώ"(ΓJšF糏Ρώ—?:τ ΰ‡B9Γ]Ÿ₯—b[M|πι 7ο4ω:δHΣf-γog#kbI[J JέnOσ–­dε©λλ¦SΙ$ΫφefXRΣHIIJΣHiZΖ‚·d ερ ΣLIύqμΐNΪsΘ|LΦΎ”ω“D2ghZΖ{BJΆ½ΑήίΜιρv ‡΄κ,ΠΫ |SY€Žυχ3ޜy*™ŽS›tš΄δš^Œ}‘#2cυJ Γ£lνμη‚Ο]–S\q2C( Εez#ˆH"eT?΅₯fε1ζ€ΜiΠ —N³…3£Μ%Ό5νLfήƒf~~M³κ|Ν½’αζμόωuΘ\$t ψwƒ&‡4ήi5₯-@z£Ι *9ΨJhZ#.0‹¦EΊαJ“‡€PC%€Μ΄–Ωώ‚†­Π=eyc=—ΚζΏ½pH7λpΩΥΧα ΩΤ=D4α,i9υ±₯mΎSφŽeΥξ"ί9±3KόΘΑύlωΡΏ’ 9=Ϊ-ΐM‡Cΐ#QNλ/4fCΖcD;χš9Υν1 A“‹§(ΩbΣL ΫMάβ€[ΣndRΣ“/O;εd^~ς±IΧRT•«ξΈ‡ΊY³ι‰±Ή{ΠΘfΚη:NdΚρ#{¦ vBΛI“ʊύX_7;n’έœnε^ΰŸ8Μ2BG‚€ΑΒԜ89:Lδ`Α鍸Όώ Τ›mjΕ,Ɵ!"¨X?/S[bΑ’Ε<σπΗ½9ΧΗ²“?ΐ5wώžΊY³ι‰Δxε@?C±δ!?$688oŒΐdα›s5‡άΒ|IfβοϊΥ οu4μ_>…C’Η»Ιiτz&q¦8^2Ž^Ψ‹Ώ7t›Πσ½YIfdί.BSgβDΩΐ@ΙΈ’*Αμfςψ„ ,ΞΤβΛVΛP­»³3€¦~&ŸϋΧοsα₯_¦~ώ1μa}s7να1’f€ “Ο.ΘΙœŽξ u­ΐΈ„wšυΖΆpΛ.Άώπ ƺڝn‘ί€δφN‰&8ςγtτ¦–΅^ŠΟό‹.%8΅!SbNΐ‘’κ£a(ͺbΨ zI:„’)I§LEQ9wώ4¦} φvΣήΌ‡’² κQ}^"‰/μο₯#="z.άλ”β”ΐα<λ₯ΕΨ“R2ΈσmvώμZΞ_8ψλ‘ ΦΡ`€γŒH”uΑŸԜφaΚ§Χ―7\7UD:!Δ ²•PtFЏ׏©/-€±’„€Οƒb„žΫ†"μκ [TΪγŽGpˆŽtޟ;λ΅άZΜR"S):_ψ {οϋ)Z<–ψ'‰™΄τ܁?…φΕΗ¬€ζ΄‹pωƒϊB“tξ€bšυFFQ*Vt¦ΒX€*¨—Ε€Žσ5VΆ?όδ’C₯γ>§₯Ϋγ-ωΚ'ξAΟζmώΓΟιώΫ†|·;| xςHI=Š °Χ€ŒO±3A΄»ƒ‘έ›͘ƒ+4e [+Va³Θ‘² Š₯ͺ‰Π‘]‘ǐ³Οb#Κη΄ΟρxN/\λHtc݁έΘΛθϋΦ&Άίz5ƒ;σfn΅οHh3θ©d€U˜˜θ€}o<ΛΔ_5Υ¨fr3Ξ€’5-νΉ³YGBQτ¦BδΙ‡(ύ­+p'£œ ω™¬ϋ\ΖHΕctΎπWΆίr‰‘‘|·ςzVφ‹Gƒ@G›@_‹φ 0 ˜gίή»Ρύ{q•β))3_±φΏ3‰ssΎB Έά(Nυl ‘/δ;ή>&+ήD}>ΓOΣ4†voaΟoo‘cΓΓγ½» β·-βΌ €T<”£w'·P+>ΤΗΠMD{»π•OΑeαcΕ”`f°€ϊΌŽζΜD5&rχ&s lyzφτ6»Ε?ΦέAΛ~Nλ£w3–[½+£%Ÿ£―ζ9ͺοώΈψ.FΧͺŽτ([Ά†κ΅ΰςϊ­.£ƒ‡ Έ\zΜαpeύaϋ…VB;|Ω}©X”Φ‡Mησ!Ld;]Ϊ=κγοΑσ5,ς1BΝΊ ([²ΥοGQ]Y0Ή.ΕeΦβθ0ƒ΄.θΚΆ7aώΙΙΘ(=―n€υ‘»IF&œΜΏώΨώnβοΕjΰcΐΨZ֚‡;TDω±§P4g1ΑΊ=Έ$(*Β₯β ζ8KώΏ`B0GAίKηŠρ•†‘φ›ŠΗmo‘Λ«tΎπ$ρ ΛΰŁϣv’ο&ώž …θy†§α\΅Lη–@ΪΝ[BΩ’UxJΚqω¨^o.Θσn¨)sτ{l°ή7^``Λ«„[›HŽ Ot•$ϊ­b”o·Η{γ\τ… §Ž{ʂβρœ>‹κ“Ξ¦bω‰Ω–hf ΑΤ:ΥΤOυˆ},•<Μθ]ο[/Ρωά㌴6‘ŠE49gΠΧμ=ϊχ|ιο%(Φ_1€I’Λ)_qEsα"\.έ5΄23@¦γ%y‹t7:¨Θd‚ΔΘ0CM[ι}νYΆςZ…θ €‘Ώχ ―1@z π臆Ϋ8ια))'T7“@ν ό•5xŠJqB¨ώ._ΕλΣ™Γν15Η”HMCK&Πq΄X”TlŒdd”dd„ψP?cέDμc΄½™XΟα<ΣfΰλθuzGί+/ϊ½Κζ±ψ7`9ΰγσ…ͺβςλ  ϊ¨―N|·G—B©‘₯RΘd-#‘ŠFHEHŽΨ«mLvh†?π}`ύ{ρ!ΌαΪΉIDATεώO`€τ8=ϋeαF–ΌGοsΐpγ^D―Κ±υ½όR'1@z” °=⸎wΡΜ7RΖ xέ`€>ήGy†$8ψ°Ÿ *ΏΑΟ~γ7Ο6ξ!π?qB‰…ŒQ€^ΰς` z… Ώ!%TΓ†HwJ7wL7Kηs¦ŒΟπzxϋτdΜπ–YτaΈ€:τ5 e†—QˆžΐšΞ_Œ‘^† +½=’ΉŸw{7ΗEν·HˆMˆ―IENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-info-256x256.png000066400000000000000000001245031524161175200266400ustar00rootroot00000000000000‰PNG  IHDR\r¨f pHYs  šœ IDATxΪμw|ε΅χΏΟl/ZυnYrοŒ±1Ձδ&@Šs/ΉΉ„$oHΉi›BΪMΰ†„^ ‘H†„nΣΑΖΈβ.ΙκZνJΪέηύcgg§οΚ6`=|„¬ΩΩ™gΚ9Ο9ΏsΞοΐθ£ctŒŽΡ1:FΗθ£ctŒŽΡ1:FΗθ£ctŒŽΡ1:>˜CŒή‚ά¨Z€ „ ¦ώ”¨Ϋ€WύN €> WύιS· mΐV`οθ-U£γύΐΡΐ$ 8xElϊ|…ΊMΡύΫιΉK £ϋϋ·ͺ’Θ¨ή ¬ή6:GΝ¨ϋ?|€_ύ©–GσTa?˜Ηΰ%ΰyΰI R†Gν¨φ£hTWυYͺ°©šλ‡ςP•ΑKΐλͺ΅°S΅FΗ¨ψ—ΗͺBΎ«*€Κψ5w¨ `π΄ͺ}FΗΏΚ8ψ°θWΝbω/ϊ3¬ήƒ=κ=9yτυ€αJΥώΖaAιύκ=+Uοαθu©ΡΜN>D‘Ο†'Α)ΑŽΰ †ρFJπ†£xB<Ώαυ#<d:L ‘$•LNθ'ο'Œ“ˆ3ο#ˆΏΧχq3°xxEuFΗ¨8hΗ ΐ§€#€ΩdCnοΚPόABu„ͺκ Տ%2¦…PM#ž`o(Œβ  ψόj PAQΩ¨ PR‚PΤιΙμΒ+3Ήίu{™‘dRC€“€“ ‰ν»ˆοΨJbχ6ν»IμΩNfxθέΌ―ΰ5`5p'°jτUUΣψ<πm² 8΅αρ’x½x‚aΒ ΝDƌ#6~%¦α•α „Τ]ί»Η(₯΄œ/•L0άΧMίζuτl\G|Ηfvn!=˜$“J!Σ)Uɐ1@6!ιRΰ†ΡWoTΌΓ ”_~x „ήβ―¨&XYKΈ‘…hΣx’-“Χ5‘ψό#t!„AXΝ‚kχ™4 ©έώ²HAΞ€RΔwm₯λϊ·of`ΧV’­ vΆ“LHep>pΠM6QitŒ*€wUπNΎΖ¨Όα(±I3(›2‡HΣB5 *jπψύŽBm'ΰοχ(€2ΓC$;ΪH΄ν"Ύ}έλ^‘wσ[€β½βτCΐο€ϋ€gGΑ¨x7ΖΗTS1ΩTά}_ιƒ!ΚgAν’)i™Œ7Γγση}rΑ?PZf½ yΘ]Θμo§οI£gβhΝ›­ ³φΛdH ’θ§oσ[΄=χ(―>Gfhp/³“lnΑ ΐύ£―μ¨8γ(ΰr`ΞΎψΒγAρ(4“šE'P9χ(|αhΑ~_¨Μ=Xaϊ{_Vvυ»iU$Bθ§σΥηΩσΟ•τm]O:9€L§χΗ"xψ†jŒŽQ0²E˜ \,ίΧƒψK+ˆΆL¦|ϊ<ͺ?š`e­­pΘ··,ηοίΓ”δ~()%‰φέtΌτ4]oΎHΆM uοWβΓΐ·€·Ι1ŽQΰ:|e_›8ƒͺΓ—R6uα†f<ώ€&δΕϊs2ώ<(=?€o€ BJwΧΑI1€‡‰οάJΟϊWi{ξqϊΆΌ΅?ΣΉψ=πΒθ+>ͺμF#YDT n_PyΨb—‚’ζIxƒ‘ϋτBgÏΘΘ)Π):ε!έπ³ΐκύxΣί]$@μ‹ W.ί·Η ’τm}›]«ξ£}υϋϊŒχ Οg4©hTθΖW€΅#]A½‘•σΡτ‘Ok„~$f~”+|ΊΌπ‘Ϋέ±χΧη7ϋώt\W __]·]ν”LξΨ{Ά³ύo·³χ₯§I τƒΜŒτςZŸ©VΑ¨ψ Ω Ό? Gϊεπ˜ρTΞ]Hνβ“Υ4jBiVVE`\ ­τΒπaxhοηΓ3(unRژ.R:―τ6ŠΆ €$Ήw»ŸxŽ—Ÿ!ΎcσΎ\ΖsΐΏ“­Ľ*€Q| ψιHΏ;‘ϊc>BωŒω+k²/@ Τ₯\dΣnmn΄QΌ°‹>(kΘΞUΠ, ύΫ¬pτ‚W$N MΒxC1–Et΄υΖvγoϋŠόΈFuFΐ|œš3Fς%_Y%γNϋ•s‘ƒ(Š'+T"›[O.·^Λͺ_PθmVΞ‘(:Ÿ]θMh}&ίΆέώ€ άά§cetμjθL 'WC―H€”€ϊιxεYΆά{#ƒ{[Gϊ^Ό©ΊχŽ*€ζπw&KˆYά—JΚhXφOϊΈ–Ž›5σUAW‘;‘(ΪίB(†8ΌΫj―χα‹]εΝ+¨°QοΉKΰbκΫY9₯‘)Ζ’(^”RBzhm½έ?ΐp_ΟH.cψ°‚‘lBρ/r‡χ-Φ)RπK©:l1 Η,λγ ‘5α΅U^υυ5H§όBuμ'€ίFΨ…‹9/lΜπƒω‘I³ΤXιυ‰ΉοH—}3EΊRJ’ν{ΨΎςNΪW?ΑpoΧH.c+πqΰeFμUΡ(Ξ.&›άSΤ¨^p,uKO‘dά”όʞΎœπ tζΎΊ]y0P/θ6Bλf¨‡bvρΝ½n±έ@)‘R7₯ M.†³’)‰R»i»½ŸΦgΙ”ΣΐwΘ’“τŒ*€CoΜ~MΆFΏ¨¨¨aό§Ο¦tς―Χπεόϋ¬0ηL}‘_νs«ΏΞ@Ν—€;aχQΰχυ%Ψ9I‚… ]Ξ-υ Αεmb‡dRΓτnx“·‰=ΫGr™«€oͺΑ¨8DΖΙΐMΧ>?΅‹N’ωΤ?κ«>½*ΠΩΥ^TE φΛ»Š’8fπvTΧB`a·KφΙώ;—@ Ι ’ŠZAZβωδΎY feΰΊOe‘ΞV€ώοτΠ ›οΌ–Φ§&(vͺ­ΐ瀇FΐΑτ}lQ#6qc>΄‚Ψ€™Y9V5\§Vrν·κχ ƒ5UϊοˆfΎ¬τv_± Ί>+Pr  §υΊΌ•ώ[„UPΘ"κνΞg§ ΊΦ½ΜΆϋo₯ϋ­WFrΎEΆμ85ͺΎ1†lŠηuα^c?rU –α‹”hB›§ΝR,&½’θ’{t ·―0€ΖμΌbΑ;'½`J':hœΥΟ­εBSAVeΰZR\HxΝΏν>/€ δ~§βύμ~ςAήyΰΦ‘πώžlΚψŽQpπŒYκƒ9¬˜#Mγι―oΚ‡ττ«ΊΆ’£}¦hΚAοϋ+H!πΨψώz@―˜Υή~u‡δΓ2ΊΦh@±n‚Τg:(ŠŒΓ E τ8AΆ¬½κη$v Ό¬.4―*€χ,$Λ[^πb=^*[Dσιgα †΅$CŸP,>½’˜[‹ dƒήRPrΉBq 2ιυ‘1ε>";”ίΝl?‘«IooΈ ·\鲏“Β±s d&Νϊ.¦υ™UΕΦt‘e}~ξPΟ!.όŸ"K*΄£―¬Š1Λ?IΣ‡Ο― ²ε:Ÿ>τ)zs_χ£h‘#ψ§?Žc՟0  κthΏsΉϊBΈ ΊέOtΜΫ £°Ÿι8ΕBΒς[Uœˆ<;‘°ž^"’₯ωδ(σ„Γ½²-Τ‚ͺωK”WΏm#ιDA€0|XΗ!!πΒσ>Έ–"ΰJ&Μ`όŠ/Q>σ§Xΐ;£σϋEΥΧ>c’F „VαcΒΜ€ ƒ’υbή,„ζEXΩπ™YΰtΫΞΗ4ΟΑιόΕ–ωΠ§PΫ;ί';Eΰ”L%@΅Θ„›Π=[₯’W %-“):—ψΞ- vΆcΰ|ˆ“mq&Gΐ{3.Ύ{«k*ηΝψOŸM°’Ɗ֫α>=’o'ΔB•ΕΞΗW](К ΰΌΪ šKΨ¬pŠPcό)δ ‡ΓyυJΑΖ’ΘΝ‘i/tχ"*  ›U€ ΎβdΈY9₯ΰ/­ φ¨HΕ{ιΫ²Ύηx²=Uοώx8ƒυ2Βγ₯ḏΡό±Ο’xύ*‚Ÿϋ”<*o2νΝB¬¨ α׎ƒω7ƒ„yKΒhώŠ"…ήlͺλeXΝίC`Η¬LΒA8[(Εΰvΐ§«"Q¦˜–^ƒ‚΄9‘ΦͺIλϋγ‘rΞB<Α0=oΏ^ˆŸP!Ϋδu!πΗQπξΝυIΰΈ‚ώ~I9-<΅‹N4ΨΆv1ύ˜σιsΰŸ@ΰΙaϊΝά7ωώΊ%2κsMό ?vζ«Iθ&Y˜η+Φήr(?0ΨG.Ψƒ0 ΉpRT6šv–€UQ:q‘κzz7ΌIz0Qθ–L$›yzΛ‘β*   Έ[5΅ά‘™ϊ&Ζ}ϊΛ”Nšexa\ύ…APUPO ztΦ€ζ(JΦμ0Υδ,…œRȝGΓ EuͺΎq\M™!Œΐ!&—bΑ?ƒ³”7ul•Y±;(‹(BDƌ§lκϊήΩΐPwG‘« N62•Uϋ?ΌͺπŸRŒπOώόχUΥrυΝ%ΌB•bΕ’„Α·ΛπΣ½½yw€|Ξ€ΕB(b₯·ψ”,ζ’ϊLFsDΓ\s¨ύ΅τJΑ©wn¨–”tΩߟΧWsyϐm$3ͺφqόXRΘiœ·„ζΣ>‡P²’(ΦBέκoHΩΥ•ϋςr Ÿ0²όX€t…AΕθr˜|Oα€˜΄Θ,ΜNeΗ†VlΏ…Υ‘”·Pω+z&Π@VxΧ£π(x”Όϋ$€΄”€3’‘t†Αt†αL†΄”dΤνvΩvssM’φΚΓLWΎαχ—ΡϊtQεΕOG*€‘p3πιB;Φύ!N8ΕλΧΪ^+Š΅ΗΆdWQΤ—S©―΄’}hX!€ξίF+Cͺ€ Β Œ\P¬1λƒιαI)‘κ2,u Θn9WT‡ƒT‡T†ύD|^Jƒ>‚Ε!‡ίωΌΓiϊ†Sτ$‡θL Σ;4Lί`Šώ‘CιŒA Vwέ1̊ cC[nV2fΫίncλ½7s{nΞ$Λ84ͺŠΏώ§ΠNuΗ|„†OΗ―7šχ³――šζJή΄7’xδλŒ)Β&Ӈĝл #]μ ώ84M@Gd-¨‚ž.¬±$Δ΄ͺuΡ >EtŠUϊο §θJΡ•fg_‚wzβd€smAϋ C½Eag dR)v>r7›οΌΆ˜[rπέQ°πψo²U}ξΒμGi8ώT‹oΖ•:φςϋU?]Ρ›ώκͺŽ£7χΡ‘ϋhQL9YAŸ,FDγ­e³œ;”†0Ν)°…c*9Ό‘‚² /e‘»‘‡(άΏλσ(Dύ^ͺΒšΛΒL―ŽQτΡ3˜b0qt«lσΜ Yo‰) ₯“f’J·ω­Bj1Ωζ₯Ug’ƒνΝZ‘šKΞ‹₯Τ.ύ0 ǟjY±΅•¬ΘΎ¦cŽ?hΘ½TύQL˜AN˜=jυŸpp4s_oE*²yΑΔ{ρ Ν-ΚlΚjχ΅¨έ˜USΚάΊ2γ1₯$Nον!ο'ΣΧέEO7}έ]Δ{{$J’ΰσωE£DKΛ(―ͺ%\RB0!-!-! œok<ɚ]]μιO’ΡY&n|z\ΐL7–›nΏŠ+ο*dœΥ­½cTXΗ‰ΐ#…,嚣N€ρΔO€Χcμt΄]9‘Φ’ΨƒY―±χζΒvZ”]Ώ1 ξΫsδςΜ₯Ε8™οBSnχ‡ LƒHkΑ;Βξ4b/'Œ«%κχΆ?ϋΘƒΌόΤμά²‰ΞΆ=t΅·‘(š₯Εγ!V^AuC#Υ c¨ΫBΣΔΙ΄L™ΞΨIS\Αžώ$λφφ²³7Α@*mpl#!ͺ ϋχ;χίΒΦ?ί\Μ-= ψϋ¨ȏid'Zάv*›y8γ>ρ%Cvž>΅Χ.ΑG―3ƒΖBLΉσ(˜"Ί΄bCZ°"œSƒm½ύYν…M$ΐΑ`2™ΟVΏΪ‘5AQ€9’O!„-ͺ.€5₯Μ«+3lΏκ'ίγ‰Ώά;"/fψA"±R*j똽p1ΗόΣΤ5sάΏ}` }ΌΩήk ΪΉ!,Μπ[ί]hš[Ι桬UΩ²ΚgΙ&N8ŽHσ$&}ξ;Ί0XΞδ6*%' Utqό||ίΈŠ[έ©&λΠ™ϋΉΥί&Šΰ  Œ€$Δ dΗ,τ^ΐ…:χθ?x><©ή°ϊ―όΣ­\?&5<όžΌ`fΞα΄/|…ωˎΗηΨ€}’ΔOnΫKϋΐ`>2` B„ϊ§qΦ]u>―=_hZ―G‰χSψπ* dωEš'2αίαρiΌ}y3\Ρ š`κΓΏ^X³Εl3MYΒΜ Y ΦH€‘P$«€<ϋκ)9…γΔ3θ’R«²qu‘ΰQ™‹4+ˆ|9²•…@˜8 Š?§9Ι*ΰρ0W·ϊχχφpλ₯Ώ mηφχμλjkε™•εΡ»oΟΊ$•DKΛ ΈKΨλeZUŒΟCk<™˜A?›’bσΏŸŸςiσθΩπ:C]{]ql²€΅ωWVηηΊωύΑšZ>ρE|ΡR ‰7ΊΌ}EKΩ5†„Ήkj/:³ή˜Ν‡…σ_{Αs ­½PtΡ₯@SŠnE·«Σ2ΐ§(4FCΜ©)cfM)3kbL©,aRE -e*CΩGΙα΄σΦ »έ1sRtΚCS$ΌzόFƒ­d―ŸqŸψ"‘Ϊ1Ϊέ7†—tΒ¦ΟΘ₯φψωσΙ=v+Ί07ωzW#o[+Š0… ±1ύUP|²Q1&ΎbνΩyΠ "¦ΥΊΉ4ΜαυεΜ¨.₯<δ#δυΰS|ΏG!μσPς3Ά4B]4HFBw²°)n.Ω5ώmTz₯π(L­ŠiΒΡΩήΚ“½ŸDΌ}{ρϊϊΨπΪΛ¬y|½]L™;―Ο§]HΠγali˜¨ίΛξώ„%™ί-¬›»No(BωŒy΄>ϋw€½«“ϋϊΰ-`νΏ’˜ \ΤΈ9³Ν§žIΙΔΖΤZE{³ΒfŒ½[’τΌ~ ;hιΌX’ΦΞ@z·Α¨<Šj=¨¦QΔ&@POͺaαέ*l| 7U2»¦Œ’€·ˆΆγρyiŒ…ϋ²/ϊΎ„ϋ„…ήΔ¨Δό9 ~šζΉGWΣ±χ}χ=ρ~ήzi5Ο>όWΖOŸIU}£v“=BPφΣ\fg_‚d*㊞Ω=__I)α†f:^~™NΉΙΰ\5Φρ― Bΐ―€cέvͺ?ξcTΞ;:’šoΉϋ ·0πΔ™έ!„₯ZPΡy±ϋ(ΒjžΫ)-΅Xφƒι…Ρ‚vόu9E",ήΉu,SΙψςˆQπ τχΡΎg­­τχv“‘‚α°”¬ ϋΪβƒFž‘"ΚΒ ²Χ0΅2¦]k4cη捼ύκΛ,£Ώ§›Ηd"AΛ”ιΪύB^/γΚ"΄Ε‰§¦ŠI+N`αϊ±ΘtŠž·^u›B%PA–μ&υAW_ΎηΆCΕaK¨;ζίςνΆΝΒfX1ρσ;—½bΚΨΛ Όb ±ΰf|ΑPΰ“+dρxt…AΖH€ΦHΤ °Υ «|q$"Ν₯aζΤ•ι¬—μΟγχίÝW]ΝW–Ώάr#xΰ^^ώηt΄ν‘‘eαhTΛ‡¨ΩΩ— ‘JδE“θg_ς ψ΄ΟΖOŸΙ†Χ^¦}ΧN¦±ώε5¬εEκΗΆPέ8FK ρyšΛ"t'‡ιvŒΊ8Y{₯“f‘hΫE|Η·ΣΟv«ίΛk~―Γ€G%OtΘS||=Ι‡Ω6‡ ­§€’ˆ` Μ΅ Οωωv««%V‘PŸ"ΰ€ρuT†ύΪΆα‘A.ή9<·j%ƒIϋ(Sσ€)œsρo?}f-OρΰΖ=!>·Π xcIˆ₯ΝUxtd½<|Χωσ WΣίΣ @YU5 ΝγhhOU}±ς "%1νΐCƒIz;;θνκdΗ¦lί΄α]‰&ΔΚ+ψχoό'~κ Γυ€2žήΎ—·:ϋ‹ λ†Yσ£/κ= ΙR‹­ώ *€ °lϋW0ΔΈOMdΜέJmφΫΝ>>Z.€9·_1uρΥο―¨Ε<ϊΤ^C1‘",ΚΔL*tΰ’0…ΝyfΣΡκGz»d1%!–΅Τ^Ύ+ς=Vήρ‡‚Yz•΅uόφUΔΚ+΄m/νιbέήΎ’¨]Κ¬πΑqγj¨΄·<7ι4}έ](B!RV–ΟΑ²`ΣR)%Ɂή~νež_΅’—ώωφlΫzΐ^؏žu6Ÿ=χ?Ηw½ώΨS)›v˜iΆ£υ!υθtδΩ{±(}ύΎ’c6Ί Ζ#gOΕΠHG Βzθ™­4S_Έ½#žzΜ₯c« zσρΥgŸβΦΛ.dxh¨¨°Ψ`"Αό₯Ηiχ*`SW?©LaHΠJ­m΅R„Θ GOr˜1±>SΣTEQ†ΓB!Γ3΄p+8œίηχSΧ4–ωKεΓyGžπ!’₯₯Δ{{L&χ+ΩhύΛkθlέΓΜ…‹ρω}Ϊ»U Π‘2Ί’p‡g_€i―½G’ xπƒ€Ž.P­{ΏήΡΤ.=ΕJ΄i^3Ό$ŠΑ0 Ά C―‰ D³tŠΒ|AΝnnf%H₯iν$πςy !Og0Ρtό&’‚²ͺjf΅„eύ8-S¦ …Ω³νRΓCϋτβn^ϋέ{™΅pI6Tx…Ζ’;ϊ’$Τ:=(θ7΄lίM|ϋ&7«|2ΩͺΑ-ΰ'K•<ΑΡοol¦ι”Gρωm€₯§ŽΐΣ"πΨtσΡ|yΕ€ΓT:lψrΗς(Š©DΧ5Π7 Υ5 1Έ.ΐž=ΆpšbyD01ΐM_@_wWΡ'™`x0Ι‚eΗ£xΌ(’~/νρ$ΙtΪb±ŒT!Βn©4[{βt £AIΐkΙ}θLΡ™€->ΘώΫ{lο`O Ι!ϊ†²%½!πyΗ›θσω3a‡½ŒΓ9žL&Γζ΅oμΣ ΌeέμΩΆ•Eϊˆφl|…¦›»γ λKŒ…Υ:2——MKϋκθws—§‘eNκΐ9ΐ%Ž~Ώ?Hσιgm™b­ΔC‡’k,=YπN+ΤΡ8ϋr_Ρgm9mτξ†0₯ρjίU„–Ω—ζΏBSπ(6Ψ:—Βe΅w[=Μγ€ρ΅T‡σΉνΫ7mΰΛ'³OιΒ?άΓ¬Gioν‰σάΞ£oKψΗVάΕβŠPπκHArΤ^i΅ž £± aXes‡}…κp€±ͺ"ΤΛΨ²ξM~ύέo²mΓ[ϋtόo|ϋWW{O<Ιύλwš†ςsΠσ'κηΤ΅ξe^»θ;…ϊžK6d~ΘZcΘφξs’ζ/₯rξ"γ oξ«γδΧ½ ]}τuζ–^Βh%`t'τ棁δΓΤοOΡηLtd"ΉesV‘βZδdξ»"ͺ…9ueΩϊU8ΎσΌϊμSϋτ 6­}ƒNoΆh§,θgO’αŒ—OwΩΰИ€«@ιŒdXχ“RyύςmΖ­“œbΘHΞHz‡ΩΦ3ΐλm=t$²f~,ೝSyu ΛW|†α‘AΆoάΐπΰΘΊΆo|€iσh‘έˆίƒ‚έύI[+ΐI†ͺλY(?ΰ$ΰ χPU+&§΅4}ψ ―ΟT¬cΊ)6 œN(Ί’ ‘/ΥΕΤΉδφΫ±ό(JžœZ<” =ˆ‘͘s6 ΰ+‚Bm$ΘΤΚ5‘ m2œ‘1”œnO ±£w€κHΧψκ~μ‰ ±ξΕβ³p7½ω:ӏXHMC>Ή΅<δgcWˆ’:ήpo4FΗKO»n>p+Π°+€ pιΰmžLΝ’“²]|LH=&AVlΘ=ΜΉύ–zκnϋ]Εšέ—ο¬ `Β!Μ-ΕLΐ‘₯Η 90•ϊκνήPΕΌβ«ΝDΜΎtΔοe~CΉaΫmΏ»ŒΝλή<`pϋ¦ Lž5—¦ “΄.Ι―‡}‰’)‚[Κ"ΩXAΐ$hζE190@Ό―—δΐ©Τp–²KΈͺ£~/M±0uΡ !Ÿ‡žδp>sΡM ‰T†wΊγT¨–ˆώσ©σ§·«‹MoΎVΤ5f2i:φμζπcOΔhˆφΔ –%XΤiμDz7ΌαΦw0B6§fεΑœ0‘lΎΏύΙΌ>Ζ~τΏˆŽŸjͺΣΒxŠ9"€•Χ\l)τφ$ŸϊΜ<‹K (¦,@kc­O Ξ1WτHΏ%2‘_ωyη…΅Θ @›]SΚt•aΫ7½Ν~ιLv½s`“ΖjΖpνΓOf©ΆUΩzjϋ^C† tˆ·‡½Žn¦<θ3μΫΣΡΑκ<ʚ'cΫ†υtΆ·‘ˆΗ‘2ƒT{3z<^"±γ&0eΞ<ζ΅„i‡ίoM"Νε$Σi^kνf}GΏfsS™ B>4±Žš°‘#0ήίΗ…_ύ»ώ‡Φά€ψŽ-ΌψΣ³‘)ΧτεIΐΖƒΥΈοτal,ͺ<ވŠk+·b 7i¦½’XΒpŠƒι/μrDž8›²ήάΏ=6αB„ΎΚP˜ZŠΫΡ‹©%Ψχ΄~=t¦ΈΏŽ5ώ<·κaVέ{η7γβ}½d2i[ΌT[>λ’Aήξθ3Π„ θΎ)fbEΤ°νΉU+ωρηΟΰω3Ϋ6¬§§³ƒ‘d’L&Μd@&“!J‘ˆΗiΫΉ΅/ΎΐcχίΝέΧ^Αζuo …P4J0VC―ΩIψ…1±0υΡ ;ϊ€₯³‘’›iFJήξμ§.4X~€–©Σyρ«Šb-’R²wχ.}θ#šφyh2„‹1œ|Ρι~z7Ή’Ν ›!xΠ)€γ8Sx}4Ÿz&ž@ΘθϋΫdΰ =-—F©eͺΣ» Š9ΘΤ΄Σί#¬αΒ|0©± iΰ$VŒΒH1¦Ψ΄‘χις‚J‘ϊz‘ ύΥDBšΚLrύ…?{Χκκ·Ύύσ–CEM­άOZ ”rΧοUλBΎΌP­~β1.ψΪφ™LJɎΝyzε_ynΥJ:ZχŽF©ͺk°ΈGS*cΔ‡RYΌ “$›ρX X‹Λͺk¨ihβι•Ερuv΅΅4a2-S§kaΑŒ”μθK`πL ²4)ψΛ*ι|ν9‰Έ[„νYPΐR^²ύόf9νP»h9ΡρӍΝ9M+½ωWl¨ΆΑ˜* G υyб°G˜" ŠΖ7`­A0ζX1a! ΕZihJh:?uΥ/ψ „ΰˆΖJžΪβƒqˆΩνz<Μ­ΛcɁΕOΩΉeσ±Lz{yλ•YύψίΩπϊ«Lž}Ρ’Xή$‚¦XΏWaW_U‘ζ‹­ρcbažƒ“-„`ZUŒcš« η[Ν |vϋ^ψΩ§Ύό j›Š >voΎγ—OQ˜ZUbi’R κΦxΒιψJΚάv9N•9ΰ> ”;νPqΨ<ΑΰH ?ki₯Τͺ,lPL™ύ†]#x,w,ΕΕLΜΎw±VΑͺ73θ"݁>³> )Φe0ΒΞ­›YσδcοΊHΔγάϋHͺ9¨© ǁ‘D‚ΑΔ»Χϋ"ήΫΓ%η|•«vq•Y(7šKΓ|hBγsΡΘμκOςf{αΥ‰ΔJYρ΅o=—‡o7ΊζσκΚσ…i6nΣΌ‚ΥuΤσa·S•«2η;@5π§ύε5Δ&LΛVΞ!+½4-ΈΊmzΜύ‰aΫε]šκΛ₯Ei˜I:5 Ξ‘Ξ1Β'σlw.=aCx"T†όT…Yl7_ς ή«ρⓏστ#jχΔ«¦VΕ vψE@΄¬”H¬τ]Ÿγƒ·ύžύŸeΛΊ7 ―EM$ΐ’1•xΕQ δΐΜwwΡ•6θ°Γ9ž)sη5‡›7ρ“λ\;As,μ`-Ήί»±§|_¬άm—/¨²χΎ+€/Q'©ˆMœŽ/Vi.QHžΥΥ_κ­qUcJi~α ŒR[qΝkΆβΨ:§™₯MΚ©΄™¬tΞ_PμFj‘wGΜηm)‹ΰΡ½ΐίxω8οεΈώŸΡΧΣ­ΝΏ: Ή4b!661έԍKσ€)οΙίzεE~ωΝ/[R’'VD™\-ΚB{r[»1rU^Ξq§ε«$έFjxˆ5?bΨ6³&fMkφJίΰθ‡Β4μ³n§‹ͺ²χΎ*όΨ­„(Ÿy„Εw―#—ύο„m(E °ϊJ{ΑDšνd€±ϋ‹ƒ‚qΔόγ\#M[Εgv+ΤƒGύ^šKI•άύ§’φ;RŸϋΖ‹Ξ7\βΌϊ2Ν Θ)‚TF1/³υx½¬ψςSͺ½ΫcΧΦΝόίΧΎΐ.ξ0―œͺ°ί!#_S²p".ϊΠ‡©¬­/ ά»{—φwU8@ΠkJ³mjI ζΘe*kάNχcφ3—gΐά>,™<oI…ƒŒ`3&Lώ»-ƒŽvΊΓέΚps+€Ι4“zα—ŽΗ³…,Zš>»yηϊκεV©[6ρβ?Ÿΰύήwk_\­ΝΕ# fj:[ΊΚiς¬Ήόδκ›™0}&αhΧ‹Χη#R£¬²ŠΚΪ:*λκ)―‘€¬ Έ_σμlkεGŸϋ4½Ϊ½σ(‚“ΖΥ2.νqΑΖΞ>ΊF ‘X)‹Oω·’ΞέΊ}»ίΩ’=G―’0¦$dΰƒ°ο·h²„ΐŽΒ Κ`1+ψΎ/°l‘‚υΐώσ|₯ε†$έvŽVΫ@ŠV_‘֟l¦ zs΄–άΒc ρ©ϋλ“~0Άλwμ1†πΤ>ϊΞΏZ’Ήr~X:ιω ‡Ċ1„ͺŸγΖΥζ“W2ξΈκ7όώ] ύ ?‰οϊ*‚αHIFJ­«nΞm z==ΆŠ² Ο 6SΓΓΌόΜ“μΪΊΧK}S3εU5#<^CΙ$έ{iΫΉ“ν›6°ρΝΧxλ•—ˆχν[άΜ#ςƒ+n ZšGΤ·tΕyr[»­:—RfγjRrlK Ν₯yΊ΅žN>Ώt>ιTαΎyξyόΫ™!$’΅ν½<΅£Γ²λ™ƒl-)ιίΆ‰7.!ƒmN§Ϋ ŒeŠμOΐg€NΗ(›~₯Sη„ΜŽα7_·₯ρ‡…?§E=!VΦ_Ϋ¦ ϊJ?,ΙBЁΝǐŒ€ίW1§(λ櫘 ƒ t v~`qSa_ώφφvvpΙ|ƒ€sΥΨ»>Zwξ`ό΄YB υ:‚^»ϊUX*#I€Œ‰+ƖρL;Ι³ηΠ<ŽςͺJJˈ–”+― ¦q γ¦Ngξ’%yά‰,U·!χ“ΞHR2Γp&C:“έfwί*ͺjψŸK―ΰΏ~‘‘‘I‘1˜HpΧΥΏ%9·šκ"Aj£AϋΥXΕm2R²§?aΨ>nΪτ’π‰φΦ|Η"‘-§ŽψΌ}ݚΘVΞ=ŠpύX·S.Peς=qpπi§ΥΏϊˆe«ljρ¬;θ}α\[o°°ΫߜŠ‹‰νΗdϊ+ZΊ― γ―’Λ}…•lΤL"jΧ₯H1Υ%θiΝŒUωγWύιΗ„ς-eΓΉ~}ήΉlΪGZλwcΌΉζyŽύθiΩΦ] 1χtͺĜzEΠ60ΘΦξzSμ’m Kϋ½³7Αζξ8owτ³Ή«Ÿ]ρ$]Ιa­o0·jκ")gΜfό΄ιΌρΒs τΧΑ¨uΗ6&̘E“ΚγηQ ;{έ;"'†ΣL5yoyŽΆ; žsκΌ΄L™¦Z Ϋ{ςmΨΕΘ‘7Εγ₯γεgœ>Ž―±΄aϋb„T`o”W2k+=Ψ‘gvRWJi‹Ξλ‘R)έMπlz€t-Ή2Δρ Ω‚fUΪ’ώΒ!؟»†œ{g›\d"Α”–LBΩλχh©ΆΉ­«ŸxŒ§yˆƒiτχφpύ…?7<Y5₯ΜB\‡3Άχ°‘³Ÿ·;²?›»γμιO38LοPŠΆψ ;ϋY½«“'ίiγ©m{l»κΙζ-YΖO―½%[ͺ\δΈφό‘Ρε(΄”† xΧ(Mϋ@lουω˜8knQηΫ ΛE@Hw_€ƒΰΘ)Υ –α‹Ή¦Ÿ¦Κζ»*€œ> ׍Ε«°ρ…­qyιꃻ ›T`sž΅Υ’”φώž0₯φJΣΎ6H­y*†ftN©#υTs‹…ƒι pXmyΆ(E]ςϊϋΉοζλ|?§ρόcgυγ«ς‘6!8ΌΎά\351+>‹ιž–μκOςτφ½ΌΪΪm*A†–)ΣψΙ5·hέz ΞΆVΏο.νoŸGa|yΤ‘·/WΪͺS@―—¦ “Š:ίζ΅Fz±˜ί›'v5G€tŠΑι=σ‚Τ-^ξvΚTΩ|Χΐَ«¬ΗCΙ„ι¦.‘Eϊρ†•_:gή9 ΆΓ•\'`§έ₯3Ι€αΦΔMoΞΧwB‡Ή²Ώ"l“]ςŠΩz/̊@/ˆ»ϊyiw—‘Ύ^(‚εŸϊ&Θ]Τ\·mx› ―½¬ύ]βχR¦r+:eš{$–”•--\ΫΠΧέip9|ύ»α°xΨΉΑͺ:ΚgΜί'Ω

 x ΟΞβ:dςΪ\Q<(Jq NΉbJ p/5<₯“f‚β(Ί~UVίtςMBυΝδ[Γ8υOόν~ΓΆω Ά!@;Kΐ “ΊŒ Œ„φΔ bΒτYE‘σ}έ]t΅·Ξςyt#yΧ­ΈDaBμmC€zLΔƚ΅+ζ,ΜvΥ²ΑwCψΩNϋ‡κΖβ EL±2ύκ) DλlJ~΅ΐ―ΥΙΧΗ„₯p —σhE<.Κ$χΝΈPiΗ„Γ9’e’ˆυΡ S«b†½ϋϋzΉι’_μsυΫϋ=†‡ΉοχΧΣΫ•κ*‚>ZJΓ†RX7Mοψ­Ψ$­»―PˆΖqγ ΞQJIO—αqέz²”lΉ  1<4TœΠ#ΊF1Ϊ-DΡ± VΥΉΙτl7k}_ΐυΗvDƌGxΌ–$ϋΆΟfsYaIΓυα5αŽ6ΐ¨ηDHβFT’M»¦8H{Σ? eOςz˜U[¦ΡTεϋ–__Δ[ϋΩΰσύk_ZΓS?¨½ΈB%4 θΊσ*WOΪ2"Yœ>w\οΰ°‘n@UuΕ‘vτχφh!:!ά‰Y=Bδ©Ο€$5<\΄EK,swmz$ ¦Ήm•sξ³Όξ‹«ώؘAUu˜rςΚdώ»Ήσ"Oͺa!ώ°ΉSϊΞnΧ܊nnΌ©ν#œ‘@s'Π@Ρύ­ε,HaρcgΧ–iρηάXuίέάwσυκCJΙ]Χ_IwG»v§Κ‚~šb!c›lμ)² αΉοf€εύŠ••Ξ VVE8ηθSx‚δ@Όθ¬ΜH4ͺΓ2–‚ΦB€³Ϋ¨^pμ>ΙλΎ(ΩJ#Ϋ}}±2|₯•:·ϊKέ lΌΉVf]©W]K:%!³y«Ÿcω•HΞeυΧ™RΊ*jKΕ΄UΏ9³¦”ζ²ˆα{λ^y‘kο§|PΖΞ-›Ήη†k &ύ”Κƒΰx»LxΑHJ²‹Eη3™΄½Kisl}{3 τχ3Π_ΨE —Δ ˜ΔP:cϋŽ9α[7hΣ8‚Υυnr}Eϋ£ΐbG?§¬ o€Δ֜6_ƒ09ζ΄{avπu+¨Yτςρki―tQ―y-›±5=¬Q ™…΅oΡꐢκ!w“Η—G™¦σϋΠ΅·.Ίΰ}―σ?ΠγΞk―`Λϊ|s‹€ΧÌ꘻πΤωαU¬eΧzμΑυ…†Œœ yhΖD0“Mj2Ÿ£Ώ§§π έΠhΈ„d*ƒ,’ΠΕάiΙq?ΕCΩtΧ ΰΕͺμp€Σ$‚5cμΘσσώΏ΄ŠμŠ,‹€CL@‘0ωαŠΦ Gόΐin/‘tΎYI ]JͺΓ~ζΥW˜@³!ξΊξJ^{ώ>ˆγ·?ώΎ!άR‘4ΰst,‚ ž•€ς _c…ʍέE°υ#‘|{0 Y8@-υΕGi€”΄οά^puhš0و; §΄4fK p·Dν" ©š·Δm G(Π€SΣE!T74fήΌ°ΩFΡΜ°}‘­¦rxl`‡ΠK“u!tX„tυΑςζˆϊyΨ!‰φ%ΎBd[F1¦Κβk>|ןΈϋϊ«ψ Ž7_|GΤΖ₯9Q__^DΈ»p—δͺ°ή J;QVQ₯ς:ͺί•φΔ³>Ez¦†‡ΨΊ~]Qη7m†α­ˆλ(Θ„ θvΝζ7=ά،'vΪ‘\•έύVuό΅ŽZΪbώ›@3‹ΰš¬icVkiώΪ#νΤ=H:―χΉU:#­ ₯°σmLGqθ=’νκ£ŽSE‰Ο˜μστΓγͺσΔyd2Έυ&:φμΦnfiΠΗ„ςˆee<aΚ£¨  ”πk_\]ag(‘€Ό\;†SΦͺ! € ©‘aήΦΉ)‡1#³‰Ε(½‘ $FΈ‘eŸdw$ ΰGί ΊAeφ)3Φ…PΪA~Fα·KεΧL1›°bώ³ΫΣpL,! «ΏωΨςJ£’° άά‹γΖΧQ2Zbήx•KΏNΡα€Cq477 …XΪ+<ωΠ_σa7ΥΘ‡’l¬σ}- ϊ¨P;%εΆ=zέEΝ«²ΆžJ΅Ϋq–τ¦l•Ξ„ #ΆΥέΉ—mΦ<‡’(Τ5εAψd*M²€pΛwRΒλΟvέΪΩ‰ψ˜£6­mr»"Γc5›ζBϋ*™ύ#{Ζ§:ιΌΫ%W©o"­ΛΏ=υΏ΄(½[‘Ιρλ( σ1v½³•‹Ώσ ϊ{{>‚‰DψΦ·ΎΕΓ?Μyηΐ]Χ_Εήέ;΅Ϋ ψSr‚Z\]4ΕΒ†ΚΙd"Ασ>R€¨£’¦N;Ψ`:£K(ʟ-δυPizvΟ―zΈ¨sLž3N30œΖΥDή‡!<"-ϋ$»Ε*€ ·}ΒΪɍ«Ίξ`š“§ox !΅ΊŽΪΛ\X$AJQΰΨΦΥ_˜ƒ˜sώ ΐ@~•ς*‚ΕΝ5Ή§³ƒ+vο±ŠŠγ3Ÿω ―Ός —^z)Š’°rεJΪwοβŽk―Μ¬S«Jˆ˜ιΓ\Τξw]4˜€¨ύσ]EeO !h™2P8†ΥSŽιq›qe­|77ϋσEέ‡I³30υ₯l]#;τ;ε(Ω«λρ„"nς]±? ΰd7퓍 PΟό™CU ŸŸ›_ν -χ< IDAT‰" Da0ζM‘‡`Ώ°υIσQ σ\6U3¦ΤϊP.ύώ·Yσδγ8ΑŸ?>oΌρ·ήz+|νk_cςδΙ<υTžΙθ[oβΥηŸΡ5U˜USj4sqΰΠ΅I[ΨXi1ΛWέ{GQ€)>€yK–Άmο°<[―4ΖB†ώ‘λ^Z]”ωoVΨ;0dΏJΧ Ή’0iE‘Vβ'οpt0όε5jA‚4IAqyΆNͺ]ξ‚mϊ­mxΐ½{―pŸ–R±&5ž“ι/αΨ u4•Y…Ό³ώƒηϋϋFθ=σηΟη–[na͚5TTTpε•W2i$Έβ Ϋο\υσg:υ!4”„¨‹¦ΩΞ=³…ψuΙD™L†ΗξΏ‡υ―Μ•UU1i>ƒ.#³ύ Μg«Š¨Q­·ά~π7uŽΪ¦fš'η³pΣI[o–œΣΔlΎέγΚ#†Oo|ν &‹šsIY9§αΛΫnm»7¨‹†h±3 !xπΆ›(œορz™³ψΌΊΰm=Ϊ α.(NaQχ¬@‘ΝΕq& Ή― ΐΥπWT[„G"‹Zγvaα •6Ω|²€£$έ…Ϊδ»ΝlΏϊ#ιw/ ϊ9z|΅Qc]?~}ήwxΰΦ›ySΏΊΊš³Ξ:‹ίώφ·444000ΐœν Ώ`ΑŽ>ϊhΗοuΤQlίΎiΣ²–θ–υoρΘ½w‘Q‹cΠ\&κχ:ϊΔQΏ—sΟ-—ύΐ@Thœρυs σΟhGο}CFάΐοQ8²±ΒπΌwlΪȚΗW•ύŠDYrΚG οΗν½° ψ=n8€š ͺGΈΧATμ‹˜‹CM±πϊP!ƒOμ*ο&€]Έ„ŠΒEμZ‡ΫYD2Δ€ƒΛ‘ΏΏ.z₯<`ωδ1T†Œ~μ`2Α?™CzεD"wάqάsΟ=΄ΆΆrΓ 7πΩΟ~–ΣO?€;οΌ“Η{ŒX,ΖΏψE‚Αό=̟?Ÿ;ξΈƒgžyΏίΟΒ…ωςΥ»―ΏšΦΫ΅Χ&βσ\vlασ(ψ<ωΖnCCC<υΠ_‹Ύ–‰3f³μίNΣeFJ6uΕ-ΛΜβ¦Κ|~‚j±<ρ—{iέ±­¨σΆδΚ*«΄ΜψΆψ ύC)ΗήfWΗή/‘jYkά€_•ε+€Jͺ‰Όα<ώ I %WhϋΟ‹ΜΥwωLΪND¬gjΣΣΉΤ§9Ύ2Ζ)SΗβσk{Ί;Ήψ;_η₯CΨηŸ>}:7ήx#>ϊ( ,ΰŠ+ΰΜ3Οδ“Ÿό$<ςˆΆύψΗ?&•Jρ‰O|‚%K²yιGuΧ\s kΦ¬αδ“OζόσΟgρβΕάtSήŠχυf[œλΠ”Κƒπ™W>ύΆφV“Ε™ώ^Ÿ_ώ:%Ία{i4μ7Ή2J}4d8Ϟm[ΉηΊ+Š·2Ύυ]mα^ΩΣ­],ΖW.΄:μͺcΛƝ3ΔTYΆΏ?.§hrT‘h^γHwA4›t/aΙ6L‚²¨Ÿ&S,_ ;tUύ["mV$‰ίγaj]%s*-Sή±eWώμ‡ΌϊάΣ‡¬πO›6?ύιO̞=›σΟ?ŸΛ/Ώœžž†mΒlkΦ¬α–[nᬳΞβΒ /dνΪ΅œ~ϊιD".½τR.»μ2vοήMΪ¦­χ£χίΛρ§~‚#–§½λsλΚxz{™\ιΆΓΓΡ’’;κ'9ςΈ“΄Ώ3R²A%Ν­¬΅‘³kΚ Υ…ιtš_χ›E₯,9ε£Τ66isξNkΰŸYn‹π&lχ³Λ@J|%₯xƒa‰Έ“hΪ `:`{—}Ρ25ΈΈuΩ‰Τ.σίBΈ­ν‰0ΜjeΘ‚`h ‡€$ΰcΩ€1Μi¨4†¬}i5|ύK‡΄πΞ;οΘ֞Ν²π{Žh¨ θ5ŠΒMύΌθΌΧΛ§ΎςMΓΆυ}Y2Q³γ οΆ™3±ψΞΤΩ†ΆΑjGŠ0Ÿ*Λ#V1G³!Zͺ’ަJΓ¬3¦Έtn=οΏMNŽYθ€Γo;ŠρbHWυζš4˜’¦²(šΪL­)…Uψλ}όο—ΟbΫΖ·9‘―Φ^¬ššΞ8γ ^xαnΌρΖ’Ύμ³ΟςΠCΩΖ₯­­­,_ΎœΝ›7υέν›7ZXfΥΔπ*Ζτρώ‘qS*ν™ηώΐ΅HiE%{έ-†Vβ)yfG‡aΏ)•%–πβ?v?+M}άΖΙρ_ԏmΡζά3˜b{Ο€ε/fε—.ŒI%Η%PεΪ!6R pι4κ …)Θχm–L'€·ν yJĊΪY+ s2sχKΘd‘αΓ›j8v‚¦ŠΎδΐχήt-—ύΰάCŽΘ³ΊΊšK/½”λ»Ž¦¦¬…Ψ’}‰ΫΪڊ>N*•βwΏϋέέέTUUρύοΏθοfiώώη;Ω±eSή ρz΄TίάΞHvχ;υN™s?ψΝ΅Dc₯ΩΆλΊΥΈ±e<ηίx5΅†wγ™{ E9>Ed³u;½³a=·\z‘ΣίmTΦΥsάiŸ2dώνμ o8e+¨–Œ_!άπrWΑΧο(―vΫ!δ$FN*΄Tύ±Wώ`>^XW˜=naγγK’¦ςώj˜–ΙXιΠB8ΚΣΌθζζΛjΠΏΟγ₯©¬„ٍ•”‡­ΊpΟφmά~υoxτΎCιW…³Ο>›P(ΔͺU«ψέο~§%ρ,Y²„±cΗ²aΓ 8ΈdΙζΝK:ζ駟ζž{ξa͚5άvΫm|ε+_aŊόρ΄|Wή .ΈΏίΟ9ηœΓΞ-›yψξΫ9σ[ίΥVτ¦X˜wzθΦ΅εΪΤΩΗψ²0aU `ΡI'3nκtž|πvm݌ΗλcβŒYΪ'π‚“|[oΒ°>L­Šδo0™ΰ–K/,υXφΡΣ§sG©4―΅υ8Z–φ‹“½ŸŸΓœΨυw..€^ž»‹ΠΗ`“D Ό>κ?HΣrYBQ4!JΞdQ΄‚‘¨ |ͺ TTό GΏEysΗDf¦qB(Ίγ ν3‘¨}ήΪqrσRΘ6‘ΜŸGQΏjšͺ©!S^ΒΜ†*J£κ "4m-Όπψ*nύΝ―ω’žŸώτ§όδ'?aΣ¦MuΤQ$ ώώχΏ³pαBΆlΩΒ·Ώύm’Ι$Ηw§Ÿ~:&L°γ…^`Ŋτχχ³~ύz***ψήχΎΗ%—\bρ—/_Y»vνbάΈq  ‡ΉόξΏ1nJ>kuSW?/οι6M]$ΐ’¦*gσNgAhΥχ—Ά‚C°!¨ €•μ‚^ΗLΓρ“ΗP±ΥŽ·όϊ.:χΏ)αΕb”——γ1Ŋ―Ύϊj6oήΜ„ ψόη?O?]t‰D‚qγΖρη?™‡zˆο|η;L˜0ΆmΫΖΊuλΨ΄i™L† pέuΧΡΥΥΕΟώsΎωΝoؘεΔ ƒ,]Ί”'Ÿ|’•+W244ΔχΎχ=Rω’\wαό^y”Rsςξώ$Ομμpw MŸ΄Ζyzϋ^rέ½το§FRͺn{ώΡ‡Gt_ΏvΑ― Βί7”βΥΦn—V³Nh³}L³π;RB°p”g§ΰap6T`ž@ˆΨΔYxΓQmF·‚[VaJnuΧ“δVae mΧV[a΅ ηC«ΨΚ﫟“M˜PtmΑ„αx>ΒδΪ –Oo‘22YN₯YΪΛόό«ŸηΩU+]‘νƒi,_Ύœ/ω˜}φΩ¬X±‚E‹ΡΪΪΚ]»²ΰZ?‡εΛ—³xρbnΉεžώy6oήLyy9Š’ΠΦΦΖ«―ΎΚ]wέΕ5Χ\Γ₯—^Κoϋ[nΏύvΊΊΊ8φΨc?~ψ u•~g+Ν“§Π<)_HSτ³YΝΧΟ Aο`Š­έq"~oΆ£ΦRξ΄”Δ‡¬ήΥΙkm=*ύ«Υ6˜^­ŽκΧρΐ½lykmQχφŒoώKν4ΓΆG6ν!žJ»·+ ΆμL}#g C&«PΨώΰνN‡οξڊuζ©.€₯Έ/VNΓ‰ŸΔ_^₯AEΡ„E3Ω³ ά­Β‹@QςBͺ˜€’SнPt}t&ΌΗNˆόΉ„((ΦW’0¦’„9cj¨Eσ@ y°qνλΏίύ7ήΘgœΑO<Α²eΛψΒΎΐ5Χ\ΓΠΠ©TŠh4ΚψCώπ‡?πΞ;ξ©»υc›ΉόξΏQVY₯3©»ΨΤ7˜ΛΉiT„|ΤEƒ„}^Υw0aο@’ν½ ϊŸ7ΐ3ΊK8}j#!―Gν= w^ύn½μ—οοΒOζœ_]aΘEx«£ηvthσΛΨXΒS―I3Θ—1 xFZ±)­•―RJCIžώΚGs §ζ±Su^ΪΐγE˜Γ/Ά`Ίί›…xAυ’SΰAHs$Rͺ]|υU„ΖcΦ”D8nj3ΗOm¦Ά$b™DwΗ^ϋεΟΈΰλ_:d„Ώ€€„›nΊ‰Υ«W³lΩ2ξ»ο>&NœH8ζcϋΓΓΓ΄΄΄pώωηSR’e¬ΩΆmψΓζΓώ°–ΧίΡΡΑΆmΫΨ±c]]]Ά+ΟΠΠχή{o6|7k<π/ΌπΑ`G}”ϊϊz.Έΰ‚‚ΒŸVο½ρZΓΆiU±¬Ϊwv$†x³½—Υ»:y~g'/μκδ•ΦnƒπVWΣχ³iΊωλ:κΔS Ξ±yςT>{ΞχρωύΪ Ω=8Μk­έgΤl©g Ψbί;Π5  'OU<ψlθω Ι³“βΠTxΌΩ6`Ž1?Σί–ή …΄‡±¨Θχ·;MPZ΄eφΌŠ,šΠΘ‡fŒ£Ή"†b cππ]βάOŸΖƒΊ•φΆCBψW¬XAOOgžy&?ώ8sζΜαŒ3Ξ`Σ¦M$ xΰ>ώρ“Ιd8φΨc9κ¨|;νkΉ†7‰DψΖ7Ύ―Θ,»ͺ°{χn-„ψΛ_ώ’#<’SO=•={φ},)%εΟlZχ¦ΑOŸVUb('ξ‘ΰϊ’3HCs Ÿ½σŽ“£<ςώ·{ςζ]­΄»J(KP&Α ‘ΑdΖ6‡Σqw>φ½ψ Ψ‡m°Ν0ΜΩΘδ`l‰"ˆ ‘œVξJ›s˜άΟϋΗΜτtχtšΥJσπY΄ΫΣέΣα©zͺ~Uυ«‹Ώϋo–Η ω֏ΖθρΥοR|ΠΤ•“§;E%Λ{Άϊ[–X6xερΨ1YΚσ€ΗZξν”B:Μ&YκgŸI˜Σ αœk-K2*KΉβ˜Γ8¬¦ΏΧ£σ―ρ8{Άoε¦ο|ϋ~~­Ν*ΐ>-c``I’ˆΕbόφ·Ώεγ?ΞΩηΥW_ε΅ΧRuνgŸ}vΌκλSΌ… rβ‰'ΊϊΞ@  Ί ΏϋέοΤνΟ<σ «W―}΄μm`ιӏΕ+©Δθβ~>A.ΪoG—@γ.›ΪzΥl=Hε\φ―ΑωW3E₯eψAΌ>?ώ@i³ηr˟Ÿβˆ£Υωζ[:zΣ%ΏΦzHΗA%¬ύ{γ5η—Ή*α †ςVVy~«Ο$YΦXWϊΫΦύ&BΡƒα—eαJλ›<=mJqmY1³ΗVQ[Q‚>!υkcέN^]ς/>ώΚ^ϋi'NdΜ9τχχσΦ[oρόσΟσπΓsε•WrύυΧ³lΩ2 5μρxœ>ψ€E‹α5ΈqO<ρίώφ·9ωδ“ωήχΎΗŠ+ˆ8$Γά{ο½\tΡE<σΜ3<ςΘ#ΓvoΟ?ϊ 'Ÿ}>‡Ξ™‡ ™T^Hη`TΫ©Α4δg]|n½··ƒγΖf1ŸίΟU?όoN»τ κ6m`°ΏŸqS¦2eζœa­οδ½½n§d6&ά ΌΠφ΅Β²@ Π[ /•½²ΝζšFφ¨TΰXΤΦ§Άbšϋoδφ†p`*OXΌ4a,θΕ*Y…!N9t< gL Ά<Χ?Š†Γ·=+” _ ˜fΤNbτ’‹5n)‹ ·—©·ιTΰTœ>›$i’r2Ι@²!qH’ΣΨΌ”Κ/Π₯ΛΖ€Ÿ” Œ.+fΦΈj&‘>œL–Ÿ$I47Φ³rι‹<χΘbϋϋ]½²/}ιKός—Ώδ¨£Ž­¦ w]zι₯Ž~ςώ ŸΟΗ―~υ+»ξ:˜1c ΉΝ)Ο?|yδ|>Χ]wcǎε‡?ό!ύύύ¬X±‚… βσωψιOʊ+¨ͺͺβϊλ―gΦ¬YΔγq^yεΥ¬χz½<πΐ\yε•ΤΧΧ3sζLb±#GŽ€――ΎΎ>.kδχwH’ΔWΎω-ΎϋγŸ₯kOqEπJ]KWζ‘(&μΣBdšΏ˜ωΩΉ+s‘ίCiΐ‡œY !θ ΗθΣτχBδΔν³JΘ|]†6σΪλΝ|±7†ΐ9 8ΓP½αΟйξ]«ΗωURΙ@,€€ν- ΊΚŒZΧͺƒ ŒS½±f^΄—:‡,I7ug̚ʀ‘zΨGρ8Ο=²˜_ύΰ_yςΎίΉώššžyζήyηN<ρΔα8χάsyϋν·©>`ΗY²d mmm²hΡ"Σ —ΩΗησ1kΦ,ξΎϋnΪΫΫ)))αŒ3ΞΰΙ'Ÿ€΄΄”›oΎ™Χ^{G}”Ω³gsΓ 7ΰσω8묳زe gžy&‰D‚;οΌ“ήή^jjjψφ·ΏM8¦ΎΎžƒ&ό™yπΦσΟ²υγ΅κ$πΚ3*Kl•†ΠΞ ο‹%hμ ³§gϊžAφτ κ„ίltB ;ΰΐ5ς­‘“„mo„d>.€ε[Š‚°4›Ϊ4ΫaΗςi;υΐžC°¦¬˜Yc«sΜ}€­}Θ ίό*ήύ[λv:>ΚββbnΌρF8ο<ΗJyδ‘Όψβ‹Μž={Ώ'ϋ¨Q£8γŒ3ΈσΞ;YΊt)όγ9ηœsX»v­š:{ΣM7QZj^€Y_Ÿͺ`σϋύμέ»—{οMu««γg?ϋ½½ΉεΚΏόε/9ν΄ΣΨΆmΌπΒ άvΫmμΨ±ƒϋ￟―}νkάΜwm­<θƒΔ’Y ―ΡΕAFΜgŸ ί„daοŠ<8s«!nK‡@ϊύ\{ιLΒΈm―IS™Άr&¦M†α-(¦xβ‘Θͺΐe*σΘΝιWΑBI‡hsϊ3x¨)ΊθκΠό­Ίšο;υ°Ι”jžΏΊΪΫxΰΧ?ηΑίήFOg‡λxώόωσyψα‡uυεn¬…/ωΛlΫΆ;wζ=Α Έκͺ«ΈύφΫΉώϊλ™7oS¦LaξάΉ\vΩeƒAžxβ Ξ;οή}χ]|πA6nάψ©ˆ~ΤmήΘΌ'2jLm‘πΛϋϊ"9d3ΒΔ7₯ΐΎ#―ΦH\¦g‘UNšΘ€φό¦Χibο{ύYb=V«#ΐv·@ˆš£,I„’tiψ˜gύ Μ*ϊμCΒθ`₯·U—eU\"Α[/<ˏΌˆε/=—χDkii:uͺGΟg|υ«_eΥͺUόα ’’‚K/½”™3g2wξ\ώϊΧΟΐ΅Χ^Kmm-O>ω$²,sι₯—2yςd]„ΰŽ;ξ`̘1,[ΆŒ‡zH%ešs^sΝ5jψΟTΐκκΈζšk8φΨc9ηœsX³f ŸΆqί-7꬀1%T\aΠω¬npL‹rlΞ™/°-ς ͜+ [νMΛ΄kΐς‘L ’‰ΌΦ²:JδzG–¦“έ₯¬¦lΫΧH_wא&YWWW^l8ΪQ^^Ξο{~τ£9ξ[TTΔ‡~Θγ?ΞψργΉξΊλ˜>}:O=υ›7ofνΪ΅\tΡE,[Ά ΗΓuΧ]Η―ύkΊΊΊ8τΠCΉπΒ ψΚWΎB]]W\q/½τgŸ}ΆΞGι₯—xγ7άrΛ-ΆΧ‡Y΅jՐο@Χ³d±ΎN`ώθ ςŒ”„Oΰ^\ “Τ™7ηύ“ΎI!§χ¬ξZΝ@Ν’šL’ [6/±\Πν,€ˆΉ@¨“ΜΔΰFΓΖ<φj­Μχ±ς-=ύκ²ΗΓ‰g…Ϊ “† ΊerΪMν(‡2`ŸΟΗ­·ήΚM7έd fF?¬[·Žxΐ”xσ?ψŠ’0ώ||>Ÿ*ΔΧ\s λΦ­cΙ’%„Γa½φZΞ<σL’Qύ;ξλλγξ»ο&sΙ%—pμ±ΗςYύΣ}μݝε z=Μ©.·ρՍ|’yZ6v­ιχY,` Β• b›WkΡH• %I| |εΩJΔ?Ή_˜H ’qΣKΝρs΄!cU°Π·@’ $ ²Έ‘Ε…i~=nψφΦ]ΊcGՌαΔ³Ώ‚l΅Tv~όκΥ«Ω°aƒγynΎωfξΊλ.΅βΞlάpΓ D£Q,Xΐε—_nϊr·oίΞΦ­)β‘3f°xρbΦ­[ΗΈqγ˜1c?ϊяX°`.ί8^ύu–/_€Rw ?³  ·«“Ώά{]ξΖ„²ΒRO3³QΈθYkžŽλή±ςΨnΆͺk+2‘o.¨Sl"™°‹XΚ³] aε(ΙΔτ¦ύΎFDQΗO›ΐeΗΞζςγfsΩq³8oή ¦ΥTκψϊ;ϋΓμhΥg’uΩΧ3~bή,‘HΨ2ΪφχχsΩe—±cΗΗs}λ[ίβ…^ ¦¦Ζτσ 6pϋν·)3t_’$<”R IDAT”Ÿ»gΟΊΊΊΈηž{P―ΧΛ’%KTr«ΡΣΣ£fnΩ²Εψϊ,Œw^}™kήΛ’Ψ’ΔŒ‘%6€pνΧKϋ)θωδεϋ„CΧ Δ m*Ά₯<ηo$(±˜³μβυΊ*`CΡhςTpΖ¬ι̝0† ?ή«)+fΡS˜}Hz~E>ΪΣDXΓΒ*Λ2ίώ―σž\ΙdΦ˜0a6lΰΌσΞ£‘‘Αρ| ,ΰ₯—^Rrr€­ϋ‘ιΣ§σΝo~3ησ /Ό‰'ςφΫo«–ΐ³Ο>«VΪΩ­όΪρκ«―2nά8•»ο³o[[›ε*ιχϋUξόΖΖFN9ε–.u&’œ:uͺš:l|©O?ύ47nTΫi}η;ία½χήγΆΫncΙ’%\rΙ%¬\©ο6τΠC±nέ:Χ^{νgΪ―κΈχ§?&’’ή‚Q…Ζ‡,1₯|‡eΧ !Cp!rψ +Ί’OcPΤ"Φdλ Z)Ν™ΟΗ’₯P°jLͺ!‘‘Α<(υ€#ύΤ“‰fΆΟ¨­ζΈi4δ’ΠέήΞ]7ώˆ¦ϊ=Μ:ζ85᧦¬˜-ιFΡDI’[Qͺ^—Χη' ζΥ«’’‚ .Έ€’’Σ—ρή{ο©Ε7ΌόςΛL:•ιӧ۞·¨¨ˆ… ’H$T>>H…ΛΚΚ8逓8όπΓ9οΌσ¨««γλ_:χάs»vν2Ε*vοήΝ•W^ΙΈqγΤςΰ€ΡίΫƒ$ΙΜώρκΆͺ’ ;»HŠ,¨ω­_©ιηΒAδ,>f„IBˆ!#gfHλͺΧι―·Δ¦>Ζ€ΐΞ°Dβύ=j―μcQ‹,Š2L}EΑΗ:!η‰w»ž`d¨`i.€Ή[ΰ΄ηD:l“Ψ,eΩ.V68ΡLIȁΕfhΚ5AΚn•ˆIc¨΄ά iΗ:‘q•e:zρηύ3Kς˜ϊ½;7mΰΤ―\¬¦ —„‚tDθ«—ΠΣΟα΅Uκ£βρxψhΥJ›->©pΑ¨¦ΎξΎe™]»vρΜ3Οδσδ“ORSSÜ9slS‰eYζδ“O¦ΈΈ˜εΛ—Η‰D"„ΓaΞ9ηΚΚΚXΉr₯+͍72yςdώσ?Σ»ψΌŽX4Β`?ǜ²H ϋϊ½΄Fι'ςTm‹p&έ/ΚΦ+»ǘ+#ύ^{ώώ σ<€π4°<_ `7‘ƒΔ@_n:°]v”Α2nURΔ€κ:¬aΗžϊΓέΊ=›φμζ•Ώ=©»¦Η#g©Γ:"lάΫ¦;)η]DνwaΑώώ~[‚‹ΪΪZΛΥφškαΗ?ώ±νʝίώχyμ±ΗΤόγyηw¨¬¬δκ«―VΓvcύϊυ,Z΄ˆυλΧσ:ήσuΦ½»"k₯IΣ+‹M}ψTμέYH% ‰ΆE …φΩ”αβ}=V;&²LΎ   ‹ˆGr°%Ν}ͺϊt} PRΙ>“«+) t+κ½·όwξχ&,{ζiΊΪ[Υ³|^ŽšT«S66ΆΦΤ‹ ΰšξ½JE¬ΜŠŠ ΚΛΛ-­‡#FΈ^€σΞ;ΏύνoLž<EQψΙO~ΐ•W^ɜ9sψb8Αώ>ž}πO„5”d# Œ,Xdή SPΞiΞε“ΏoV΄aΦ++»ͺΓμΆpλ>Άνμ΄LΛrή `3±C%E‰F1Πlq„’^³±Κ*ΰυ2{Βέσω%μΫ³Λτ’wνdεUs^¦ΧTRQRV[˜ν-Ί—8~κtΞωΪU&USS“eΪoUU•)ΐW\Agg'Χ_½k)vž7ί|“I“&ρΞ;οπδ“) 'CΙυΕpkήZΖκ7^UφΙ2Κ sςι±X₯svXϊΈuΪσΑσŒ²εΔd"oαζFkž†XZ–σV6.±žvΗ[jGΩB] ¦ŒEPΓC?ΠΧΛͺW_F±Ι»_²ψτvw©/¬0ΰηπΪ*•γ_ X]ΧH<™Τ₯)œ{ε?QY]γψRvνΪeI|QQQAEEŠx€°° °fΝyδKΛΐiŒ3† 60ώ|ξ½χ^:::8ζ˜cΈμ²ΛΎn—㏷ύN˜Fty–v]Κ³°sτΙ³ Π•XΫ€Ϊ³DΪ›M*tu°EγP€%΄nΩ›{AšβμσW,οzώ€±ΊΏ·­_ΗΞΝφ 6ΡH„Ηξώ_έΆ™c«)Υ$Ε’ +·λΫ<—”WpΡ·qœΫ·o7-΁ώΈqγ8γŒ3XΌx1oΏύΆŽAw¨# ςΚ+―0uκTξΌσNΆlΩΒΘ‘#Ώl—££Ή‰MΌ―ŠF±ί§Kw~»’b#]—ρ8³N@VY…n\ ζ`S€ΚbSƒUK0[v£,³\bΝfN°Žφ;ƒ jIΎ΅Ώ(* ¬0[5—ˆΗψψ½wHΔγŽ/|ε+/²yνϋΊ‡vκauOiKS;-½ϊΘΌNfκΜَ.€υΥM7έΔO<ΑΕ_μjr>φΨc:δ„B!n»ν6V\ΙgœΑ]wέυ…dη1voέβγΡ ©0Τ£δSόcΣΕΞτcϊ―bΨK8υLο$IY| νj’ »Q–O4ΪΩšΛ χ΄X(Α47:mŒž–: ³φε_ΐβίόB§,ͺJŠ˜9ΆZΧDςΝ-»5Tε‚’²rΞόκ•ΆΥ‚MMMττX’ͺΤΦ֚& Ηϊυλ™3gW\qΗwœcΡsΟ=Η¬Y³XΆl»wοώB’σJ2α˜wo'€Vuϋ©`Φq>!¬‹n7ZΘΨSŽ™β†m±žNb=C’a7 ΰeΛ‡‹οοΝγυh3ι{žP5Bgσ΄77ΡΩζž•§©a/<ώ.ς0oΒh όYL‘{ ΜΊ†έΓϋΒΣ™>k½ο3Dv D"Α–-[ΈβŠ+˜9s¦š5XWWΗ 'œ“֍FY΅jΗ<ηž{ξη*g`±“¦ΈφΡ…‰™>Šαrυ75ρ BξΚφ0„2c=Δz»‡$Γn@½έ‡ƒϋv›ίžMU v”„t;}°όΝΌ^ΈP–ΏτΝ Ω€™ΟΗ‘Ζ¨ρKΨΪάN_8ͺ›ΧώΟ―q€|Η|ΐ~πζΝ›§6Ψ0Z_|1Ο>ϋ,―½φW_}5Η{lŽbψbδ7B…E̘΄ϊχ@<‘[΅΅‹―α%ΙGήW#lΝz œ+υ'Σ_Q€­™„ύBl+ΓnZάΌœlφA€ΉkσLΈ€„„RΒ/Λ™ͺΐTf`έζόI(›κw³ς•ΉΰŸΎ‹,{$˜4ͺ‚-Mν΄υ]aΆ΅t0oόhυŠΚGŽβούOήg^RϋΡGρ΅―}Νέ545ργXνΤγ΄ο·Ύυ-ξΊλ.Φ­[w@Ίκ eH’D " ††Bψό~|ώ^ΏŸΟ/ΐησασeE()t](Ι$Š’ˆΗI$β$βq’‘pϊίρX”h$B,ap ί6Κ3”qι?_‹WMjˆ¦j,’άΕβ­€Σ.σOXXzrP'PE%c2Ι`sƒ“μ²Ώ ΰ]KΠΊΧΪ)2!-L±©¦Δ½$ΠSIΠ°sϋ^ώσ>Θρ§ŸEuν! δχ2sl―oͺK™y’Δ‡{š˜V]Iq( Ύ…3.ύ+—ΎHγ\ 7Ε5B~υ«_ρ‹_ό‚ΎΎ>ΧR{{;Λ–-ϋΔ„½ ¨ˆ1γ'RU;–ΚͺFÈQU**¦°¨˜PQQZΰtΒεf$…X4+π±H„π@?ƒύύτvuΡέΩAw{­ϋφTΏ›¦†=tΆΝν:κδ…œyω7³Β-»{H*Ξ!4α`ϊηο3w?gυΧ†%͏݄-•DœΎΊΝN²»ί ` πL_r$L¬§i…AΥεώF<#ΰσιόŸd2AOηΠVΓX4ΒΏϊ97ά•%œZ=‚ {[iι@‰€"X±½ž3fNQέ΄‚’b.Έϊ»άυί?Μ[|τΡGŸŸ}όΤιL9›#Ž<†‰ΣgPZ1‚`A>Ώ―Χ‡?ΰΟFlφ³Œ6sΈ,ΛCC””W˜‚ΔΙd’xXώ&σœ€n;uΖDY•Ν“ίέΡC}GγF”ͺoeή‚“9|ώΡlΠΠLμΨ±ƒd2©«Ϊ3šςŸ6α—eΧGuνX›w Ξ8›CηΜ£¨€ΤΩ΅$?α7"ՎlDnyΗγAφ€\€ςΚ‘2uǜršΊίŽλXυΪRΦΌ΅ŒΖ];IΔβx}^¦ΝšΛΩW|“cž‘aΑΞβ;3sQφ;Φ•xVΚΕΎή@`ίΏΠRHE·=ά²xoאd7ή"U˜γƒ„[χR2mVŽ·“MbΠL Ν―’‘ίOEμίi,Ύύ6(‚@ͺZpώψΡ|Xί¬κΞεΫλΉ¬ό°tŞ TXΘYW|ƒ-λ>Τ…3τ`΅΅΅¦ίu葇~*„>*`ԘZ™<•CηΞη˜//2+²"ρ€BB€šcΖΣM2•τί™ΰV¦q¦Φ\ΤΜ;YJ±τxd oϊ_”jπΚx$ΙEZmκ—Ι‡Νdςa3Ήςί―'‹1ΠΧGaq ^ŸΟ$V°½³Ίώ\ε†EΜ}uN8@>ώΌϋζ?Ήv„M@2ή1S±ξv’‘0ž`Θ”4¦“iαΒώڞ@W[+ψ.ωξΏͺΫf«fGk'½‘’$ΡŽςΑžfŽœ0ZΞ[ps?I—O°yσfK0nά8 Uͺƒ=&L;”cNYΔΤ™s˜|ΨαTΝλψX‚ώx‚ώX‚ώh‚HR!() ($A"-θ_:Θrψd9₯ $)ΥΡΗ#αχΘ½ |^Κ‚> }^Šό^MΙόΜ>ΏŸ#LΏ8–Tψ°©‹έ¦SJBϊ³@5DΈZaYλjύΜ¬: YΛ†~ΟΞυ«νŽz«ζ>y*€π‘Υ‡ρήNb½„‚crΠώZfΙZ+Bϋ-BVΌόΗ.:C]^σ'ŒfΩζ¬―΄½₯ƒΙU”f‰$―Ήρ–°aΓ.\hin;–-[Ά4‘rΒηrΪ%—3fόŠΛΚUΕΨZΪΈ26χGθŽΔiξ0O€Ys@Qœ™σ„f²*.ίCζœJRQA ,4<‘ršΫΑ+Ι„ΌF¨ ωUPiΰΥϋ32 κΊΨΨΦK_,aΚΣυŸφΉύvΌ˜ΈKNΉύŠΕ~θΏ­ΜςΆfwΩΒTΨτψΜG@*›¨—†‰u΅9ΪΊ‚I'ό©iK$³yΤιΙνσˆΗ’ϋ%$νΝϋxλωgωκ?_‹Η›BΗW–QSVLSO?²$Ρ‰±΅Ήƒ£'ŽA’RΚͺ°Έ„«τώtΫd…C.ΐΌyσΈπωύT=„σΎώO|ωά (,.±¬'B ˆ”ΐwEbt ΖΨΣ3ΐ@<ι°R“χκεΦΜU,(²2ηKŠctL$L$igίiΐǘ’5EAŠό>dΝ5$…†ή0Ϋ:ϋθ& J³žΒJψ]³[·@XdώY‘ώvΕ?ΪΟΪΦΌmwυ8dζ«κ­@xίJ&―Α P@’M5A$“¨‘ŽH’DΕ¨Q΄4ξ?°φΒcrβYηR;q x<Μ;JW°‘…iΥ#(/ ͺ+Δ)_ΉˆΧŸyšέ[S‘;’¦¦¦š;’ͺš)GΜ✯]ΕΌγOΤ?A-©ͺ$I$ιΖι‰ΔiŒ6%‘δ™Σ>D5ΝFΠΛΉΗ*6ލΣέgckoΆdΧ €³(χΕΒμ6d&„Š0§χ²zNΒα_£βζ7eyOνp#―Ξΐ±Λ96)LŸ[ΈiI O»™­dL»μμΘACΗO>`νnύ™ξr'T–3Ά’DύN!omΥΣnω|~.ΊϊŸΥ˜w¦εΆΞ׌Ÿωζ›™:uͺŽάsΈFeυhψ·ΰ'Ώ7έχgζ’ιdK(‚–«χv°’Ύυν¬kν‘©?’—πΕΝώ˜Mx'αΒΘΫ/lͺπτI;vΒo–3‹φY[+ζ9ΒΦJζ•&#œ¬#Ϋ_ ϊίάΘΰΎz;ύ,ΐCU+¬@…Δ`?Ρ—IιϋˆΔγΔ ΕD‡yτ°MΤνλΧ±μٿꢝ:c‚NΓ·τ°₯©#k°sœΘαι”p8LWW*ΜΣΣΓsΟ=ΗδΙ“ωιOJ^•cnΖωW}‡;ž~ž―ώΛχ™6kŽ‘΅Zκg žδγ–^ΪΡΜΚ†vw‰k²ή„ƒ)Ή‚o* 1–.‡€uYά_§"†Ž°;uΆ+ΐqΤΉΐP„p`[όcrλίG±v•£iYu5ΌyάΗ³@?4ϋ°wΫ: Ηζ~ΪΧΡΓ΄1AUΓN˜>cX'ν“χήΕΡ_^DQq π{<7e,ομά«ΎΨUu{™4ͺLσϊόœχo±ε£‰D"¬_ΏžΆΆ6ξΊλ.ή~ϋν²ͺN9b?ΈνNΖMž’—τ̍+‚φΑ(;Ίϊiꏀ―]ONJF§‰e6ΡΤ ΄¬”ζΞYa½*Ί6ω…pt=2„2ŠΘw0ώGd_±01„`z˜M&Ÿk—Žύλ”`"NχΦuζ•Έ©ΡŸ–UW#Ÿš R)Α¦%WρΎnJgΜENoZnΜί*γoΊπR¦₯~†ΨψΑjΊΫΫ†E°β±(±XŒΩΗ§NθΚβBv·wŽ'‘%)ζ0ΆΌDe23–ΖΊΤmΩΔΚ•+YΌx±mΣΠ‘Žq“§rωΏ~Ÿkoω₯#4 #RΧΪ‹ΣΠfMSΫ»ϊι%,&§db~«·cΥ“Υ‘±&Ή±Bΰσz½6+±‘RΟ΅…b\q³fΉdγfΨY"B£μŒΩν:Ž ‘λJ8)+ϊoΡΎ<άάΘή₯%1h™!ωπΰPΐζOQΑ[XBpdJzέο)^ήΑ0GO›^‘R΄α½έl^»fΨ„¬u_#Gy e••@*%θ󱫽[₯*ο Η[Q’.#N½ε™GΛΏ-+m9>ώΨq ΐΰ'V؁‹P:m–A`Pσ(εӍ,-’²€(5}e0ΘGο,'‹°E#a’Ι³ΏtΌJRV€Ήw€ήH) ¨AΚ Θ˜Λ>€κΪ±¬zνεaώ“Ο½οόvŽ>υ4Š€"Z°΅½—• ν΄‡c$4”PCa’–Ψ?ϊimά^24Ξp+ψn’ ΒlεΞώΊ#έ–pφαν€?-–gEωεF™XZ`yβ4J°G―ξβKā‘i%ΰΙ5’xό! ƌΧψ΅ g…t!ΓƎnf“‚΄‚˜0}νMϋΨ»kxqbΡ‘π Gžp rΊά΄$ } LwΊ-S?‘²4›£€Δ˜ρu‰$I,Όθ«\ύ£Ÿpς9ΰρxU…‘Y‘ΦννΰƒΖvφ¦ ‘$UρIŒ+/β豕L­,%θυ€+¨%PΈcΓzž}πΩγΡ)Υτ1$ D<‘€³icͺ²ρxŸCηΞgοΓR)ΠX·“CηΙ¨1΅κυ”ΩΪ‘&¬tFSVLqΠ―‰•5£iΨΉ}»λ˜1οHώσ7ws9η3’ͺ­V“¨οκηΥνμν$šξ‚œΉ§Ÿ‡ΖW3£ͺŒβ€OffΩΪoqΟoβιϋΗΪ•oΣΡΜξ­›YtΙεκnΕ]‘8}±αMT’ΠV|Π»$C~+³ίLψ%μ‹|άvυ5GύΝ?3ϋNΕ)<}jΆρ|αΦ}Τ?( λφίΐεωdr8 IDAT‚Γ‘b€Κƒ4½‘D?@¨ΊVηχ£YαtΫΣ» ϊ}Œ.ΟςΨ‚AŽ9ε4φξΪiΩ58ί±aυ*ΞΊβ›ͺΐϊ} Δβ΄υ ͺS·±§Ÿ™΅£ΤΏ=^/Υ΅γ8μΘ£ωϊχĈͺκΤͺŸ|A*‹ονΊ}¬ΫΧI\©U=£Μ<2S*K9uςJƒ~• ##&I%ΙΆ?βΦkΏΛΣχ{φν#ΙVYvwt0bT“f‘nY`k{HŸΠ σκ?•@ΔeŽ“ΙoΉς α ΤyΊfμ&μ'ΉϋY»)ϊfaBΪVΏEλ;―Ϊ]ΰΈ < `π–’·‹’)3‘}>Cψ/ΧΠώΎ·«‡ΚβB*Š ufσQ_^D<cΟΆ-$“ϋ·κEQ”$‡Ν?ZυΗ–—°©©]Ν Œ'S½G—©B^1ͺŠC¦LΛjρ΄%Σ‹³Ή΅‹e;ιΗU«G’RΌx‡”qό„j&W¦² ³rŸ:Oύφ­Zzθ‹ΖA’P„ ©oΡeE„|^u–K4φ πςζ=μκθ%œH¦ΫQ§~^ &Žfξ˜JΚBdΝjŸYω_ΫSά}γY½μGώ{νΨΉi=_>bB…H€ί#£ hι|Tν -i„I21_΅+Ί”~Y™C r²‡^ψέ”+.²ό2$΅NΉŽ€!ΉeΔ"εgͺFϋmΊχˆ[ϋώη“ŠΘ}’ LŽ0²Α~ε# VT™+€œΌ€¬5 €-ψΌjΚ5¦3…˜2s6'}>‰xœϊΫŠ’χΕ·5νeΚα³T@Π“’TvwτͺξΚ`,ΑΖζNzΒ1ΪϋΓ4υ°ΊΎ…χ΅M$u†Χγabe gL?„…Αt^CΦUB°gΫn½ξ{ΌψΨƒτχtη}ΝΡp˜ΘΐGœΝΕͺ,PΧ3@,‘ ΫΞj"n’…Ν7Έ‰ΉηcφkΟ)ΐ1άΕό%“σ»2ύ…5BΡΌb)ΝΛ_²»ΥǁGpAόy @"D^buΎδ@Ε“g˜'ιLjIƒΆdB}{ύa*ŠBόZΧ™@(Δ¬cŽγΤ .eΔ¨*%‰$Ι(Ιd 'p˜0Ιd’`(ΘασΏ„Η›κ-ΨΡfwGO―Șλƒšϋiι$Oκ\Y–©--βΨρU^S‘ζΘ—²ΑIbߞ]όύΑ?r珓Ά}ϋυΠwlψ˜y'|™Κκu[YΠG–[;½€\Σ«D i…έ ΩwR7V‘8‘ΰcίΜCδiq˜}G.v`_Ύl'όf%ΨςΗΫH τΩπ—ΔŸvcΈ°γπ"p’ΥU'KιΤ™ϊ$ )S $§[Κ‡΅ϋI2%!«­bώΔZ‚~­9-6‚φζfφξΪAGk3-ΝτχφθCQ$I&**fDU •Υ5Ԍ;„±“¦ͺy›š:XΎ£$9ηz E .ΛΩΟ*‹BΜ©ΕΈ²bdϐ€Ύž.^ΫSΌΆδ/μΫ]ΗpI3ŽΰφΏ<―λΫχξήvvό^Ϊάv3pΟ)λЊχύπ#IR€΅νΌ°oξ$ό:εšΣρ7ΏΥί ό«ρ v/ω3X[΄og¦­ούήazaΰOΐ—°Θ θX½Œ’‰3ΌΎlΗ t§` 3fΜ΄²9τ½ƒήέQΟΖΖVŽšT˜ £sLU ¨¬¦²¦F΅.‰x:jΪC–=ψΌ>d―7 F¦χŽ'Ά·tiΌήT›kΧ«ΛΣΟμ§ACWkZc#9B^VdΡτqΊ”ζΜΗνMMά}γyκή;ιξhv’ΞΜDλhifή 'SXR‚„„W–πΚ2ϋϊ#TθσAΤ­„Ι.„θF’.ΐ>£Ÿ>ΠOwM˜3Y „λ$…ζε/ΡϊξλΆ0παz§žaž#€oZ:.]mŽŒ· (Τ†I \j¬@B·"©°«­‹uυMτFbΔ’‰T+ |ιb }‡’¬b‘T_w0– c ΜG ­Ό[·/½‚I©DΦw%{Ν§NGqΐ―3ω;Z›yω‰‡Ήύϊ₯~Η6τθιh§lD%‡Ξ=2u "•iΨά&œ0·8΄fΊρw·`{ίή(@Φ+ΎSRQψm‹~rVekΑwb6^—p ύp~sΣ?ΦΥΞΆΕ·“ŒΪ.kώˆό±%€*MGρδ#¨ώςΉΘ²Gƒh­9½]― d9#|²njŸ]‘ JB ƒ ό>ό>…@ͺAΚζθΗ“ Ρd‚h‚Hξn·|9±(½C23EXtΤΡ9šVΨ‚/Ήf™^{+f&›ΚN#,3Ί„™ 9‚‹.ΊŒͺΪq|γW^dνJ}ί‚γΖVšTΖ<†{’Ι Ο]q#ό.ΝύLbO₯•`–» Ÿ_.\ “¦,v%ΕΞ½ύ2¦ΏI#fϋCw:έΦνiΩΦα9sQ!ELpPjΆC’· oa ‘‘£U @_€9›&?@'ΥaΜ,Τξk΅όϋ’\Νίϊt_}?ƒΤώ=α(Σ«*²9 x}>ΦxλQ[Χ­eΡΕ—αMχfx=Δ…φΑΨ°~ΆYΈΏƒ".αΪ=ȝ£Φ Dνyύρ~ΫT_;αOΖ—BΫϋΆd> €jnZ? R %#€γ­\Α}»)6 9CbR„ΆhF“Ο‘œκ+hΗufnΖ‡Χ‚wΐH^ͺΝ*3yβρ%!?# Bͺ‚Y3†mλΦήάτ‰Έώ`(Uΰ”‘{zT2Qσΐ&†„α\ž~§ͺ:+g‚O†³Ο₯πM~«U_8Ή1Β£°ΦηΧ³9%ό亍»¨{ς$Γv κĜρΐωψ&p΅•€’iΫGιΤΩ Cbœ“%] ½*Μ²9ί€d’`”έnf]Xμ―±.2Q‚΁SG•αM'C xΝΧ†”šΌίš·Ή‰Ωǝ 8yΣl}ƒ¦F²Άy΅VΠݚηf‚/™ψΕΝG}ΦQ«ϊ½ς1Ϋ''΄—Ή­€ΎO{ovέ}­„_‰ΗΨυΧθέΎΑξΣΦ΄ψ¬)A*7ΐ²QAb O°€Z-ˆΉ2ΐθ&Hj­€6F/D&υV›Jœ|,„_U6˜ό-‘£t‘HIJΧ@mYqζ*;y [?ϊζ†ϊƒϊΊ»((.απ#AJg+ϊ½΄ Dˆ=Ι, LLgΙ%W ΩdQ\‘eš mΎΤaVhΏβoεΨk&a*όm,gΟ3Ž]Ό.κΤ|ρΰωΈ ˜ΜΆΪ!ΪΡJaνDΌ…¦ kλ­‘Y‰uΉ9ρ{IΗ΄£Σθͺu――=PIIYY7AƒHΠMP[ZDί«n›0}oώύoCTܟ±cύ:ΎtΪY”UŒ@*pςΘ =ƒC«ά3k|¨έs…ΖάwΫ%X»›b“bμv΄rM¬¬ΌΘ=TE¦δzΐιΟ›ΩτϋŸ’Δmρ™ίΘΉβ9σq%©Ό€¦:%>8@ρΔC‘d9+¨Fɐq'ιψϊ2d£e!Πϋρ’‘ΰ(£Te^ad±‘$‰ $³oBI‡Œ-/Vʊ$ 6­yο +€d2AKC='œsΎzΏeA?mQz£q]ΡΔΥΪΡ’…/?Ι#ω˜ϊyχ0;Ξ‚ΤC29Ζ.Ώί…’‰˜<Α΄ΖΪp׍DZmAύνΐWΑΟΊ$Υ­δB+@0ήݎ'XH¨ΊΦΆ§ j‚ΛF…ωLθ;Pρ½U‘λN`LϋΥ"ι_…63Q{>$Ϊϊ#LQ’³j'Naέͺτt΄t%ΠTΏ›šC&0~κtu VΩΤή—ΣΐBΫΝGbxˆA­VK·Ήϋfν$ΠΔ œΓΔvmΐέD&œV;σeΧ’Ε΄½o)R€ή?ΠσΔsζγV`0έj‡Ζ„F‚Ώ€ά ΔΪΥV˜ˆ` ΣΊ);_2ΊθΩy³&?9™€HzΌAΉzl’s0Β‘UεͺπCB!>\ώΖ'n[·–E_ŽΟŸJWφΙ2ρ€B›!AΐkΠ }>‚o₯ŒlšωΈ2ϋ­:φΈ1ϋ…ί…ο΅ρvύυ”XΤnοgŸa•5τT °Έ(²Ϊ)ΌoEγ§α „τ!?«‘?Wv΅Φa`Ύ\_ld<Ξ>ͺ5€8μ% ωΌŒ,.HOΰ“πΞ ΣΊ·α +€hx!³ΎtΌΊ­"`kGŸ»>uy ΊρΧ{ωβ Ϊ,Ι^πΝ]sα&Ώ“π;―ϊΦ™€ρΎ6ίK’Άαό–΄ιίq0ζˆη ΞΗ4šy)ΉαθΤCEˆυvR4a:²Η«6gΟEζ54"Fa6& i}}m/kͺmΑ²ωθX~r„ŠEJY1}Lη@„‰•₯ψ½$$όΑ Ε₯₯¬~γΥƒ !hoΪΛΌ_ΦπBO4Awd―j³6ΡΈŠζοΌHC*²­5mψY'αΪδΟίηGϊYŸlγέ7ΣW·ΩIχ}xη`Νwl&ΥWπ8«βέx„ͺΖhrωe}ΆŸ.$‘ 7ω¬8ΉMo…Ικow-ΒP―9T“_±JϊΡΉR^fΏ)ΰ—ήλοeλέJ¬»ΓιέΜAω}šΐο€ημvHτχΠΌμY’ΊŽΌfάδw­Α(Hr^΄€VΖκͺnτ?sϊρŠ\ΛCή¬Ϋ§[…ŠJJ9λkWακƒU•"`9Xΰ£ΖΥ­ςΪmΒέ™œΠ}£ › ?VΫ…M΄ΐΦ΅i«žτcΛ~AΈΕ±‚χΉ΄,π¦ Uδ°ΡΔjΨAλ;― $γζKΆΦH5θ<μζ|6iΈθ„ΝφFb|ΠΨ;ÜγO䈣;¨yβŒ#tΜΑΡ„2l‚nͺ‹­LsαNδEšO@5ω1½ΐfήp’άŠ>AnOÜγνL~»›Θ0NΕ’l{ΰΧtoϊΠιv7¦e€T°ˆTΕ“εθή°š–ε/eΑ@Ϋ‘αŠ渁Πjρ – eύyγj vC}Ί©Πΰ BΐΊ}t‡£κdrζί΅¬~3’ΒΔlΧβŠΠϋЊ’\Έ\ρυBo}œQΰ…q ζƒ…©Ρ(\ϊvΰpφb•xœΊ§HλͺםNӘžϋŸθπ| @)2Ρ³Hυ0G[Ι$…c'Yfβ₯b}ΎZ3η{¨aB,βόϊtcc’–¨$[œ”½žΜώIE!©IΧ $ͺjΗΆo/u›6π‡ϋ₯EgrΦ•WαIS Η’ οξνT f„A’r}t­ΰΨRl틐;ν"«±ΐrp#!5q³‚oΛvμ@ζ©=ίΎ7ώNύίqΊυNΰJ’a( R Bα΄F΄΄JΒMυxC…©ςαΜlΜςόs…^_[€!ο_ί AΞ5υrιΒ%LڜIЍS]RHqΐG&™iα3YώΒ³D\­GΥΨCψώoξ¦Έ¬-»pw$Ξ¨’ε!Ώz ΥcΗ³}ύZZχ6Ϋ9z ~ηίψ΍ΏΘ‰6Ό΅§]e ή‘ν8μ‚3ί ¨Ι<4sEœ2ω„C>α’ϊXXΕο5ΒίΌςΆ?|'"ι˜κύ ©T_ΎPΦγε΄˜i§ϊλ6γ+*%8jŒΖ„Χ /νΗEN9°φs#g Ύ‘ˆ”nΗ ²ϊAΓCS|Ν}a«Hυ8Ό>?%eε¬yλuρύΜ’2^r9WύΧΝuΚi©ΞGš±ΆΉ›Νν}Γ$κYΟ]όŸaΐ Μxψ΅ώΏ’±D«>yΉΙ=Fε΄½6ΫΎ%κ˜Γσ(©Ž>‰/€ύHo €±vZΊoΧf|ΕeGŽ6w˜² cκŒˆ²…GR.˜hβ θpƒτŒHU ηVJ’D,©K*Œ+ΛvFͺ¬Mέ4ξά>t ο΄³ψΞO~Ξ‰η]DyεH½Γ)ΰΖv6΄υκ4ƒ@[ zξš.ιψ€νβτnέα`ζƒ3)ˆYhΟΞ°_υφIG :ΧΏΟζ{~κfε_EŠ―οΣ&lŸήQΌ ΨνK/U'œEεœγτƒΤήZP0Γ8$§P–Aˆ‘€ο0”a'ΚζΉέ‹eΩX}¨‰€ωψD¦)Iϊ3ΏWζΌΓΖSYRDsύn~ό΅ θοιΞλ3–―π'yςBεΧ Ds˜• τFγͺΘJ†W/ΤμzI·ͺgχiΏ†‚8΅ͺΉŸΧΚoΧ΅Ηd%‘ yΕΛμxΨUέN=p Πτi2Ο§XτO§#mΰWκ·“Œ†):dZ ΊΣ’φf~Z`tάΊ*C.] ²ΠΈ&¦ΏŽj;½=)±„Β„ŠbΥ0)*-gΒτl\½Šπ€skο²ΚQ,ϊκ•|φ{˜0}FŽΉί6eυήNVολ"šTτ5υZ°Ο!ς₯Γ1φ4ςNγŠƒr0k$jΥ­Ψς5&JΦΧΆ+2‘`χίpCζ ° 8Ψχi²O³θ^ŽΖΨΝΦpS=‘Ά&J§Ο6”σ ―3ύ ΎΌ±‰Ξ΄7„IG-š¨8P£ ½Ρ8# C”…όκ΅U=„ΓŽϊύ½=΄ξm0ε(«ΙρgžΗ7~t#'εb•ιG՘±kφuρAS­ƒQw&ΰ0’ύ¦ξƒ ςP·=μΜ|W9όŽΞ…Ήΰ M~ΞGξ’ιηܜψ}ΰ‚΄π©Ÿ1‘TΞτ §‹'Ν`Μi—ΰ+,64•ΝsΘτH»²#P#ΘFb•§ΐ έŒtβ2ε!?ϜˆΟ#λ”Qdp}»λΨΈzϋvΥΡίΫCωΘQŒ?τ0¦ΝžKυΈρψΑœ)Ό±΅‡[zL$?ρ—₯ε67%Νjο$ΈvŸ7Θ'²Ή‚Ά(Ώζ|ΙH˜m‹Cϋ+άόηpιΌΡi@p₯-0˜‘1γ}κ„FΥ¦HA%ͺ―α” ½dΣ–ΰ€Fnς‘¬‰>ΘΩnęȁ6*‘ή^]βμCΗHϋοΩC ŸΘ”Kκ9΄.KͺCng8ΖͺΖZ’Ά ήp ΆγjιςXαRX…Θ£ύv@Ÿe·#Ψ 4Τ±υί0Π°ΣΝcj ExΣπY*ΟgHτO'£νvLτuΣΏ{+Ύ’R#ͺ4δ½Ήα9σœcHΡΤψχZR°ΰΤΈWc ž ³’€"ΏO "ΙH²Œ,ΙΘ²lšΨ6a]sΛλΫι%,Yu2ί/΄>―MΪ«vί ΈΆKΞPΘ?΄₯ΈΒAIH.ύΌ_—ξ‹΅Π[˜ύλήcλβ_nv%Ο§pšx~‘R£Tτ Nξ€ §ωΧ…΅“τBͺUΊί3;s_ήj΄4βšΤbwC OŠ&vuυΣΤFPQ@–δlΔΒ°’· Dψ ©“›»iꏸ@{@rL³uϋF&_Εe{/I£Yi»Ϊλ3}–J)· o6ΔΙδO“Λ4ΎςWv<|‰WΡ»§+€ζΟ’@I|6G!p?pΉ›ΛgKΝ‰gγ RΚG7qτ!Γ΄@š“P–m2υ¦½l,Κω ©ΖπJ~Ÿ—š²bΚ ‚½²:7»#1Ϊ£τΗ(iΎ w1ύιλ[ Ώ“?/9˜ωV¨Ύ{_kκ»Ϋ?cρΔΓμ|δnZί[ζφQΏΞ}°c«1υε%sIΝiΊα 2B”Œ”λ·ηΣܚ―qh=φ†dξk„?ΡBΣ[/ψςSξJSy( >OΒβω*€Ει—uΰwΪypο.ϊvmAςϊ(¨›­k#pΘ;ΉIDAT9 DYΧέ`Ξg©«τ ’Π–ƒ”~Ire`Ua’\™όn|ϊΌ|~Η„ž‘ς†™1ζ f°ŽLύώς—¨{βt|ΈΒνU ?Ύωy–Ο£§ͺ&t΄Ž%Y¦pάdjΟψ*Α‘5ِžΦ%ΘD !@9œ>TΞ͐e]Xg<< ­π»Q f1σa1ύMβπv~ΌU/U^›Δ#ς;V›Ϋ ‘φfvΏΚA0dm.|}.φ§”PΨΧΊ2σ εŒΪcƒύ΄Έ’νέAλ»ΛτνγμGp=π_ι9τΉŸw @;Žώ‡T}Ά»‡#ΛŽ›ΒΘ£N¦μ°ΉΊβ Ω#η*ΥΜ7(M€ s|*˜ §Μ3τ τ~™κωΊVPMjSUy.aE3?sξΆ5oΣόΦ‹τlίΰΖά׎7€Ÿ€ΏΟύψGRe€2oMξRx(¨Θ˜ER4n²Ξ%™h€6P–M9…‚TΦ μΰρϋΣσW23 ίOσpΰBA’dλr\§σΜόΜθέΉ‰έKώL_έ”x^‹wwzΕ2ύ;_(€ΟΖNΞ[ƒΜ˜KυIηY­ΊpΠ΄6 Δ0LγήPž‚ΜκΥμOzΰΑxνυY€δζmqX% (Ί›λixα ΪΧΌ=”«#νλoϋG„TΧΏ"•AθϊYH²LωΜ£)Ÿy Εγ&γωνΛoEΗ?»φ:φϊ‘4N“:MHZ@i© - RZ*„„„=ρ/ !ΰ7„„Δ‰Nε€!N₯Ph+θC¨-Q›>œ4Ξ«ΆγWόβ0λΔ]―Ώ¦Νο#­Φ±ΗΩ™ωΞof~σŸΏ*ˆ*³[°’T%.†ιwhλ:ΞΎΈ8 ƒ½ΑtZ$φ]Α!hg͟šεΌB&Ebβ±SΗ™ύλΧfΖψ•QCψψr³6€Ν.ΐ§ΐ[Ν 4ΓC`ψQzφΤΫ?HxοAΒOΖ·5‚ΛηGw5iΑ+yφ6“» Αzδ ΤΙκ«τμu7•J” yŠΩ4™™(σgO²πΟ233Πύ5‰ϋ;jy―,UV`=yψxŒ5œˆΕΣέK`Η‘»π ανΐΣΣ‡5ΆO ͺWלD@Σ”γ­Νήά>X9/±\*ΥD'u7ύ8δδη“q–gΙΞΗΘΜDI\ΉΜΥq–㠝*ƒ40|ό$URΰ~ππ.κψ²':V0n#ΒΣ¦kΛVΜΘ0Ύν˜‘GπE†W&7 …lšΜτ SΧHΟDΙάΊΙςν9r‹sδ“ρfσρΧβππ ΚNΈοŒΟ‘& ί€3’ζJJmͺ˜†zzΒψ†πυβ‹ γΑΫ?ˆΛΣ…ζr·½³°^_ΚeΙΞέ"=uΤτu²±iΣΧΙ') ”‹E΅>ίή8ή‰eΰΤΔήi`RͺœΐFΔ‹Κ(|•f:tO ΤmΠΥΖθή‚θΖπ1Ί{1! —ΐΥεΕερͺ,EMS {9G1›‘˜I‘O%Ι/%ΘΗ¬ΧqςρEr·η›έOί n’΅ΏEeπe₯Š cΐW¨γ’R¨΅ι²\ŽWΡzFQ뙍Iυ‘ΰaβΚͺμΤ‰Η£ςHΐ η― ΞΦ;ŽrwDZ|¨Υƒ1Τ‘&― LL7g¬Ζ~•¦;d€jˆl6άΦΌ΅α(Κ½hχCφ^FΉμCεγg­« U@@p.›#Vdπ"°0P« .λΎQΚ―Œš₯/Zχo }w]¦Ϋƒιφ`:šγ²&4„‰Οƒ)nP‚¨eqͺ'Fg,Aλ†79ςEG”RsΚͺj]HBΏάlΘ›>ol^ωl\n7†n`š†c:Ί¦ah:™^7ƒRθIX @Χ4rΟ4±όΡ{ωtν*Nξ¬aDͺ‹+Š"š; ΝγMψhΚΡ5ΛV:·ζ‹ZΧ,x9X<εζΌιs“ΰš†iΊ0 Γ40tCΣπ™:ώΦO©{υ9J&_C·ξFfjΣό _„³iεRNΥο rΖ,z‡M Ϊq:'Ϊ֜YVU³ήΑCw–nu³ž)Έe!._ ¦n`&¦©c&.ΣΔ0 ]£`m‹žgOέ‚―#nK† cθ02Knΐ{ΥυL™9›W― ­ώCΚηΝ':d ο(iͺώΛώ²ͺšΖΦ5Λ4Ν x(σΪJ ·’I&έΙv]€‘ R£aφμ`ΓUψ;O26#š—žζίΕŠn'­νν)|ΩE΄l ΄ύ‚ιςožπPύ“h€­MώΣ* ˜αΙΚ«H:MΧNv&3ώ<Έ.Ή)v―^A΄§Ϋ²Ψ²ψEΌΊNωύΏΰ±E+ΘΘΙcΙo%7rŠ@.ΕSΛΩύήΫ}n2'–α>¦’ϋhγeK·Ν›‘­ΣFaψRJ].ΠšCΐw²™wVUακξΨόΗvΦρgΚΝά§_Β›’ΒΎuoβ3uσ ιl?O˜^/Α “¦m½§Ri[V…nTΈ³“ή+…ΐYJ%ΑI’t7+Ÿ}ŠώXί)ښWΜΘT:‚ΞΎΩC θ:ہΗ0ˆχφΰr»‘€‚`Ρd Ϊt K2άƒ³―4S%Α•‡( ηωΧοEγώOΎTΊ|v˜†5+IqιΈBΗ9TΏ—+J&αw|ώΡ.FŒ/’;a!ψ GaxSΖ0Ό)nέ›’ΤGGh”LEͺiP’ξβŸOώœ·lόΪΒ²aey2ŠΫt1}ή|γΦ4­[Ιρ#ΝLΌν.Β1 !ΐp{0ύin  αr!t%%J)”RB0f°Oγnž»w>»j7-Έ¦λtœ>ΕζΕ/’gζRpΧOiκμΑηρpί ΓŸuΎ 0S#™cII•R‚RψLƒ‰™>V?σkš6\V­–v²μo^ύwJfΝ%–5ŠΈmγΊφfb¦'G)yNΊMΧ4 , Mΐ˜Œ4&d£ιmΝMIΎβi`έ+/0<Υ zΡΈε―@9a°Ί#a [±^iΗz14σT «~χKlO*w?ώΔyΖ€¦iEΖεφΰσϋρωύΈάΥο%r`¦6PΆ“)-•“Φ"Ρ‘@ΨBρ³ν‡γ]‘ρiΓ ionεXS#-Ϋ73rE“ež†‰e%Θ1Šq%>~iΩC0}~ϊ{»9Ϋv”Žγmxrς‰YΫΙ)ε”h)‘–F¬hΟg@Θ([ϊ~ϋΦ{¦ΧΕCgΖGϋbδ‚Ό»όoŒΌ‘’ιwέΗώ=;±¬9ωΜΉχ! ―― $\τ&,:Υ@? AωΘ‚–Έ…eΫHΗs©Άm‚ΓϋHt‡kšJfFmΟ±FΊΒa/!wX!Ρξn6Ώϊξ‘#©Έε6*ζΝη±₯oqεΜΉτ+E¨/N,a“°l,Ϋ&aΫ$lI,a‰υ“2 .ΆCMG)EgΓG΅zλΪj΄%"•ξάό!χ fΟύz"F$t–EΕΏ9•’©Σ8qΰώϊπš·ΧpΝμ['TςlQާ ι訍ΒVηΑmΆ”tμΫAΫ†U»ίρr„Υέω-Oα8Ϊϊlr―šΜπ*i‹Ϊtτυγ;ΥΜ‹ήO_o‘3§UΘIά–N †¨°œΙ–ΨRaυυXυρΎ§J—lΪΣΊZθ$t½ΥΫ]¬Ήάc=9ωD,Eg4NB”Π6$#{> §+œ”ήƒϋ™rΣ· IK%“K:’g–RXΈ”6ΗΦVsfWνZΰρΦuΥ§Ζ(@”ΎΎYtμ|x€ι*ΗR’„%±€ΝώP”ΉχμΫX΄ΡΫEBJ,[bI…%Ά³,₯°εΈδΜZŽ­γ8πΘ…ψω–lm5₯K6EZΧUάΣrθVw0Σεœ0 ”Π°…FΈрӏ¦ι,xκyN§Π›°Q$Ξ9+…  JrfWŸ½ώ|―²­;€Ώv0)]²Ilϋጠ`yφ”YyΩ₯³1SRΡ\ntΣd|~―—Φ(q™ΌγβάU“HΫFh:RJμXmο¬βΨΫ+Ow[.k0qž —RGŒ“yݍ‹&£{ΌΈRΣΠ γ‚©$ωΕ€Z*%A*Ξ~Ί‹Άυoi>Έψ pτK0Ύz4+]ΌQl»o¦~”:zBq`\1™§βR˜l/²žcM„ξ#Τ°—πΑϊ}ΐkΞxφ™pˆθ@ΈxΝ5(ΨβΝΙW±W«ΜΙε*srΉ Œ+VΎάe¦₯·8 w©‹7—rT\ ν’; dAηΏΠœΪ.νε…!ΏΨώbSc&υ{φIENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-info-72x72.png000066400000000000000000000173031524161175200264670ustar00rootroot00000000000000‰PNG  IHDRHHUν³G pHYs  šœuIDATxΪνœwxεο?ο̜stšz·-Λ’‹dl\°Ϋ²ιΥ”dY—@Hv“s“» yH …\Β&ln —›Δ1&\ΐ@h‰)Αƒ±qΓΩ’%Y½œ^fήύcΚ™#Ι΄Mφ>wτœηΜΜ™φ~υϋ}υψΛϋ.βot_‚€(<€κ:F2@H1 €ώ_Hji@°˜L°€ςηY$F€N`;°Ψ μ·φe+T,Ξ–S,0\O  ψQ½>„ζA( Π‘Ω,z:…žJ€aŒΎnh^^°Ύ»ώ+4ψ°˜ξVoq9ώͺZ Κ*ρ—γ-*Ε*D Q=>„¦εΐIΔΘDGHκ#ΩίC’»ƒτP?£Tρπ π °ϋο )ΐΝΐ§ ‡pJ+(nžGaγL5“((―ΒW\Žκ+pžΰxϊe―θιΙΑ^R}έΔ»Ϊ9Έ›‘=οθuŸ2¬~΄ώ½t+π  ΤΎnΈq&ΥKΟ§°‘‰‚ςjΌ‘B„λŽBˆυ@J0)!!ΩΧΝHλ^Ίίx™α};ά§  Έύο S€‡€©€b3iω•5ž€―¨‘¨a‚upί\ˆHJ9ξΊ‘€G‰9@Ϋσ0Όo»ϋ΄ύΐη€M+€Ύ|Ο2Ϋhα"―XAωΌE¨ή‘TEqδ€Κ{χy Ά’T_·}zψ>πo™++νU-ηQΩρψύEEΥTEEQUTS|œ›‰<ήοϋ ΅&GeK’‘”©~ϋ%έ^FκŽπ(π`ψ£ Tύ˜Dό[ΰb-fꡃ g\‚κρ EUPUQLιE€b©™‚pώΜ}Ή’δ@tpŽ`ο'ΌΒ”LͺΧGωΌ%ψ+kή·#˜,^³8κ―P°X œ4•ιtE M¦ΤX€¨ͺŠ"Šͺ (ŠΉξ@b!@(ugΠγ€#ς₯NΨΚ*\dοΚήœΨ@ΩΌΕ Ώ·“Μπ @½ε—½τ}*Φh³!(žyu—~Ž‚ UCΣTUCUλΫT/EΥ¬X7.‚ΒΤ™χ±fnB(ψj*šͺ  0€uƒdΦ –Ι2”Μ K‰DbH“›2ρ({uύΫή΄/» ψGΰΰ'Pπ8p&BP:g“–_…―¨EQQ5UΥL@4λ[±A2UNŒ2νn/ςΈ&ဋœg”…i(€Π§9\d“xڐ %ΣτΖRtDŽ‘K0€D’l*Ε{έMχŸ_°OΫ\τ=\P|Βκ.Ύoa‰ €¦:ਚŠbξWl)²xH€Ι!Ž*HςzxT"ι,Ι¬‘GΚHsΝ«¨œUβωϋξaΛλ―’ŒΗR’yΌTN˜HΣΌ…œ|ΦyLi>xVηX4Ι»½Γ$‘R’[’tΰ·?wƒτ π©Ώ”ƒ~ | 8y“/½oΈΨδ‹ŒE1UKQI+›“,€r„ϋΜͺ,‚ŠC4ΐ@"£qλ8ΏGAήΛ½·}›‘~β‘ρh„θπέGΫΨ³u3―ώn [^{…IυS˜ήΨ@m؏GtΕR @ΡΌ„šHφtοj³95όργ΄άςH5OΈ˜†+Vΰ-.3 Ω&` EQQ„”j₯ͺ&a !,πL R-iͺ+τsbyˆ-/>GΫή]L>š’]Ρ$IΓ@6HΡ%΅oώώ) ]G΅TYX KΓ@ΟfιοξβυgŸdΈΏŸΩ 2±΄―ͺΠIš @`b=‘Φ}vLw °Υr*?’Š-2› ΠxΝ7O™‘#`[΅MΣͺEœšjωAšjξW5<· Μ―.ζε»~ΐKk`ΩςKΉι_ΠKρJkO/I)ρ© Σό j"B ²†$f€υ=vyoΎ΄–ΘΠΩL€ωΛΞδ‹ίΉςΪ‰lμ`{χγTφl~ύ«f[·N+¨Ž} Ί 8•-ηSzβ)fΘ E˜ͺe›raξ³%G³yH1S…yŒιη˜jςjL {ω?7ύw€•Ξμο₯vR=Σ›šIduFYΛΧ1Q5$τf$=R£}$IW$IwZ2(%xΒΞ»β³{U: Lu€•ξ£mœ°πT&U”ΡO1’Ξ"jκHυ3Ί€ εΐσ Sa_i%u—\kŠ³Θ©“ͺ„ ‚ΕAB‹€0MA³8Ι&j0ύžͺP}›Δ†΅ΉgJ%$bQN>ν ʊ´ Η­γΓ~ZκΚYX[ΚμΚbfW1­4D‘ΧCJΧ‰₯uΊS:E3ObΡό9ywΡα!:B’y‹ZπjΡ$Ί”€ <₯‰Αo‘°S4―G? @h¨»τ ψJΛΒβΥ”acσ.p„’˜ΝΗβ!!@―ͺ0­4ΔSwέΑ±φΆΌχvuRY;Yσζ#%τ&= +ƒΌ½φήzυevlzƒ#ϋφIĘV?‰¦ΚΌšΒp2K$!^XΙΙ'Ξδΰ;›ID£Ό·}+3žJστ©tE §2¦EυzQΌ>woAf³+ΑχΘtΊ„† ›ζR6o‰C†Š"Lpl‚VŠ*\’γ²hB1SsL΅BŽbŸ‡’αNžXy™t~ŠΩΠuz{˜·¨…ΚŠ2ΊcI }:7ΏΞOoϊ;6½ΑΞ·ήdΛλ―²sΣ›μΩϊ6…%%̞1Ώ—ξXŠdV'[\I}ΘΛώm[Πυ,­9ϋς«0€ŽHΒ‘’ΰ€vΌEͺΏ`°8l?2i_T!εσZŠ vΜδ8ω€ΕπΦ·bω.B±φρ”U˜ϋU!˜TΰύšD,:ψΨ΅ƒυx–%!Εγs\ί±N6½ς"?ύζ όα‘UΤ„ XP[‚"‰¬AΩεΤ75›ΧάΉύ;ήaJqŸeQm‡uς₯Χ"T«€pΫx– ¬ΔWEαŒ9Ν\€κυZ“γαR1aΗ^N fgΊγM[ρR‘O£2ΪΝχώœΘπρcΖοξ€εόεLͺ’+š@)©ΰ΄³Ξα΄‹.ετΛ―’±ωΊΫŽ"±kσ›ΤΟhffSƒΙ CΙ U£4αΰŽmΊN β€₯§sx8N4•uΌτ‚Šϊ·o"=ΨgηΡτŒ'As€f3ι5 Υ[`J”ά£ΉOq%,ι²₯I`žkΕBΐ‰UΕΌπΘCcΈΗΙY{½DG†Ysί/Ω4SKÌθ ΓU OœIy=Α₯Λω―ŸdρΉ"„ ³ς·`θ:sͺŠB ’κΩ °sΣJύ^ΗͺΪ†cς+έaΥ…γ©X‘eΦ)¨šHAy5‰n`œΘ(—ΰ²"OEΘό|ΕЦμh»Ύ8HοΞ-Όύϊ«ΤΧΧσωΟž;οΌ“ηž{Ž={φpχέwxι‰G9Έϋ]κŠTΌ€ ƒDV'™•D3mρ,άr'M7‘·——Φχe³Ό‘ΝπφkλL))π€h‘BTM3kEιRš’-€ΜœU•ΚSΞ΄o;Χv•Q5¬ΙBσPP^PKmr*‚ ­m)•2₯GZΏης7NŠC –Υ•σΚS³αΕί;7\Όx1‘P―Χλˆzaa!W_}5555μί΅ƒ—ŸzŒΏ‡Ί’€σΐΆΡHfuκη.tΌνcm‡A€WQQψTeTΞt8G₯5BP΅θ,ϋ°°M5φΩ^+&ΑWRŽ·ΈάΚmJWZAζΕ&φυε8₯Ϊ¬dΌ€œ#οΌΕšϋ~ξδwB‘UUUcς>—\r gžιό7ωυΏίΑ`o'Vαχ¨Ξ]ΰU>ϘλθR’ΠbCdi3v*,Bρ΄ž³bσ‚ΠB…φ₯Z― Pε=£–ΰ M€»$/#ν€…ΜdΈͺ(Μ―-Eτwqo§ΏϋXΞΜ™ƒΗ2έ†a°gΟ’ќΩϊΧΏΞδΙ“ˆ qoGS³*ŠςΉΓο£gί»Ξy₯•U#ι ͺF’ύΙΈbΥΟ0Mώ`2m z~žJU)ižη8P¨Έš fxBΕΝλp Β–ι.Β8™*!FY8kΥ«¨Μ­.₯B¦Έη»7³Χφ|s9gŽc±t]ηΙ'Ÿδώϋοw~?ωδ“Ήδ’KPM„ΧΧ>ΟΆ7ΦS_ "ΰCΚτ²0Ο­~ΐ”\Mcξβ₯Δ­ΜbCIν~•¨εNœ΄μ ’ι,±L–Q6ΖQ‹pC“½UηΘLͺ†'Tˆ€‘7πΌπ_ΊR¦ξ|¨ΕKΚ‚‰εTi:vΓWΨ±ι1Χ Π† X΅jƒƒƒΉJδ­·RWW@2gΥΟ~‚aΜ©*F`ς˟y€}Ϋ·š*.δ‚+―fWΟ₯~/₯±>vΏ½ Γ †—]όi ΕΘκqKrDi‡κνΫA ΰU½¨pž7#\$-rv²}.pl%«) ²¬‘–‚h?·\{οnykόβύΜ™h–u‘R²sηNvοήΝƒ>θSYYɍ7ήθlοί΅“gϊ5Ε~ΣΛΒ€²ν­‡œτΖWΎs;)O€Ά‘8‹&–ρΗΗζπΎ=&‘\x αŠjΪ‡γθV$ Gㄦ{“RΨφσ*„Χ‹ZΜ“ 9~5OζJx~Ζ†Z–6ΦrhσΎuν9πήψ ξ²2ΚΛ˝νX,Fgg'ιtš5kΦpθΠ!η·λ»Ž… :RτSkθιh§±$ˆ¦|ξzΎα&Ύσ‹˜uφ…lκ θUΩωΚZ^xt΅3πΟ~γ›μοΠŸH;y1 ΥηGρϊμέm *PTͺΧk1οX)Ιa–Ψμ \:g*UΙΓ?ω!wώλWθν9nΡάάL(rΆί}7G²7ndΥͺUŽ5RU•»ξΊΛ%E;xώ‘Υ<3ΚΒ3<Ÿί˜2‡υm}DY2Ίδθ‘€’IδοώOi%’Vϊ>™B‘ΈΖIšul!`Fޖ؏-»ŒU― Ε!–Ξ¨Η'³lyεVμίσ,Υq‹kMMy qΩe—1ώ|š››Y°`A^yzρβΕ\sΝ5¬^mJΔϊ~Ο)gžΛ”Ωσ¨ ϊ8MbH³ΜΝd™°p)σ–lfκμΉ,Ύθ2ΆτΕHeΖ©Ψ2ΖOόA»­Rsu€PΜθέV)]κ”»HΐγaYσκŠ‚Ω·‹§sοΌ±ώCؚ›› ‡ΓΞφ\ΐ9ηœƒ¦ihš†’(cΈαφΫoηΡG%“Ιp΄υ λžy’/Ν<Ζ’έ‘dώ€% U³δ–»ρ{^>:H,£#Fi…nGUΊ“δT\ύ4HCΗΘf\εώζ‰•Τ‡‰$R¬y-k]‘λ­'―¦†ΚΚΚΌλοοgΓ† ¬\Ή’λ―Ώž––„ά|σΝ€R¦°{<½φZ¦M›@<αΩΥ’Œ 3Ή8H@S­1nσØ·]Υƒ”žˆΫ[ΆυH]ΗΘ€r–Λ:_‚Σš9gφTΞ;q:±T†©KΟΕ }δΞ‡ϊϊϊ< vψπaΚΛΛiiiaŊός—ΏdΓ3-ρӟώ”φv3°Νd2ƒAV¬X᜻iέKl~mε~΅a6Έ}Z9n߈”2ΏJkdb{³Cq΅ad©„£ †₯RΥε$ΪήγΕ5ΥΘΠXUJ―ς©/]±ͺ¨pΊσΨΎ}ϋqΝd2άqΗ¬]»–•+Wrλ­·ςτΣOη©ηͺ»~L,2Β΄!―–βƒ˜β8χΤ³i²Ρ{³Ν&ι,ΠidR΅ΩDΜβg3„R2»ͺˆgο]Ε–υ’qζlš›gqΈo˜κΉ‹¨ŸΡμ8cf™6m₯₯₯ζM³Yή|σΝχ=ώΑΜsG/ΗΪΫψέχrΝ7n’Τοe(™qz…€ΛΠ8ϋ\ˆγeΥɜžG[‚Rΐ#2Ρ“†s‘P€ΆVvΌυ&RJž]ύΩTŠλͺ88γ²―άπ‘$¨½½|ο~χ»\ύυ<ώψγqΰ‹k1λτŠmOΜBαι‘cΉIJ€‘%vΤιύLqΝΠ6`YΖΘ2υ₯‘­ΫI[NΧ;^cΟ;›™ίrοuχ3lψXzΑΕ¬_ϋάχΪΑO<ΑΓ?L&“γΕ E!.€΄²ŠPQ^ŸΟJteˆ1ΨΧΛΘΰ@Ξ7³Ύλ§7#% $2&‚‹ξNsš#μ Iδ°γύw#6@ `=πυΜΘ ™hoQ)=ΛP_~―Ρͺ»~ΔΜω'³dκdΦlΩΓ©—\Ξ–υ―FίΏSJ"‘ˆσψAN8ιdζ.n‘aΦ\Šκ¦B#–1»<²†n˜@MQπi ^ bGθΪ·‹έo½‰ΗηγΪύ6ΫΊ‡ιO€r­zΛ<‰r6¬zΎt…RJ†φl³χl†έnσ> –fFπ•"€TFγ›τλbνc«ω‡ΎŽΩ*9ΤΫΟωŸΉš§XωΎ)ͺJyU5Νσ°μ’Kin9‹φ‘]Ρ{3:tGΫ£θ°,(Gžx:MσΟ$θΡx΅/E<w,’€εVŽ΄Ή]—ΩA8’ΤP?‰cνφ­_n€z€w²ρHKz°—ΐΔF$’ήXœiΥΥcόwΏΉ—Eη^ΐΒ)“ΩΧέOέΌEΤM]GΫqΤͺ “8υ¬s9ϋΚkε΅μˆςJkO^eαxR—γkΠ†Ή˘υ{)Ιǐ†υν–¦QίvDο ϊ·npΈΨ9Ί.ǜ\r†πxρWMDυωIλ’)%΄ξΨJ*‘Θσz;;Xrξ… ΨΡgZY˜½[7;:PrϊEŸζW|ƒ™|šύ Α±Xd ΓκPΝ7ΏζΰF ΖήοH”pސ₯Pλο#=†αRAK‚φ?τ32#ƒ`NgΈ ˆŽ.κΐgτθˆ78©OΈ‘(d½~j ΔsήΣy” υ ̚5‹ξH ₯€‚TG+½],8νLόκΏpβΕ—Ση+’'–bjY˜ω5₯Τ† LdHκϊpΖsιœYΆp;z‘.[΅l σŽ^JbG[9ςΜCξ–α'Η«¬K€‘7j‘bόΥͺ‡XFgzS3ρcν ΊRJΊΪ³τ‚‹¨)-fΫ±A¦T”‘'γ\±β^ς"%΅τ%3L( 2Bν[7rθέLih@υxθŠ$de€S•žpώͺ EA—‚Ύ€ΞΔy§ΠrΪιΊŽΧW@ ¦yώBϊRFιM„ύ,¨«’8Δ³χœ}ˍή·›L:M6“ax ŸSΟ<‡ŸΞH‚¬!·t€ΚIBŽKdž4Θ<@Ζ‚c«–‘GψωάΣΊζ^"‡φΪΕνξΉΗ3_~‘ΤΙ—}_q9Bυ˜m/š‡Ÿ‡βP€ΚΒ0R†“ϊbIP|^§M―#²wχώπ{τw?Χνϋχ–“–žΞ†£ύŽyiU?Η³½7_m\όβζΛYΤmitGƒθ‘μψΙ7νψk—Uξ‰|PΥΰr™ΝTια†&Σ+fM[—‚TVg ž’g$F_4I4•1n( }^ξ»ρ+yœ5zΩϋΞΞ½ό**ΓZ‡bd 9~Ϊ{TˆGΜn`l2·•9©rTΛΉ -xψ’mlΣ~3°ωΓφ(nΎκ?¦xΛ«ρ•T „vW©E sam …hFηδΉ³ΩΊξΕγή :2ŒΟοgή)‹Θέ±δK&“ž΄-]†;%leΖ€c©«!σUKJΙ±υkι\χ2›±Ηϋ΅ΔΩ4sG(jš‹βυš «ίP(NS¦½_ZjκΡ»Ϋθ:rόΙ{ΆΎΝ²‹?ECM5{ϋ"dtΓR ‘ημα"qΓςŸς‚γcx^Ψ!%ΡφC΄>ω€έY°Ζœού@|ωΰp6‘γΕΗAΧ]5{σΫnχ7\βnΚ`χ±~Ξώη―:ν'γζ|)~{χ€€,ΰ—| «Φž5 +~r$Λp›‹-'q\p ƒl<ΚΡ΅k;hc|70nοƒΒšΩ“ˆw΄rμυηAΧΝΆ]k0–)Ά>†9€%ΜEŸςϋή`ΞmH)‰g²d +H•έ0ΠmPά³ σ€ΙwtηŒŽ‘ΝΠωΚΣτl\gίώ ΰ–γƏ £ΐUH©νήJΆ H]GJΓUm5rώ‡”yV₯c8Κ€%gQ7uΖqopή•Ÿc(™aΠ.ꍚεcƒ’£$Ζ%59HζD7ηΘ»ιUξ7NΖΧ€ΐ;WΜ‰ό γμDχQα †ρWΤZΎg:BA EX%$‹›† …ΣλΩ²ξ₯ό ku +n»“g\Θ[Η†­&οqβ±q-Y>Y£QqΚ™”/8O0„°'Οε}Ϋλ ͺ¦R ( …ΑÁΑ(±TvμSˆ±IχόL Ω‘οζc«6£sέ3~ΚΙkΗ€kν`τƒ–3gυ@ ]?#Φ~PΙF†ρ•Χΰ MkŽ%ŠΣ‡'„‚D!–5θΞ*IdθŠ$Hι9‹dwZ8±—` Ÿε"rΛ/2ŽŒωc¬γ0‡Ÿz€Ž—Ÿ²Ÿ½sšψΣv° €?{€‹“=ZΌγŠζ#P5ΑŠΥγR=„‚βσ!₯ΘWαΚZ"—μ/—,έu<Αξ7ώHλ“ΏfpΧΫφ3·Z„όςGθ_:o~²ΦDΥ$\?ƒš³>E jBΣΜΠDΥΜY@šΥ[€κσaO\―ύδxεΰ1Ϋy=”9 β]myφavnFOΔμΧ[’sδ£π“z5Ε£ΐe‘iΑBJf/€ζτ‹ρ•"¬ωcBUρ†ΒΞΤ†zΫΒϋδ`]Žjn_::BϋσΠ³q™θ°-žΓΐύΐw`Ÿδ»;–χYύŊκR:ηTͺ—-'PYƒβσα †Η”>ψ1δψ₯c‹Œ’ύ=}ιIΊίxΙ]SOZyοYΙwώ²—VXqœσ.κ–σ)›ΏΝη7{°-ιΓ ŽCC’MΖιίΎΡ|©Ιήν£~›1§―κ“Μ_λύAeΐUVΈλέEΣgS4}6α†&όΥ“Π V@XΩ†ιΏ %RΧΡ“qβέG‰ΪΛΠΎŒμί5ϊ~`π,ζLζώOj ν7P•'aΞ9˜=:ΌQύ|%xBE¨ώЦad³θ‰8™θ0©Α>›lG/Y+ΐ|Α2Ϋ-οψ]ώ³ήaζΑœ Rωώ² ¬i˜MμfIZ!Ο6Λ*m³jyέV²λ―²ό-ή‚§bN\σYŸ"Μ™~•˜Ν€ar]oCν˜/zKYŸ„U’ϊ«/κ<(/(ΑsIENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-logo.png000066400000000000000000004722131524161175200257120ustar00rootroot00000000000000‰PNG  IHDR’ΰώ?Gr„iCCPICC profile(‘}‘=HΓ@Ε_ΣJE*Rμ ␑:Y•β¨U(B…P+΄κ`rι4iHR\Χ‚ƒ‹Ug]\AπΔΙΡIΡEJό_ZhγΑq?ήέ{ά½„F…iV`ΠtΫL'b6·*_€€0βΛΜ2ζ$)Ορu_οb<Λϋܟ£_Ν[ π‰Δ³Μ0mβ βψ¦mpή'ް’¬Ÿ›tAβG+-~γ\tYΰ™3“ž'Ž‹Ε.VΊ˜•Lxš8ͺj:ε Ω«œ·8k•kί“Ώ0”ΧW–ΉNsI,b D(¨‘Œ lΔhΥI±¦ύ„‡ΨυKδRΘU#ΗͺΠ »~π?ψέ­U˜šl%…@Ο‹γ|ŒΑ] YwœοcΗižώgΰJοψ« `ζ“τzG‹ΫΐΕuGSφ€Λ`θɐMΩ•ό4…Bx?£oΚƒ·@ίZ«·φ>N€ u•Ί±"e―{Ό»·»·Ο΄ϋϋTαr›‘Ό”ΣbKGDPPV”άΙ pHYs  šœtIMEδ9zοι IDATxΪμέy˜€gyίϋοοy«»gzI£Ρ‚φ]BlB lŒ±˜Ε0«X ,ΐΫ9v'>ΞI,N|8x»μΨΨcbβ;Ζ`vd a‹ΕbB  A„–Y»«žϋόρ<οRΥέ£‘˜‘fω}|΅Ί«κ­·ͺήͺ~ΫσγΎοΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜΜμ$³CΠ{h•ίvQΏΐXΦ ΐzˆy`ii˜#b˜5#@H#"„ΚΞΠ”Ѝ˜!@š“zy± #-Λ»ΛΟ±“Θ;!ν@Ϊ μ–hŸΆκn£=s©<Τ―.Όοc~ΟΝΜΜΜΜΜ’ΜEού£l6Τολ‘ΦC,B,@ZΜ• ‘”—‡η†48]¨ίΎ»9Aάή&?”π*r*!ΦΤmšΪχΰ±»Λc€ΫQμΆ܍ΈtO»όN>πΞ’υ»%κε|~ν·ύy033333ΫG$™ŠώΏί„…Ν‰Όi3‘7›‘6‚6Qͺ‡’$.Š>·© L fR_ώΣ–3E ’4’T+’²jِ¦hjΖ}ΒCB©T Υοe_¬<‘ϊT’< e"Ά!έά ρ}€ο黐w ž#]ΈπΤΛύ133333»$™ŠήϋŽιj Θ ¨•AJAG›€ΝΐH› Ϊ€©žΊ„IέuDΤ )ALJ(TB)J`”Ϋͺ’6pR}Jƒͺ§@΅œ(ULΠJ}d€Τ»"ΚKˆ JQŸ[Οu'βΰϋ οBάϊ^(ΥΧ—&ε©_ζpΙΜΜΜΜΜμή8H2;=υψGWΤ€Eε;ԐFΐ0—jSΏ4bpΔfΰHΠ‘”ωIt3€R ”€H}ΰ4Σ'₯ξρΛ}-k΅₯­?κžWyžƒVΈξLU‚%DZυ•KΓ*¦ϊ8)•E εώI³ά|τΠ·!ξμ ©Ϊ­žϊ2ŽΜΜΜΜΜΜf8H2;œΌϊEπφ?ƒΏώCΊΡGέΩ «`Ju¨vζK+œŽ†88 qs5 ‰ιΣIΧβ]km€₯Αζ ₯’>EέΎ΄¬ #4UΥ†Im+\{ϊκg;u΅OυnipAέ ™ˆ2\L(C½ΏΊ ψ6;—neύBžzi—9X233333sdfπΧο,ζ3ν_ Υpi„4OΔV€­GQ*—Φ ξT*€VT$1˜―ΤΝG’Ϋ r?'©έ>r €©p¨δB3ηΆ2Š~ΖSDmΉΛ³η»ΤoW[ζBΠbi7π]ˆΫ€[ΰ¨oΑχϋ‡jζ!α)/ρηΖΜΜΜΜΜ;’Μluοϋ#ϊtiv%6Fˆω.­mΆΦΚ₯-ΐbΈL}5¬έ·Ξ1ΥήΦ§¬ώΦE₯™S£¦gEE_©TφΥΤvΫΚ—Ύ…t ‘oν¨Ο ‡Jfffffv¨pdfϋΟϋk+\—ωĈˆŽ!t Πξ!M$IύΩJ© db0;©­Jκ šr©ΫG Ÿ@u•MέipVœi‡ž9Uw’©₯P©ήw7‘»{€[€oBΎ₯]+ŽΝ₯ΦmfffffIfφΐω›?\ΈηΨtΔf€#ˆ|4plY Ž#AσΊUΰθ‡pG΄«³΅Λ²₯φ†Aψ”J”jL΅}.1˜Σ4¬nJƒ!έ θͺ‘ϊκ&u―aΈΚœPi›K#`p'p'βfΰfΨ|ά=}L(™™™™™ΩAΔA’™=8ΊP©ζ5γέΠ,l.•Iڊ8Ψ Œ΅R ¦Β’Ύœ¨_­­ζέΆ―εΏMW΄eτi0M―4§Ύ½mduwR?Ώ©<—a˜U‡u3BΊβˆΫ€v¦νέslΘžτ".ΜΜΜΜΜμ€ζ ΙΜ|xgωޜ#Hγ#AΗΗΗ#Άl*P7O‰Rƒsš@‘Ίl©¬Θ&€$Ε`R ‹¦šίΊ‰άtv­pA?£©},%bR§}k8 »ξ»φFu˜7wί₯΄Ύέ‚t#°£<―2.ʁ’™™™™™¨$™Ωε<}z*ίΆΗ<±tTYύM™ˆ˜š»]Β›2˜› -υ3•Ί6΅™ ©—Vœ#»9Lƒ™K’κύS•4x|jœΤ%TΓΚ©R₯c€mH·AάDΔΧΙ/Ή‰Ρ{ϊGβ ύy03333³Šƒ$3;p΅•JνΩJ$2'' IΔf€†’ζτΥ@m+Z„ϊQF ζ΅ΑPZ΅ASΫ™6XΉm8Τ;B€TG- [ήΊ™L}el»]WΜ€9`3°±ά ιZΔ—GpέΦ”ΆύΔQΗpΗςvDß\π4ΜΜΜΜΜμAη ΙΜ||g‰a&Γ³—6§€N‚|,h3°]…R&H5X*D]uΠΤiP3gΕ銀r‰TΒ¨θWϊΰ© TŒ΄ςρΊ]€ ˆ #Κcθ6ΰNjšΏxΛρ_Όmχφeg%ΠzηOχηΑΜΜΜΜΜ4’ΜμΰςΑ?™Ή’X>₯“§l6Υ@¨ΆΎ₯Α*k+‚$V―š9GJ©_½mΈjΫTu“Φ8»φ#€ ΐ"hκΎo­;­inxΪζ£~gϋdωFΰφ@wȁ’™™™™™=Θ$™ΩΑι-―·ώA©VЍ³ΣfΘgœ…8’ΠFˆy€<Θ=sΤΟ1κ†m“V΄΅uΥH­œ?D3sZ-χ-L%Π*χKƒ ¨¦V$ ƒ€υˆυΐΖ³›ΡΆ'lΪό‘₯œ—λD|-ΠΝ‰|g0ΚQwι–733333{ 5>fvPϊτΥεϋ;ώΌd3< `7ps\Cζn”2’©AΟ\ qΚ,₯.Π‰Z_”@©Ԟ™Τ…JΓj€z©†QƒPŠφ2ΤΑKiπ˜ν°ξDh1_ζ%ibΞNšΫ"νΌ;/&•ΦΆζkAώ²ˆλƒΡέsΚγT»ιξΈαfώΗσ^γρ™™™™™ν’ΜμΠτgΏGY/ -ηΞ…84ίM2ŠZ¨΄b€vhΕά$i0oItαS·£©Ξ9ΉŸ―ΤU;A­@ZDΪ€R ‘ mmΫpVΣάuρ¦#>²”σΒ*―$βZ‘ΎΤˆλΪ6'S΅{Ϋvώλ<ǟ33333Ϋ§$™Ω‘οCοͺ?0ΩΝΉΐÁ-ˆ9B}›―bpjŒΑ$Ϊκ#υj™ι0©­8φ ͺ’ϊJjΚl€vΰ6‹ Ε$ΕβΩΝθξ‹7mϊθRΞsεƒΉί5Œ Τ$4jΔWϊϋ‘tύmK" ‘Έυ«ΧςξΏήŸ33333Ϋ'$™ΩαγCοžϊΦηmEŒ@΅εm8'©SnmG‚”!ϊ‰`^ΰώ “F  mkΫzJ«Ϋ:ΊŠ€ΝkU$!‚@ˆD#š4’Ύ:BŸMβFI»šRE&ψ«ŸϋnψΠίϊ3`fffff?IfvψΞQJižˆσ€GΗΧ₯ΦϊωH"­mi8,‰&u+΄©T!Υm4l‹X’V$±‘~΅σ‘AλPͺA¦.H)9ΧIΐ„@$D#)‘P3‚q_Mπ©$έzWΊkΧΡq£ϊ ώΓÞθχίΜΜΜΜΜξ7Ifvψϊπ»gΓχLώΟΧχ{offfff{ΕA’™Yλ1‡ΟΉΆΌ•"$`#p!e•·c€IA ‰ΊJ₯ayΊj₯rm;{©–!1κZΫJ ΄©Ά}v3ΊλβM›>²;ΗzΉΞCJr; H εvFRSΆ)αQiyS"$‰R_ϊψ†ˆv)e)ρ/Ξ{Όί{33333Ϋ+nm33k}ώΛεϋ―b mώψ4J;ΛΥ”ΚBoP*’r7 ( C$ΊήέΟџŠΫp*†· ·«³Κν|€*ϞΛSϋΜ4|œϊŒ₯,Iι%»Sσ\©Ω*ΰW―ύ ξΪOϋύ73333³{εŠ$3³΅όεοΓ†υεηQγΙιΰρΐΓ(…?1}>ΥΜy΅]½mΨήHσ”ΥΪ6ΆΆ)+·­6œΥŒξΊxΣζυ«Ά•ωG₯"‰a˜Δœϊ*€₯€2Hi€D3hskDW”H$Έ=)}n”š«#&;–Xf=sόβ9?μχήΜΜΜΜΜVε ΙΜμήt«» 4šuΐΐE 3ꍹΆΓ΅s‘ΪσkBΚ΅Nέtmi6H’Φן대ų›Ρ]—l:βC»ςdaϊ€]fiGWκ$F*osR7#I$’`$Ρ¨!5H*Χ' !‘RZNΔΝ‰ό1Τά0ͺϋύGg?Ξο»™™™™™­ΰΦ63³{sΩΛΰo>F)jvΧβo nοƒ" Œ$JˆDͺ§ΫΊͺ[¨ΰVΆ.φ}+Zii›ΞϋΛΚm³Χ)uρ*·K€ϊ"rέυΜvP2­9‚3`τ‚„ž.8RΏυυOσΦ―Φο½™™™™™MqdfΆ7ώΓοΐS.‡oάά^“)σ“>Ό τΠΠ”,G3σ·£^ˆrAš>wΪ ˜ž©4ά4Υο₯]NDP₯N)ϊΰ”!ά+šξΪόͺ T: xBΐK!Ώƒ€Γ$33333›ωχˆ™™έwoϋ—π°s‡§ΣEΰ4ΰ1ΐ9BšΤ,(ΥPh*ΕJ‹ λ‘V‘΄œσB™³Ο ΉmmkTς«9ΤΠVΎ+Ρ΄s‘”hPίΪ¦Dj·+Χ§„r’ξ|ψ π½φίrΦE~ίΝΜΜΜΜs’ΜΜξ―cŽ‚Kπ?k{:MΐfΰΡΐ£ΆNυͺ! VQ›/α‘6’X_ƒ¨EˆuHΞJΝ]o>βCK9/΄s‘ !Ί…ΪJ>%b&HJ$ !‰9%HIδ¦έ.AR;|»μ§‘j¨Tt –οPV¬ϋŠTͺ <σ1~ίΝΜΜΜΜcnm33»ΏΎϋ}ΨΎ½ΜP*2p'π)ΰ/€/ΤE₯O¬T%ύp$˜­4R»ͺ]{Z”EΨVT%Emmkg&iπ]΅Κ¨>JDDΠ―΅΅MuEΈA7^ωΫ°œ<xš”ΆDoύϊgIs#Ώχfffff‡)W$™™νKyχπμz$Dzπβ€:e;΅"iΈj›ΦΞj—l:βCKy²Π†=Γ!ΫνεΪ–Υ*€1R$΅mk1h_K$‘šΒV+’ I’)uLmER+;[€OHιΊvxχΟ=τGΘΛΛ~ΟΝΜΜΜΜ#H23Ϋ—žς’ς½d-wρΏ€Ώώ7h'ΔBe‹5³ό Ϊ%u§k­œ“Τmœgoͺuƒ₯Αˆ¦αζ%t"D^­BͺΌ’Eΰlΰ™ Šr™τΥOς²+~ΟΝΜΜΜΜ#H23Ϋ_>ς§ƒ3­6ƒ.€xp|°½T$ΡΟHλΕ³šζ‹7ω‘εœ’kqkΏOΕC4*ƒ³G”%γA4€AERͺΆΛ0ξ2#©Ά]ͺ™RΤέΞP’τ¦θ._ΛπiΑ·Rέίηίaώλ›ώ±ίs3333³Cœ+’ΜΜφ—§Ό”€oά “O |΄ 4‘©yά]υP?)κςoΤφ³r]»΅nD{cšήλ`ΡΈΑ^cPλ4˜‘Τ†HΓg•!6#=²‘žœΥ;ξ`’3<ύRήφυΟϊ=73333;Δ9H23۟.}1\ϊ΅§ά1Δ ΐΎ]†oΧ%Ρ†5’]ΆΤf9Γۊ€>η‰˜jV[qͺΧ*χ―Γ·Q{M ν†Hεηr{σq:πŒs:κβˆΨŒxΫυŸχ{nffffvsdfφ@Έτ%”η‘w3„²²ΫΥH»sύΖ±ζn’›cΤV(υ•HκΫΡΪΕί²fξ½ςgM]S―Ν³+)·DΩχq‚'§”ž qbͺ‘ΤΫ μ―ίνχάΜΜΜΜμδ ΙΜμrι‹αG•Ÿ›fL䫉όnˆ|·φ˜iΆϊH΅fˆϊί fFo°΅mΆ[.§S+Ώ•­Ϋ}¬l±›Ρ.ΧVtK)ƒ6Ο tΞKˆ†“Ξ=‡ίΈώϋ=73333;Δ8H23{ ]ϊ’&¦(݊š?xΔΧ€ε²²ΫΚfΆa hf7΅ησ\n™^-θ*‰ϊνΥ7ΌυqΥͺkΓ₯ˆHͺϋI΅. εςB8xΦσ©€Ή_ϋδgύ†›™™™™ $™™ύ€Žyή³G;Ή.οοΗΉγ _α+Ώ§œόΤ‹ΩΈeΑψ›Πά“€-IlTb$5eEΆ6R"QBŸr{Ρ υIšnm«wIAύžΪΆΆ ·ϊE;+©&Iu^R7£›Άœ ιž@·‰ΰά‹.δ[_·^χ °ΜΜΜΜΜ@ž‘dfvψͺWΞO––_@Ξ?Œt,p$‘Ζ‚HKjšΏωφρ»φΧsX<σT^όηΐ82#₯ΣGŠK:½A*󐂕ΩHδ:|;uC·%J΅Rjg)Υ‘άMΙyύͺm«ύœ€€vπvMˆIA3’ΊΙJκ#*‘&Κυΰ{ΐί ΚΌ€4‘ΗcΨΜΜΜΜΜ0’ΜΜφ /{ι3σxς’˜LƒtΖ$5@ ©t~•Ύ²9ίCΔwRJK £ΡηΎύφ·oίWΟ%m\OήΆ“W\ύ!vLj#šΙ± ]6R:―¦]­)}fy8l;‰”PnM•z₯Τ(ς HZύF]ΉΘ]xHƒΛ΅™.§R‘υJ)ΚρI₯ *κΠn4‚;€¬Ÿ›ϋΫέγqxύιφ‡ΞΜΜΜΜμγ ΙΜl/χ‚|ρθ”š£bυ ₯­Ώι:Έ"Ζδ|;MσΏ5ύήθΔκ›Ώφk“}ρœβ:ςŽ]Όζ !4aŽfσΌt©ˆ “άHMNΔͺ«Ά‰αŠm")Ρ(ΨC4U•Tη(iνΏ+‘Ϊ©M"’¬θ&₯v½7υG¬‘ŒpΊψxJ£Gδ%p˜dffffv ρͺmff{8GϋόηωΦη<η5ΝΣ€΄5G4D°β"κ*iu&uδ #R:A³c<ώ­Ι7Ώye»σγ_ϊ’ωδΙε»ψάoώ>#wεΙέ“ˆχŠτw‚%bR†•ΙIέύΚΔμϊœΥΞDŠξΦX₯I«@Ċ<©¬ΦV+’Κγ(%‚,bue·rΔ”k9Χ‘RzJD~jλ@όή _π§ΠΜΜΜΜμβŠ$3³G<λΩiύ†ΕWε₯έ—§ω…§Eνχ]­NOΪeΜbκ¬ͺι“kmCBwGΔoΟoάόΆ›ίώϋ·μ«η|ι―ύߜυŒKY—“q€Ρ(]ΐ%΄˜”r-ωaT«₯u†’Ίκ€Ά.IkύqHΣ/³ άGCš9 ©μD(P’ή”vw"B*ύqχˆτ ΰ£"vΌφτ όΑ43333;xΥ63³Gœώ)₯?H£Ρ#Θ5ig έ›nθτκΧ ?/Ÿ±ε’Η~ε«―Ύ}_<η?xinŽM7ήΒβΓΞ‰Jί\€εFΪ*b1‰(M Š₯TS›¨ΑΞZ«Ά _O0XrMm#Ÿ€%•ΉΫ₯)΅{¬Υ•αϊλκΎ” ΅MeΣ΄^θx`”ˆoKZϊρŸ{ρŸ~ΫN3333³™+’ΜΜͺ“^υͺωρφνΟ‰œo5Νm R*eHέΆ{Œ”JεΡτΆτν)AΞο_Μ1ΟΏα7sΧΎ|oωςΗ0Ξ‘=&IO€cUfεffΥ6)ΥΩI±§Š€δ$‘#R’ʐm"kfΥΆQVvk+’Τ UφΩ©­Ž$Υ}Šh(υNχ—’>Q*“~κ4W&™™™™™=˜<#ΙΜ¬ΊηίΨ*ιm.ˆΥG±χ;ͺ3bx―ˆ©―˜Lž²λŽ;~i_ΏŽ·>όI4ΐΊ€Ό;/^θ*Α­Ή9unQtƒΑE€{ω[‘r”ΰ)G‘Ϋ’€v»φ‡,R*c½ ŽDΗFνx₯ΆS°\#"K"Θ›‹sΞ—IZπϋ7^νͺ™™™™ΩƒΘA’™p̏=wΣΒ–-Ώ ‘λβdQ—Ω`(~Π«!Uσ‘σϟψŠWόΨΎ~=ραO" Φ₯ω<&1ΰo[DL†FΡ΅ξε΅ώF”aέέε‘³ϊλ΅^«μ-&DE΄₯XνΌΣ 5ž IDATν$εΊ|[έNŠξo‘ΪIK)ˆ(-rl–#β²Μd”oΏρ Μo\τ‡ΦΜΜΜΜμAΰ ΙΜ{'δ+ζΣ¨ω•fύϊWFΔχΐ₯(gSL&ςδΧΌζθ}½ϋ_{Ψ“Hs€<Ι|Έ ιf‘&’R?v»}2έD£vj˜VΝRΚ’RΏς[fΪ€Kk[»Ÿ ΈςοN7©­ˆκVŽδF•#±τδ†Ή§Ž˜OKΫvπˆΛžθ―™™™™ΩΜA’™φ–ξΪφJΝΟ_™K»Ω£+o ΘωόΌΌό ϋγa~υόKb.‘—c| Αί n *“bP%U‡Ν<ΩΑΚt)‚άΝDΤjmνVΉ―ακ'r‘’ίcM’ΪνΫ§ zϋTGqΩ2;/ 2xΣ—ψ‡―βΜ‹ν°™™™™ΩΘΓΆΝμ°φΏψβΘωΞŠι±=ϋυDΫf&BeNΡάάMsσsηέόφ?ά΅?^η/υΨ1§Νsση”ܜ–`.IΉ ΩN€Σ°Ε J+•φ3²(UID¨ΜY€@D*cΌ£ms««Ή•%ΩΚ«ŽξΊϊHuΉJ…RκfqK€r[ΊCπ—ŸhξΥ§=Κd3333³ˆ+’Μμ°uάσžw.9ͺR:«φ}k[¬ςΥήDΞΔΡ9ΗcφΧkύΧ½!6ŽζσŽ<Ίψp#°άώ-˜ik+O1¦ώ^dˆA"2"JaR_JεΥ)χ34έF7ψ!­<φƒˆ©ά/—*&mž Ί¨m‡ϋCΰ63333{ΐ8H2³Γ©―}νzΰη4=‘½§iΞN XΘγɏοΟΗψW}IbCšδ,J˜$έL€&Θ«ΌΞΑ,χ7Ϊ-K΅’Δ lIέl€aͺ+Ε΅ϋъZvΡΥBΝž:Ω›ΘAΌβ‘J ’ψ£›Ύθ΅™™™™ΩΐA’™vNω™Ÿ™Ϋ}Ο=Ώ—CΠNb*Mν1\eϊ85?AΔy’Aˆwάτ%ΈΝΜΜΜΜφ3IfvΨYώήχ^—ζζή’'“α4ηͺ™F;Ηhoφ¬™―ϋD±υxύΏ|ξγ0ί€¬ΌσZ‰O n)Ί£‘α±Im‹YωγQη"©Ϋ¦&I‰2Δ;Φ<:΄‡•Α#i_}T!Ρ^©Wc‚ӁηCœ,Α;nϊ‚?ΰfffffϋ‘ƒ$3;¬œψς—?/FΏΒd²±΄΄΅G«m]£bY‚D,žςS―=ρ8μάR™”b–ζ βS·PœF¦ξοDκ£%Q.ηzΉ¦mε Ά›εڊ&Ν”{ #uG}Εμ€ςΎ ΆT}¬<κ­ςxηρl1wb[™τΗns33333Ϋo$™Ωaγ!/|αy1βψœΫΆ)Φ^©m―€˜ž­3—ΫΫƒΑcΔΤΝ₯'ΘKγυ“₯₯'6΄ΆIΉμ‘>|!‚xF0ޚHβOnv››™™™™Ωώΰ ΙΜΏΦΒΒ“ςςΈΟ6φx‡θƒ‘nVRΜOmΓV[\S/·Ρ°ͺiΏLX!‚Έπ<$ΏxφγΑœζrjΦ}E§@·ΧΏmeRξUŸ E„…ΊδlvPv½ΊKλb΅W?ud†muƒ?PQf+΅#˜ϊ°>φπCBOŽΠƜΛjnΏώ‰ΏφgήΜΜΜΜlsdf‡Ό_ρŠ£O~Υ«ή9ΪΈρ'ςx τœKΜ0ψθC£ΆhΥz₯lO¬Ό<΅ν JivhχΤX%5ΐ)τρω…³¨Τε₯±}IθΣΐ΄Γ³ϋc•"J…R½ύϊ₯Φ΄β(¨έ0OΏς9Nj}7c) w—Λ£†jΣ[’D (+Αi“ΰΙHO©eǝr²?όfffffϋ˜ƒ$3;δ©iώY^ΛΛχΆεΤԞ>ίΞφQ%ƒ²¦aΫΦπr;G3£¦WΚ-§<ΗθηΟzl}6γ±ΘŸώpΠL!)IeU5uiΓyFνΛ^ΡƧوMDˆŒ •iJ©gužΪ•ά$₯΄FˆTG0!΅γ½a‚tŒΠ³D~Τ»άβfffffΆ9H2³CΪI―yΝ/©i^?SΞMrΤΆͺΝμpνa`To›ͺ*Z+ ZεfX₯ΕmζΡ#ΆμΦΰoEDFD»‚[΄•IZρzJΠ4¨ϋκ¦u#»Λc΅—Ϋ/ϊͺ₯θ€r]Τ{ΦUάΪ²¨&E,qπΒ1ση7Μπ›Ώμ_3333³}ΔA’™²Nύ©Ÿz³ΰŸDΞ›b2Ι„ˆΙτˆ£Γ±«.š€M”j›ύmO_νξ§―οZαb0ˆ;€ΙDyyωˆσ˜½ε¬‹Ϊ—€Ώ>° $)εώπ”γ’$IΣιX[#Tκ”ϊ$-ϊρΩu©vΐε\‚!€¬ 3¦j₯4xgΘD&JͺΧ>Λΐy dΖ§·‘Υ»Ώωώ…03333Ϋ$™Ω!ι”ΧΎφ5~9r>"//Χι:ύXθX½"¨½±Οw†AP½mυGSΧοi³DΏάύϊ“_υͺΗ>˜Ηn&Lϊπχΐ$"—δ'‚!””#bΨΦ֏ΘV©‹μ$EκΪΙeΆQ„R "r»r[Yš­­ΫMPͺ­ptsšΚ s©„Z»ž)8nΓ$3333³}ΐA’™rN~έλ~’ΙδWΙωΈ<™”κ‘œΫeδϋiE% 0ZC¬ρ5» ¬&ixΓ ‰’TΖ(E,δΠωφ1όΉσ~Έύ± “Ύš€¬ώψED.M~‘%I™ '‘RΚPD(χ+Α₯ՏR™‘΄«ΔRŠˆ΅΅­θΖ@ ͺκKšZ] ‘&υΠF·¦¦ΣR F˜η•ηz`jˆΤ-αVšεB9"ΥAMeVw­TžW £€Νΐe9βΒ…ωM ΰΏ}Σσ’ΜΜΜΜΜ~’ΜμqΚλ^wEμήύQ5ΝΩ“Ι„œ3δ~°v .'φ]L*νMH΄VεRWu€ΩQάνΈϊ5ΜfHRqցp<Ϋ·¦™ςw"ςΗ#ςm9"·ΓŠ4=h;(%J‰ιψ,¦ζ©°]Š‹¬—§ΑŸ¦A[[3•D’Ϊϋ }'ΥJ$ˆXxJΟ]ZΪφπφωό™[άΜΜΜΜΜξ7Ifv(Πozγ9yiω'ΛΛ§N––J+[ύ"g”σLfSjXΊ ₯™XhΆρκΎT*­6G»‹Q%‰©―‘ςξέΗ(Ά “„r‚λW ξ JV&΅ƒ΅ƒœΛΫΥΙΪ‰Aιpβx))’[E/ΚuυBd€T}[ε„j[Ϋ ₯.  o#AQBeη/ωτvϋŸύ_σo™™™™Ωύΰ ΙΜz§Ύώυ―\ΪΎγFžœ1K€4™”οP*’€˜‰$Π½€C1ψϋμ―±J\™‘DžLΆHΗχ-g]ΤΎΚΐΏ&₯*)’=e\x”g^Y»UƒZI”Ռ©‘Ί–·Ύ")•”έ%uwhW‡ΣΜ;[J€Ζ σ!=3Α€‹ŸϋLβ˜™™™™έ’Μμ 7ή±γb<>g2‹Ά}mΨ&­¬έ­ΔΖ3ΦτZqγ¦_ώςΝ1~ΛُγΚR”O >)©¬Ω6ΘpΚl€ Jf4\΅MD€Ί6D¨΄*—"€¬AύW¬ώ%ʁ¨Ρ 9ηh«ŽΪ¬*§r9υΉΦ~εy‘θ °δΏ}fffffχ“Ÿi3;hώΣ?ύΠ.Ώόc‘σΓ&“2X»]­΄Ž΅B^1Ε§ξKΛΪF:Ц9λ@{Z'<βΌφψeΰ9βKlM—Mjž=Ό΅Ύ¨NX4*Ν ¬*ΓΎ£ΆJJ"uσ“b°Ϋ\w”ΤNbΚ!RΝ©’ꄦh πt˜ ˆχάςU™™™™™έG’Μμ tϊW\°{ϋφwOŠˆΤVA[m”ϋͺ£zŸ©p©mwc_Ά¬υfϊ¬φ:°*“…΄(xθΙ―yΝ•q}ϋKΧπ»WώS$‘šfœ€ηˆ››Dτˆ”»ρHmΦ€αQ©[².rtsͺ₯’hB Ήm‡“ˆ”TΫάκsDVΧ^W{έ’ŒM*Χ3N=_jΞxΟ-_ρ/“™™™™Ω}ΰ ΙΜ:§½ξ§Oέ½}ϋ;‘uuΆΘ«¬Μ6ψRL­Άί­Xnj5«©iάυ5L&sγ;:ή΅λ€;φ_|ί‡ΉςΜΗς3§_ˆ€Ϋ“τ1ΰϋ%ΐ &“6Dj{Ξ LΏng$©Μ]‹‰€:φHIτΓ‘"rέ&‘ϋήƒΑΪmΊ”R9Š™ Ά΄iE„78ςA πίoρ*nfffff{ΛA’™TNyνkŸ1ή΅γO#βόΌΌLE]h΄ΚύξεΡώ Ÿ¦γ¦φ™GΞ 89r> ίƒ~nyž$ιڈψ„€q%D3œ‘„ϊκ£R*„‚ν­qTR ƒBRΥ–5PΣnήNφ‘s”ΉI¨„TjWδĈ!iτhΰjψefffff{ΗA’™4θ⍛&K»cŽόΓ1“UͺθͺΊ/Υ…ιχN»2}m• νύ}ξS\U·λž¦ΊβΘω$rցψ>|ϋΛ_cλY§ρGτ_‘#rDώL ΟA›ε€ΑŸ—@Ρ·•X§]GOD)A’¦VΥkη!Im‭­­αΆͺJe•ΆZΰ΄Ζ’|epw-xzNΜρηž—dffffΆWδC`fΊS^χϊ4Ω΅λiyΌόiSby\‚žaΕΞμͺlύ,ž#ΕήE<³ΛΈioO“qO«etϋmφΖ”7n{Ο{Ξ:Πί›·}ύ³”š£΄.‘_œ§R.”)cEΧ[VßTΥΡΨ(©,λΦΐTn―ΫHυ@‘jRTή₯&•w6•ψ¨”)©l#ꎣ>ΤPJRJ‚ω‹‰ζ­Aά*‚ηŸtΎαΜΜΜΜΜφΐIfvΐ»ωχ~7ηΘIJO‰qmC ¦λŒV„1³0{k0ίYχ%kΏ―λΏi•ηΓrͺγNΈός£τχζΚ³.ͺbpπ(«R0¨¨’RR—ρD=ΐ₯˜¨ EW}ρ‘DΫ—Vg$‘Υ;Άοhn)΅΅­{»sttτλ~YAσ±€Θ,!> q”εΠ’-’°Œ,“Μƒ¨‡VεΊb[©9ŠUc€©Ά΅ˆˆΤߚ©!‘©Υ=³R^ύOΞxώ»GΒ-nfffffkqdf”s韧_ρŠ’ 8//Ι9“'“$ε>«ΉΧ`ηΎTAj΅DΣfB ΕτM1˜€¬Ύ§©ŒIƒ΅ζPΐ…q-Šπ3g^Xœ΄ τ ΰ» Σ±΅«ΌJΉY₯αΖ1uXΛAΎ"i3©ΞDŠΑ­α/™Q;μ{π†Œ끋v³pIψK·Έ™™™™™­ΚA’™PξΊφšK"ς[c299/-γρτκlƒHa:υ²V™Ÿ΄ΚΟ|ΧjΓΐ1ψκ^ŽΠ0ˆHz|^Zj¦χοΝg^D"qη]|Ψ 4υd¦βξO Ꭹ₯μb•w¦Ήv^R[Ω” «^R4 c¦©7!4ϋΓ± ~ΌΆΊρ—nq33333[ΑA’™Ξ½β χόη?+ηό&Λγ‡δ₯εξŸχ«—δάΧUͺ©Κ XcΏ«]ΣΑϚΟiΕM_Υf$Z­nzΫ€…ΙξέΝΑφ^Αu| ΰΣΐWKšnkΣjΗWƒχuz€zΫΒ¨¦s9Giƒkn+ k¦΄iεΰτ\g$υ3–jψ”tπίωΞοΡgfffff=Ifφ ;εΥ?ΉξΞο|η՚›ϋέ4?8"Κ’Z{²W9RL}λξΧώZ,i¦ ϊκ’H¦žTΏϊcϊ«ύΏˆ©ΫΊΫΊρΟύ}6lοηϜω˜φΗ]EΊ9"&kΌ‹©Κΐq‘α nΓή΅.` °I3owωq­Š5©¬δVB¨~Ϋ€&­“τΓΗϊ‹›4β½ίΎΦΏ fffff’ΜμAuκλ_·yΌkχ―4λΧΊšζ„™…Ή¦‡ru›Άδ=ΜGŠι{­8uaTΏŸΩΰB«B+žήŠG^ρkl»²@*ϊΡΓΆόθtηκ6LάŸ Έ;j‹Ϋμ›²rΤx ©xhΈmwΔR]ρMύ€€vΨφμ„«A1XιT™ε”ϋ7}z˜Υ1ρΒΝυνλό‹jffffΦώ?β>fφ`8ληΆ9ω§^}δςφ(­[χK€tm 0•,¨VθδΑŠhΓJ£™ΧSσuφ`*ΪΓάξι±ESΑP¬ς₯΅οΎgέΞ5hυJΝK4ΝΜοuΐ(-n»i!υr­ΐš.όyŠU•w[δˆI<}@Wήώ~}3έπ]-Ο$‘σΖL~"p{›™™™™Ωƒ$3{PL––OFι]Νϊυ"rΜ!*%A1ΙD.+΅M70 Χ5› ΄2@Xk̞ξσ΄₯Xγώ«Ž†žyΌ5w8{O©i^”w/­;ίγΆ*)IKtp=«ζmšlΤ Πž>DiŚwΓJ΅α{­G{0t[BݟΏ.†ŠΑ=i.ΰIМ/Β-nfffff•ƒ$3{@zε•Ϊϊμg―_ήΆύM©ύhLΚπγ<™0^Z&/-“ρ Η,fΣ„{Qηu«Υ―aΚΠ^œ“΄†“‘΅r¬φšc»WΩ~X²ϊƒ΄W R:2Η£ƒυύώ™3Σ– έ|ΈƒΎΕ-V{οJ­PζWbλΰ”%”X¨ŽBO3±žD$­rτΛg#ΧkΪ€²d\mϋp<Δ+E¬σo™™™™Yα ΙΜXKKη,žpΒ‡F6όβdi‰ρφmŒwμ`²΄„bPη£ΑW2D¬2Σhf~QΠ-ϋή.ަ„ΡΗ>ύm{Zš.‘Ρ0˜ϊqΆ&ŒtbεΧju3Μܟ“ƒώ\ΐΎBωΪ5ϋ²§3Γ:Ζh•½ ₯rϋ±Ϊ’²¦'«G D.‘Q‰ #r¬Xέm*TϋA τ4 ήw««’ΜΜΜΜΜ$™Ωζ€W½ς\rώ/jšYήv“₯ݐ"QͺKXΉW *Š€ΑL£˜Z™k0b¨.μ«ά Ϊ0ΥV₯θ^«‘†΅*%κg*­Ο΄†ϋ4p{Ε}“Όp0Ώοo<γΒφΞR†Ώc&Ÿ“fsΑ1κ†$εvΤωΰSU))†Σ’¦ζ R©n;˜΄> φΚΓε2“ BδπάGB “6n9ΚΏΜffffvΨrdfˆΣήψ†Ÿ^8ςΘO¦u O,/Ν¬§Sιΐπ’ΤWτΔΚN¨Άφ¨ ’φ«E;ρ¦ν_ξU4#νqF·gΧk£­ϋ7p;VΦθΤπLσ /=κι?zPά~S&ΕwD|6w¨kqΣΜېˆ’*΅ ²₯rDΤώXΏBύ(ξ@)©k}«{h£§Rα₯u ϊ¦ΖΆ1.†o/}ΘIΔف^9©OwΫίχ/΄™™™™Ά$™Ω~uβ«^ΉpϊxK3?hnn 9“)!%¦ ‚”a»ΪžL­Ώ₯θŠZfWVλM7’ijςΞή‹UφΆΚδ£Ω—΅κεα΅ΰ`¨„IΝ¨y},νήt°ΚqJΐό5Χ±\ώuοu=tΉ}W£ή–Λ,EWq€„ˆZY” ͺΓ΄#‹‡wkΈτŸ€”jE\”Ηlr—η[bGž09GˆχίzΟ~ΝΛύΛmffff‡%IfΆ_ΝoάψKΝ↓֭{HΑRCR_qΤ΅ŽM2‘cΈήϋŠ ’ήηΡΕƒ±ΙeN? gΈΫΆ[nP¬to+·M5LiεJmχDi΅Χ°08λ"QsJΞγ…ƒύ³ΠΆΈΑς.ΰ“ΐυΡ½sŠ>Ό0‰2J;uαZτ mλ*΅λά£6VΜ΄«±υ­nšκe«b”*ΆΊ³™Š$1Hυ"o^-4/Δ{ΰώε6333³Γ’ƒ$3ΫoNγώm³~ύ/€ωΉέ`‘: G)AΞLvξ$//“I½W¬ΚhεEu©ΦŽUj£€A‚Τ§QjY’ŒcžmQ[ν‘cκihE5“BL‰Αsκ+φxŸvΫ„5 1‰ωCα3ρ†3 4$ψ–Πη„ξͺεEΚ1 jxΤeΚ»ͺi°Β[žΩv‘™Νˆj,)ˆ mK]”}iψΖΧXR’ΪΡηžώ[―KoώχΚΏτffffvXqdfϋΔ1Ο{ήΊΣήτ¦3·aρ­iέΒi4Νΰ_ϊεŸϊ“ρ2γ₯έ]uτ”‘ϊOy­5i8!§ήK5j“ usύ(εφa4¬ κΏ·+ΎMU' uφ Wi[»•mO—χjΨΆΤ £ζΕ4s›•ΟΛu^rΞ7%ικ$νb00;wοl7p=·=iY’”B₯Ά )JkάjοLω”0ޚˆΌb¨{_Ρ”κΟΉνx+ΙΤsy€ηΌκeœωΘ‡ω`ffff‡ IfΆOl>ωδwΝ-y-,jΒSΏΪ’OvξbyϋH‰@+#υ•D+z’ PuΖRίΠΤΝΥ‰™ά jΐ΄VλX Νf6Γ-€AΥRέη0Žκ}χχΦl|Ατ<₯=U-Ε°©λ« šQs"o<Τ>7s)'_ψšˆQ]ƒ-—ζΕL]@­ώΑ’r9’έ|μςξ&ˆάδ6ύf+΅Η½―YΣjc―ϊOέg­O—ϊκ΅$ΰ4ΰ΅Τ0ιϊ/ώƒOffffvΨpdf?S^ϋΊΞΌςʏ€u ΟΡάάϊ.@jE ”˜,/3ή΅«os›ΪKτCB+ζΑπŸό1©θ‚žΆ3lΆΕ¬΄ΤΕTΡSƒK—ΣLό0υΤΚ7us‘b°ά°- ›έ5I ¨ξi F7­|ν6uΣάόсΦJŸŸ}μ₯€”HJί]θŽ€&I$D υ•d)Τ !ΥΦΆZkVΦ盝°Υ―‡ΊαζJƒΊ΅4άNF‚’DR7ΰ»Rνά¦H3qΜFνδƒ·]灙™™™6$™Ωύvκίxωά¦ ŸlΧ?Y©‘c•>ςOωρφν,mΫVVkΜ·ιZΞ†₯>™``­Ϋ±rε΄©]­θj ΊΚ nΓ‡―Ϊ§6|Ί]šͺ?ͺyJ(4¨PκΊ>ω~ϊŽϊΛ{ͺΤ>JšŸoR;sκ±λŽ»ψςUŸ„˜1Ύς—‘ϊΥ‘§ͺ‚Κ ξΈ—?bέ*n*Η[Q·d{‘nΊΤ¦αΤ.!RyG#«΄AζΆ5ξΨ@―ΨΑβΊ@|πΆoψ„`ffff‡IfvΏΞ§ΏωΚ?-faέ©ύ`ŸUHδ₯݌wξκΑύ€κ™όΟLΎž—ΦΈ­Ύ©+ˆΔ{έή}ζtσr†ΥHŠ>pκVŒ§΄"c IDAT>57)ϊΫΊW²V©Σš—ι‚&iΤ@Ξpgy ώσ«ήT>Pέ-τiΰ ι€κ;ΠŸv~{ ςͺ―|ˆϊάΡ½%\ @yζO`΄οdύ8LP(’oΘlίς§‰tڈυ‡ΪΫbffffΆφ?}ΜμΎ8νŠ+~δτ7_ωΎfέόO€Ρά‘«ύ3~ϊŸτbιžm,oΫiΡΤmO WncED°ηΫΡ5,IpoΟ«MDihκβ…©‘F};[iAS] ~j½·U#‘`Υ—0ΈΧΚgLerΜXpιΗρˆ#ΝOVδo ] MΤΐ¨Άκκiυ`¬(Μ’€<ψό™\ͺ™R{TKœj-YW‘€~}=[D¦ #’ύθΆΏκ΄Ψ “—LΨΎc>tΫυ>A˜™™™Ω!ΟA’™ν΅Σήπ†W¦…ω·Φ/W?}Ζcκ€Ή]Ύρ5˜Μ•„o]“­„<ƒγTΫΤ{HHIQzγϊQU‘g'p­ΘωΚ6}i\ r΄σ˜ΤnC ι2ΡocΟΗΨfΜxπ36Ζ Ο/˜7c@ ±ƒΩΝ&B΄t·ΤέU]Uχήό½?2#γ‘y«KΠ­κŠΓΧ¨κΦ½™y####Nžs~?Κ0χIΐœ #βΙ$]ι4π–7ΦUύTw}fXQKe¨vPSOs–"eŒΊTΫΦΑΫ‚S+’D ΘΈŸ ΰ›ύλ½wΰg6œ„„„„„„„„„ι jBBΒΌΨuΓ ›Lžs>1ώ‹‡D‰αμϊΣӐαΐEΞ―Uq‡gMδΘ›¬Ή­ΪQΦ3DΦ68N!>f―\‘Ju„FH6ΰμr άn>q›pΤΥku©°ˆΆ\±e.γ©β£κό%—A^³L·ΫEžwOΔ~φͺ=η2ΜΎ ΰ2ΤyFŽΈ# Yg$•·±€Ρ6@‰Ϋ»κb"A€–D=₯<•œ“Θ!T»‰ψΐoSΫ₯$ΞΚs¦{jBBBBBBBBΒ €HJHHhΕΪg?§3±aύb§sU6>~6¦s‡fΐ`f¦¬Τ¦•HŽDaτK@ΕΔZ‹ψœ"ύ²ώ4~¬ΟUMΡIU>S-+ςΧΑαα2Œ”M­7χύjQΓ°oϊLžƒΐΆeΠύξπ'“fŸ&[¦‰W$•0u›Ρ7* }:RΏ%A•—¦ Ακ3{‘uJυ‰‘kϊύβλLͺ€„„„„„„„„„ιιiBBB€1€;nΌρμΙM?Ÿœψε|b"$‘"αP»ŽˆΞΞb0=εΥHδˆO©0#χŠŠˆD[E½Œ)§lα 2k)`€*ω†mx™¦,\ΜSΈ ms“€ΆώΉΜ§"¬V;‰ /ΟΓέΣN[y"φ½›φ“UΆb±’DέED€¨h$Σ*e σ™ Ό˜4g˜•”p‚#Νx¬{ε+Ÿe:ω_δ―±yήωIͺš“ΐάC0œ™­”#ˆHΆH†Ϊ²Œ‘I-ΜPEˆ—„άΚ'Iλ†ΪDLβ…S†_λΟꨚc₯qυΥ<ζφNE2EΝ%€Ισ§Ϊρρ'j»qΟΩ€έ»χ y€½"εΩ5λSκ 5»Jt^”ό¨6XVe₯fbι,,χΕ*YIAU[ιͺ]UΏW*9‘ W‰ΘZψπ½w¦Α$!!!!!!!!α„D²Ά%$$ΆΎμe›mž²νvž—οζOχΒRΤŸšB1€Ζ*Žh+%…Kœ[-zωΊDΥίκ₯ΏΟν†―πζ₯ rŸk€±ήOύ›JΙΈPν*ΫΗ8«ύ‡•ικο%ŒX.Ο.IωμŽ1ͺ]W½ίδω.Z³ϊDοίϋޝ q7€/‘Όœΰ\Ω&H‘†VY ­°<'•%͝;!€Š»b³[ΥΖBGJIY0؎λΔΒ².\O’COψόDŽUiPIHHHHHHHH8!‘I Λ;nΌqεφWήpMΦνΎ­³jΥ/•$RKDt#³z”© ˜}π! ηf++–΄η]‹4UJeh€}―T†#‰md*G€moώ5A¬jbΕΉΚjŒC 7PοD\t³'’tϊ:‘[ͺ,%ψΊa€ͺ$‡ΚNWΆ£νv'˜ε“'rŸ,UIξΈσγΣ"ψ$ΐϋ€ΦeMyΞ„lΉŠmŠ”šd •iRŸ‘ͺKfX“Oα6¨YIqΛς6Z³–« ΉΌƒΙ΅ΐΉχ»i€IHHHHHHHH8ᐈ€„„eŒ7ά°EρωψΨΫσ•+.‰ŸΨΥςΑQ,‰αά,ϊΣΣ(*±Fϋg*r§i(ν₯‹«Ά92ΐ[Οq΄2*E‰T ;€ΤΫΡD M5Έ$²-•m-ά«œ¦Έjœ°’Ήθ)*WσΝɞ¨ŠΫ‹(bŒ¬+Η(·Yrβ€έOΙ{ω w^JBIη‰θ~QŸ;γ{\ΩΆJ—Vƒc«›Wφ Έkr°Φ$isβ@€‹xT±€*„ K‰HJHX†ΨqγcΫ_ωΚg3Λώ$_±βuΆΧ[XΩZ²fFEι·—j€QΜΝ•j$•JΓHΈΓ–ΟΦov?FaAa φj1οΔ"*:YΪX"6χA(a’’θjpΠۜiJκμπςπT΄¨o§ΖΑδΏΏ¬ˆ)β­έo¬φιΎ½ΦΐY8Α­Ι7ξ9@™™©ω€|KK>ίΙ"κr±„LBY°Β[e`«s”ΠQνΆŠΔ*nxI†~ψΡ{οHNBBBBBBBBΒ …D$%$,3μΌωζMωΩψψŸζΟ0yž·ΏS•§ ]j­ΚF:4’č°ΙM5~Χ€S΅ΖΓ“Yη…ŸνGSΆΆΐFVνUͺk‘J[‘tΕΆPνT7GΐψP9ΠBzLsY”πΈjΛ•Ξw’„QIΥΑSYYΞ”-ξM5Ή$A+%Juw­™ ’³φh‹Ψ ‡1–„„„„„„„„„ ‰HJHX&Ψu㍫wάpΓσ όu>9ω«Y―·‘A©ςΡ0β-όΣΜΎ}(}l5¨ΥD ηΫΆΆΒΉ*f¬—ςuΈu₯ψa ΧΡ€ŽΫˆT„c™*›ΌέΜ…dSν#ŒΕq―+΅QDIHň‘pφ6Ÿδ ­‡‘5±ŽIr!βn&Λm;'zŸ-UI@!2ΰSχ9’gT·ρ(FuεΚέVIΪ‚7Χ4"kc‘*ΪΈV”=„΅°ΐ“D€ΧΓL|N(€ͺm '8vΏώυΆ˜ž~œ?—Ώΐt:+Žτ™:ΏgA(}kΕl•TuθqΫBΎ^€+ ΅- jέ.Rš?ͺZT³ŽžV\‹*’J«θ‘\Ž+‘k:#ΫI€¨Š”z(όlΛVθ+­UV΅}―ϊ–ΞZZ»vΉτa °?ŠΟΕε‡T EDη9L62e|Άψσš€lœyΈ ¦ŠύCΓΈ:.Ic€ΛŒ1ΰ·ήw'.ήΈ' F '’")!αΖξ[nYWΜΜΌΔt»οΘWΌΑt»G$‘tΆ_?+ŒP%ΝμΫ‡b8¬­gΔG+ήτ©4Υ6t0vm §ͺΆ$h¨Τšςe‰·‚0c‰:Œ›AΆNTΎήΤϋΫ›$¦$"΅Ϊƒ½5 "β•IαwΤ?ŽΫ0yn‘e“Λ‘ί°ϋl 0[ŠΫ (ΓΣTκσMhΒͺ2oθΑ>ι*€Ÿ$μω”FW—θœD58E€σVχzi JHHHHHHHH8‘I ' vί|σŠώττ*Ώ‘ΏΨd٘gGζQό[¬ΙQΏ PΜΝa0= EE$Q-ΝݏѢ)ΞCͺŠkB:‘N­+‘J‘C)-b₯[­R! 뜑@TΡ ϊΘ©X’€lˆZq¬¦Κc(ΠšήšMLόσ|m¦½HΛO£^)wΩH( Ώ’ΝC¬•EήΎ₯BΕ…βϊ썧‰¬ͺĜ―ΆS»Π\ν/·ŸŠX’βžΓδŽK½ζ>Ve-ΩNwΒΨlνςλω,|Y€ψ.i­z˜)•m5£Y©»LΤ•)ž.”Φ:zQ½½κ$ˆwgέ.€Ϋo]…OlΰΌ  Ρ€‘+!!!!!!!!a©#)’–ΰJzΓΥWwη|πΌ•§œςοωδδ›lž―ΕOK"Ր…ύ‰ΔτχCŠB vΚeΆ •–ͺl½ XEλνTδJ»a β“κ*νώ5§YΗ( TςνJ΄9Β‘bgκξž *σ]\8NWŒƒŽ―j‚iŸœxΓsXΚΒ&‚ dZH΅ͺ₯Df–&³+—SΗωξ³Aχογ„ κΦ­Dx‚Rf)Ulž;λŽ6…§μ‘ιUGBš@ΒΔ >Ό9’ R†&yύ]ωkeζδyCτΟ€ίwgΑ–4‘”°„°γ―˜άyΣMΟ\qςΙ_ΫΈρ“6Ο·kYΓ}ʈW‹ΩY gf›V­ο #‹κ•x₯ρω5GZΫˆΠ‚ζ+VKτΰ«%<‘ΔΐiΖ:θΖ©‘j5Q½Γ(ˆ^Du`RοH‚ƒ¬£ut6U2Žήφ„f ΈˆJS::ϋ_E~³bμ–εv ΌlΧYȌΕΑ©ϋnp―Χ–I!υwmjΌ­•^Q=•֍Ά¨ —Ί$βcΉhͺώk*>‘¨’—ε$ΐΐIΞ8j\oBBBBBBBBBΒqD"’–ΈσΖŸMLΌ/Ÿœ|—ιtφk,AQeIŽπR•ω3u₯I΄•LΌ²FΌM‹•}LPVf“h»’Φݱ샡-Ξw³~gqΤ–&T55=iŽΚ0ήVNQε/σͺΦΆΊz;qΈcΡ΄R… Ύq½?I―<ωΔϊ­$ž=ύ±[—Σ…pκOΐ bΕΔψiγσ‘Œλ)PΒ4’†…θΔΚ3WIΨπ0.ž^;W½‚B€‘ˆHύ`€J5UQQ .4(vΐmχ?h K‰HJHXδΨώ’—¬ΫσšΧΌ=ŸœόgΫλ]nσ|άC€GŸD‚Z^ΟσJ1;‡αΜαšδ§’aU½ͺώΕqJŠό¨ΆT^‹^sΏ‰{΅ φ* Ρ\Qύƒ'™j>‘ΆΆU7.l(,“Vώ(ε6Λ τηt‹Έ¬%κΚnAπQIKΤΡ9Q 4QwωώΊΈ½H£ΑŒ΅ΆέΞεt=|λ“ŸΕΛvεαγ™ ϋ©x-˜¬Ο{θ1Τ$²b}jͺ―΄©Ή mH€m /«)J"RΔ.ΕΑS@sφΡΞΑOHHHHHHHHHx€‘f΄ ‹»nΈα¬=―}νίvΦύ²©νvΧkι–»ΗŽDΒȘ€Κ „ƒχί_‘lΙ&Šς‚‚hγ–Θ ΖΕν}\q 4bT­VλPΧf―9„:ψΪε ΉΧžsbL AqAβ@±bP‰*Ω ?£@gΘΝ*φΩ%hWΊ™F99AΰΩSΩίΜ²ΜσΝΛνΪΨtΚ|ψ―ί ϋό€‘HQ œζΈE# Ÿ%%K$RuIqŠ$έUΎ<½ΑPκ'NWΗ +θΞΏΐB Ϋ ‘σήωο€Α-!!!!!!!!aI#I ‹ ;oΌaηξ[nωM39ωχvlμg³nw«±6ΈV)‰ΏŒnC1Χ‡ΜΝBW­―K‰ •ΓΛ«ΒUΊΟO*εΪκ%qα8ΔΥΧHGΉχW$uu6]‘M3XVkTvCΠ‡4ΕqOΔ%5JίΥ/Mq Ϋ›ΎΆ2° ’G˜N·ΗeXΉνήοά‰·Ώρ7]ζΦmL•S$u}@ρt&GdbeYΠ{Ϊ”γrAA©bΟΉ1žΑt|₯qΊΉR•TG«W9J§Ώδ”SžΟΈξωi°KHHHHHHHHX’HDRBΒ"ΑΞnX³ϋ5―ωΏμψΔ²γγoΘΚ$^¦ ‰δ—Καk‚ι½χ‘ΖQ!·―β΅ Œ^Ρo’ϊοC ϊƒ\Χς"Eϊ(ΒFΖ@m`£σδQ΄?ސgqΔΟρ›ΘθοΥΨα[ŽιTJ&³0™]±\―™λw=ΏΰCPΧΩs*4!!FA YCRS‹ Ξ΄„7'’{ŒHΒ[’Ύβ-@`H`7€Ηΐ―ΡMƒ^BBBBBBBBΒ’D–š !αψbχM7?‘μ…0ζi&ΛN΅Y֍Ι#‡GŒDλizP17‡bzΊ`΅­)' <Β0κΈΑΕPjžIt5]ώΝύξ¬n’Λ»iu•GΣ]‘ŒH.\‚Qβhc b2MδΘM~₯ΚšmιΙ€Š(Ι2€ά½\―K^ϊg‘όL!x”!;U'2¨Ι#\&7E$Š2œ»μe%+D[v&SζsChωΔJ]Δr»UΟ*”ςŽmW*νœ‘r«<Αΐό½@H£_BBBBBBBBΒRDR$%$'μΎω–SvέrΛοr¬ϋnΫνΎ.ουΞ΄Ξβ!‘‚ε°Tj€½5DeΣ 3-L΅Πή‰ S¨A˜°rΚιTm΄¨ƒ₯–/0‚Ω‚΄:Π€ε#ϊ0G‰F‰ŽF‘΄lœr€σ ΊΞŽz|χŒ3v.Ηkθ£υ· ˆ~QάjΘUυ΅Ί|`EξΤ°ͺΏJv%Ε“@ο\dΆ΄0ƒ%ujκN\ςS₯|jλ^=‚αy₯H)!!!!!!!!!aι!I 0Ά^yegΧ-·ό»e½ήλ³nw—Ις|ΕpΈ0™Λ±‚ΓΩY .r₯‚…¨’ˆZΝt₯4ΕώH`Ec—Δ–εϋh™^―αm?α;"S^],Ύl“&{Τ^.δΖτ… 8]m€±φdΫ霼\―§—ξ: ]›ό€>ύm­qBΚμt–E΅΅M "&κ?.ΜPrSHƒ²ϊΪPŠ*΄Κ‘Q‚Β Ϊ‚dφ*π{(ΰVΐœά~χΣ€˜°δ¬m vΎκU+-y•σ3°φ|›η§š<ο‘\ψFΞ{6Š‡οΏ‘€(Ν.—ΩΥUΦ3 ˆό›Κ-°±…κwΆ[εŽΠH띂F”·&Z_Σ―”LD-€ρŸ‰β™K\λϋΛWƒΪpT–<&ΛΦ™,ΫΊ\―­WώφRˆ„Ϋ ωdAΡuaW₯ϊLQDXVΚ3#­’)`hQκ‘*oLighœΕM%Šͺ%pMΈŽδ,ΐμπƒ4:&$$$$$$$$,5$")!αbηΝ7ΰ*Zϋ"dΩ“³,[Ο,³l,^GγΈYΪ†³³ΜΜΐ6I‘€έπKjBFďς Β€’#(tZΠFΖΔBžΆ]{J*‹ώœ½Iη\ψήαA·ΡBΎU$zΕ“ZΊε€±%]TN˜N§Λ,[·\―±?γoβg^όιΖ5JΤ¬J!o$DΫ7υ΄ n’ŠlΣν(-GΦΦR’›YZ»ry_mDQ @ΰ „9qέuˆ’ŠΕŒo€ˆNnwG0b¨‰"Λς¬QΧtΏ{7§Sί0Ϊ›‰Β]Υq)lp.ΐχΕ – K ‰HJH8JΨΈu«Ώϊκ«aΜΕ0f·Ι²Klž―…΅|8 $‡€D:ŽΆΆbnƒΓ‡AkƒΠ‘¨žYύV…ΤΨd?DΪΏ ηχ°ι&Ρ* q‹zQ%αδhK«{Σœ₯Ž"a‘P‰“ΊXœ.'#Φ?E–9 ΫΡSސWϊoQ«ΠXΐ˜ΛωΪ{ι³πΧw}Ή«Œ‡ο0u*“škVΓ’­sDΩ2+Θ ΜπΘ'1΄DXV‡+%N κ—ŠοIuGϜj Nζw€Ρ3!!!!!!!!a)!I ?%V>ϊΡfυSžςd“e/a–=έdΩcmFc~bH (ΥH3ϋχΓXN/0jHuΚίιEm[ΕBƒcK—+ΊήΨ‘‚ƒT&Sυώ†’Ι­λέ+"bcM™ N{#ώ- a…Z΄βrxHWα+`E.ΖΥΧ¨•€=—IͺoDUυ; AšΗvύ˜³ίψχ»–σ΅HπA|ƒΰω:AŸt?‰ˆ©„IH!( ›$Κ:mN‘D…†NIHΔ°Φ±QoΧΏ―¬Ψ&ڜq‚-^ΘŸ|ΰ.\°~gL–‘”πbηυΧχΨ뽎δ9Θσsm§srm_ϋ)DR­εƏ†³³θš‚ΙlƒaˆΝš°iudE–0D‰@ΑςZ½MZ>/@ΰosF£ΊLVeXςf$—oΔ2[‹Cj΅—  dphκξξκΥ;$$‡;HEQŒ8_CΦ€—Έ6“Ί5‘‰-V$“(οœDΡδ5fΝ;>~*€»•)_Μγ‘’Ξ» O‹4A·,ϋL@2ˆΊ4+©Nz‹,•MτV9²!LͺΨP BΦ2<Ώ"m:m K‰HJHxΨuΣM§@δ±0ζ,ηΪ±±§Ϋ,ƒ1ψIμk%°H“D:NΆ6 0ϋΰƒ0™ρLH…€1A€φˆceσ—‘‘Τ-ŽΈ°Lš&^Ϊ'fQ¬ι/κ Ρ?pΰ›ΕΜΜ[°fυ…Ί%OόΈ}’avωg§„’Z¬€R’0J–RXsqlo“ΚTUn[<'Q}ζΩZfvYΫΫ^ΌσΜΚήΖOΕ³ lkpOŠ˜*νʟ˺δ`ΥιΛ:……ˆ ηVο ΞbΝUImƒ h$Vͺ§:³Iΐiv`w¦Ρ5!!!!!!!!a© IUΉf/ IDAT ΐž›o^S ‡ΧΑΪη˜ψ»u?ΘpPτxοΜΧΎφ©όΒ Ÿ.ƒL§|5V_\  TΫ«$wœΤ;Uί»ΞM‚―Ϋ₯¦RΡΥ“&‡&Ÿ§ΪΨX “ε«–ϋu[ͺ’Ύ _p>Θ ΓΊΑκˆ• ©ξ±F‘˜( ϋ±?Ÿ¦6ΦYYώ].š]₯.Υ½ ¬)Θsό!iΰMHHHHHHHHXHDRB‚Βφλ_6FΓσ@ΉάtΊ—ΩnχlfΩ„ Ξ>0ΒvŒφ΅ gg1wπR#΅ε)2§ς]IXΗΝ;ΓF짌*jDjϋ#-ΜqνT―ύ₯ΞIΟδ„YCh±ΠΥ+{Α`zκ{oΏύ&’8\τϋ…ιt t¨7YS BΪΜΖa#°ΓΉΟΔίήtΙ\“TΊΈΌ V%Qe$Υ½κ h-hΜ†t—˜)fΎ4n'‘Υ ρ6Z1έΟaY«‘UO ’ρHo/ >’܍ ΚB'ΚΪ%p2 λ<Ξ\BBBBBBBBΒR@"’–=Ά½δ%[HžBς$Zs‘ιvŸj{½“L–uΊum‹₯„τϋ˜;xΖΪJuγ³xB CΆ(Z}»•rπΥ«σ.’ς—κΟΤΚXωŒΌ?ΘύEԜšεaKBŽΪοππΜα™φώ€>Θp0U SWhZAάω‘7­IπU$¬Κ7)""MΌRΚQ"v|I‰Έ¬%EΜΉfo“X•ύτδlΫΆ5ƒ{ξΩΏά―ιq;1 ψ-@Ά˜ˆΊ&Cι@ˆ­{OtΡ3LλRc‚Σ–Ee]&ά»Iί‡œeŽ%ΛYOΘ%Ό7Ζ K‰HJXvΨ|α“ wξXΘvcμ9&ϟa»έ l·»ΥdYΦ"\,[,&KD0œ™©lmyΥQ#-Uَ­-±ύM΄ΘΚGΧv±Ϊ%*Ϋ-½Ÿ#ΚΦζ—χ-€0ƒ¨Zσύ ϊΧΓφoΆφŠ+ΈοC ‹)ιχοΚ J\lξ-ΡΞ¨r•ΪΞfMj‘GM4­‘βvιUJΥ9(λ‹Qε2°φ€Ξ†υgξΉηcΛωϊ~ργ𻾊ςeO4ΐdΨΉ<]W}ΔTZPPύIiγ {nuύ MP-°άͺŠζ"CV<ΐ{?υΐέxϊνiNHHHHHHHHXΤHDRΒ²ΐ¦η>§kŒέ&Ζ¬£΅ηš$Γα·)τ69pουλsϋχξΟ&&?ΰA³¨ο‡0J<Ψ_lvτZήοq9ORΫυnΑJЉΣiKf٘Ις­ιΚχ§ΐwμd¬j΅Jk&&!Iΐ*1Θ3b}Uʐ ’™ο}΅Τ¬ώέ…m M½Ν’Œb•›$`γ§qΟΕ€άšN[BBBBBBBBΒbG"’N¬»ςͺΜΠLΠrΦΈΚv;WcvΫρ±‹l§·ήv»f.8²ˆ«$5μl©F²­ϊ₯ΠhΚ~€Κ*‚²œI΅¬¦__+κ£^Ί;u‘HPυΝ­ΰYΏS†‘x‡-Ϋm\!Γ†‡ί6υ™ΟΌ)n‚α]wΟrΧξΫE kΑίN ϋ ?y―žWŸxgŸ"Ύrtκ{ ‡ͺJ§I€šͺͺ½k ­YŸFΰE;Οΐίόΰkπuη£ΜI*ΚS₯UFλΜuJQ”eΥώΪ²F˜’(2š` ΄FUL;a‚ο― :s©Θ Opλ§Έ O\Ώ3Δ„„„„„„„„„E‹D$%,i¬ΏςΚU&ομ’5§™,;Οδω£M―ϋx“wΧfcέq‹ς_δb•ύaΡ©‘Š’¬ΤV«‘‚1ڟF›ΊΕΏNΘCΆ VjΉ]ΊΛHΪςΥ’RΤ±Κ7*+½1H΅rg@8βuG ωp0†ΖN©H«R»ηνmr.€ΏϊΜή»qώΊνιδ%$$$$$$$$$")ay`εO2ΜrCk 1,!­ΩA›n¬=›6;Υt;²YΎΑδωJ“eζYk1&Λccα«ωεE₯7;³ς›¨΅6ζ―Wœμmmφ5‡κq•©?5fΆΡ4eΘKEа²―©€ Β΅υy¦,yβL 6$ΘL ¬tZE&*2™žπ©«œ‘ LM½gϊ‹φώ#6GQLƒαΩΈz[ލΏΟ,‰Oβgm«μp ΙΈš~pj˜XNuΙ:Ν'PΠD4i–M˜<ί•F!ƒβ^?p Βήαˆ@DX© DŠŠ¨&Ω2ˆ°tΕ5βΊkW¦*œή/A)xͺ¦ϊ΄W]¦ΰΤEg΅MHHHHHHHHHHDRΒΡ’BV_tΣΣ,ϊ}ΪnΧd““+iν*cνi°φT›η2ΟΆΪNw§Ισ6ΟΗ`e–c-1„5U5γΕ*’Φ… [λˆͺ™Φ˜ΜΓ#©rτυω£ΙΩ85ρ ŒZΏ?1œΑ`z¨+΅ωRτ.,[βͺT.)&p†€^ΈΘn‘9Α›ŒΨ¨†Ζ ’YΉ°wΝ¦¨‘€Τ©3‰tV1DκΠmn»νWtކΓ)Ε·žlˆήΖ¦ΨΜςΈ?δx‰ΐΦFν£’F{ιό₯ϊo %ΗΧέ™ΝΧm–eΜ²ΝixCΰ»Ξ4δšΠσ(:•έ‘Fυ4‘¬±V9’Ι4τf:^…k‡$·τ cH³ΐΩΎ˜Ξ\BBBBBBBBB"’–,a|–›Ή‡βΨƍΫiΜvΣνœίY΅j»ιυΞ·™]oΊ½υΜlΗdœ–΄67°†4ΆLͺ5F)Eš«¨šΣ³ς0δ‚"BΙ 8ΔΧFg€Z Β‘£=Fϋ{$ˆ›Ε΅Β —j$cm­:ςΝ$EqΈv―2’Fš*£ŸηŠ«‹ψ0ξΪςWͺϊ™>­ΪΞ„§“Ν*m$ϊΣSϋό?¦$Εϋ§d|β3\λ‚³₯ΩΥ.*β¨ ―[ItΑωQdΧ…­…±vMΆJΌpΗcρž|ΎEΘYJa`PΆvA cώͺΤ/C_œ­:G‘}Νυ‰B€JήT½ίΈ:mtVΠJ‹$"œN sέO€#Ν…Ύψ3ΟΉϊχ˜NbBBBBBBBBB"’%Q$k.Ή€·£YωΔ'M˜<';ΩVcνfΩΩ¦ΣymΆΓv»§¬8ι€U¦Σ™0ΦZf™% MfΛ kcͺE—*OLo ¨ΧΞΥ"ΌZTΧk8’½„„κŒ€μsδ‡#£€V’ϋ¬Ž“2‚’Š+ρQ%R³΄΄ §¦ί1υ™ΟώωB›eφž»ζΊ{v~’ΞbRuΣ‚Ύ(±υIΠWQl>h›a'I$Ζ\•žΝ’taSII$ΑΪ΅iXΣ(0Δΰ[ςΙ`00€(€0%Ώ*…*bΙΕΚKΆΝΪV‘H•VMΫ8E 8Ξ(γΉhuzΑ₯O#Mΐγΰ·μ­8οSNRBBBBBBBBB"’ŽΦ<ε»ΆΧ\zρšύΉušK/Ω=·χϋ»›Ά\J0·½ξε›žχάGΩΌ»Γv:k™ηγ&Λ:΄¦Κ:²(­hΦ₯‹āEŽ,b.Ο%(=‹—¨ΜΊ Ξ5Ιn'd9Ψτώˆ„2™Qξ΅G€ΰYT$RΥ–Γ™ΦfŒQY=Žϊ +PδC}‘/^οˆCc¬”3κ'Χž‹a•ΏδΟ£#ŸX³‡J…TυΗΘ{)Π?tπss{χύώΓ>Wιb8€ΙςΰΈγu•5Δ/ΗTiΝ1Tg©p$Ρ›S9ίZŽηI;ύ»&―μμ<κ΄έsίώζχΘηntω€;<@O1@ˆ)iΣWNWVύ·¦Kk›ͺΑζΗ% –tg£*¨η{«ΚL8–’ ΰQιŒ%$$$$,ΆϊgZώΛpB>ŽCXW+Zώ ΥΏ(˜ˆ€„£J]|qw­·Ξ½μΝϋ>ό‘―{ΪӞ2sο½_·+Η^³ώΚ«$λ]ΊυΪk7±ΣΩ4Ήη€1c³ŽΙ3Kc Κlμͺκ΅ρKžZ™‘7Šρkg]ς<τ’₯)Θ–¦%*܌@/Αˆ…AΠ*Ώ6$²β[U ΅œH$>\lΦ‘Ϊaό?Τ•π‚sΞ0«Š’*¨©Rχe_ΣΌΦμ¨Jk΅2‰‘b,κ‹ΪώΕθϋšššϊσ™―}υΞ‡έ>E1ΑPεœ‡m Γ½γΛlŸ"U_G4IV3^k£f•΄Ϊ―ΆιvaŒΩ”ONœ=$" ΐ΅;Ξΐίώΰkπ}‘β!cFy]Œt#jς[]ΒRYΧͺ³R*’€€θt77ͺ‘’"§Δ»6=™dΒ!OXšδƒ*+I{ –Ξ^BBBBΒb™=Vλƚ4ZyΡE§―Ψ±ύEΩΨψΉΜ²­΄v%­3d Ε?φOWZ¦OuιZ"₯@^ †"2b8]τϋ{‹ΉώΓΓ‡?ΌοK_ό»™o|sou3(’)!!α–ƞpnžu;vxpΪtΧ];uΧ]Vœ|ςΓ~ǝΙΙ—Γ˜ ΫνžΑ,_•w;«e9ΙLžΠZ[(2κ©zh© μ9>ΆmŌφRδž5Π„Γ|Π“ a±_h#Θ:’–}&‡t@sH:kERQ‹Νξߏ™}ϋΚά*uΎ}ސ7ΰπφΐ Γ(:%afQxn%Ϊ%>αlοzmΗ3`vίΎ?}θΦ[_ύ“΄ΝŠ'=ρΤΞκ5_ΞV¬θ.μrt† iβKΖΐΈ€Jk)’¦!Iš²q ”T»΄¦|ͺ8νςCkJ•’©LΊB˜ΚδζJΓU;,i{_ΓO,iΰΌυΌuΙΪφΞi8’―`}Έs#iωY0ϊ)»{ž°8ϊǂƏ―=œώ±ΠΎ!HJ‹„Εs½”€ΡΔψκΝW_σδγγWšng›ΙσqC“ƒΜXήψΚ[$ΡκNPΟÚσΙΦ©žτ+)\ާ@Š  Ξssχfίvψήϋώxο‡>τUE0%υRBΒ1BR$-b¬ΌΰΒIτ ΫΫ,Φ‚ΝωΨΨ9 VΫ^ο‰άl7dέξΦΙ;ΖM§3a²ΜΒΛ,3$A’&ŒT¨/š./Ώζ/Υ πdP=όIZi‘zρΫτ錘…»ΰeE-Υy<βAs’(%α<‹όˆ0αΫΗP˜΄ΥHΓΓ‡18|Έ.aaν₯—mb'{ΊνυbήΩeσ|ƒι䫬ΝzΜ².²¬τ KcHgC«‡uΓZ6U%(Ξ.- tQˆš)E*‘x •,Lυ6BR©±(ΧvψL$;Sͺ/ά»…σ5B«Τ™oυ°XΥH³ϋχ7ˆe ž΅Q€a%3M"!΄Φη|D£ΛθuΪB―ϊΤb8=-³{χώΒΑO|ς~φ?χάΝωŠ»·aγc[ϋU4€t_εBΘ€ψuiω²=!“Ή>fχο{η[?φE~ί‘•—\r…΅φJZ{¬ΩLph: δ($™‰ #aΔ”4ŒSS–&1ͺ­φeΘsηκkΗdπy6NKγΌ1ΆΟ X”ϊEQE1keΌΫΑD―++ΗNJυ«W wmί:{Ξ9gΨuξicl9j³,/ΐRλδ$‚wxœΏ>φ|Zϋc³“`Μzγt@ddΒjaH#•¦Œ₯>LuV$yD;θs.Q DΆΣηAΦ?”Ι—>ΓLG›Υj]UpA†CH!b /E1ƒΑπ@1ά3˜™ωπΑ;οόΣώ·Ύu7ŽιcŽbξʍΟ~Ξ l'¬}ŒΙν&N‚μΜΙ ’°¨„…n!\#Ωptίsΰκι„›—ˆ2yκΑΞ Gh³;€( ₯M§a1[ΓCΓΑΰ^™ύμΎ}o}πΦ[?ΏœΓ»oΉ…ί{λ[’ψ¨CNώΕ_δo~σ’»nΧ_yυYγ›7ύ™ιvN‘΅“΄&3Y^ίc%ςθa₯ ΦΕQ±δέ>γqδuέqυ³΅ Έ―gjΥΑE]¨",R@C Γ ‹Ω™ŸΎϋ‡―»_>ψΕΕ2ΆžτϊΧρ»τΗ‹΅?',§ό/α;πΛς^:ν"̋/~ψΦmΡ]·φXSN‹“Œšh!£‰„εΡ)Aξ΄&Š#V{M69ωγ[Ά<³•ΐ‘ˆP#©Ϊšδ‘ψ¨y;–_₯JUύ}X`ξΑa‡υYΗ»Λ­Ύδβ=Μ²›`μ΄fΑ•Ζθ@€Sβ¦ߌ―ŒηΤlR*΄4Cm©œŸˆdπ€TORcΒ/d­G‘ITCTcWΫΨX‘Wn`+†Αc4²frBΦ¬˜ν7 Έ3§ŸxΑωfy”Šͺό,€οŸθDϊkΉΦXϋLZ{:­Y'‚Iˆt!™3ΛHW ”&$jΩ¬οκΑHΌx‘&wΠμ`m―vΤΟέΓΐΆk’ €”›—r‘ε©HQ‘Γ‘@dND¦I>ΰKΓΓSγΎόγΏ,·ωΠΊkΩ‘υn1Y~ΩcVƒ  c¬1eΕΨΆB κΚeΤSΪ,6£ΖdmρŽj{Δ·Ήζά‘}»½ΓΡKά4?]u3 A!0@Š’/δa³³ίλOM½ύϊ§?Η2Κ†ΩρŠWΌ‹Yv E&έ Υοκ₯ΎG΄έΖΉ[6υMgt?‘΅œώα?zί>pιRiΗ΅W\±ylσζwεccgr »kŒm-Σzί¬§s>γ€Αδ¬ΩR‡dδ –}ΆšOŒν§9ΐΓ³]2ΔpfF<$sswΟξί¦½χ½ο:ήηaΧ«nϊΣι\Α„―cμξ*ϊΫ‡`—T&[MJ5τΈb6nͺ;*rƒͺ˜ ‚ƍωOτp΄mE₯₯ a•eŸΌ]b5‚5ZGJΫΊUFL¦γΉ\σ8όAΗγυΦ‡ŠΑw½νmfΉrΙΪΆΩ=›m0™]i2k˜η­ύ8Ύΐƒ ‘Ÿ‚ΆRαdG) E)jα/‘f₯eJ.Ρ$\ԁι›RsB ³’όΰ"jNΐzqL5‰υгF·ΖcΊ½θ*΅ΜΜ`8;4‘χ1?wRͺ15ςϋ‰„’ŸVΖ–ΜŸ#‘HΑγ'd |bω·ώΑƒ §Ώυ§!‘@ƒYΕΧ<Qχ’ΈR[ 9IΖη^"ΎΧo£ΥmΉ@ Z¦ΫΗ°άXοƒcΆΐ»ϊjξύ§XuΩe‘17ΣΪ'’f³PV (&HΫ₯΅.N¨ΡΏ‚W…ŒλΰπφΎδU_Κ֘²φI­Ψr χ8α­΅τ^K4—Γό8βν‘νŒ—Μͺ£ΚsΜψ£Ω9ώxv_`Ώύ__ώZ·ϋ—οZ³aŊbλ†uΕcN9iΥΞ=η™»OήσΗ'Κ}m㳞u½ϋ\> ΐZ(dάd6sd‘0£†w=~H°wΧ_˜Λ&©i-vΡv7[΅oˆο–―βŽ‹Ί[„CΓ‰)5ρξlΖΘ¬…trBΠ…Hΰˆμ±Οήrν΅s0φ!Pξ*fϊο»χοήχ;KΉOlΕ+xχΫί.°ιy?»Σ˜μη˜ηO±…ΐJˆL€¦Λ<§±e-DG8»ΫDΐ IDATœ’ ΅tƒlfL Άϋ503iŽώο£{‰_2Š·πσ$w‘*² 40΄`I6ηΙ!X)γγΫ²ΙΙ wΌόε½€’ώΰΎΑΤΤwίϋίλ:QηΙ&ΛN²cc“%ΑLσˆTˆg5m έP8κ,ΝΟ5 ΫνN.…φΫrέuΏάYΉβh즬ΫΛQ=Μ jπFSδΖ ˜ΧH8χWr%‘Θ–_8κ[Ν΄„-Zn†b?7—B£ζ ‚Β$Π-μψ¬ΐΨΨcΨλΎsΟk_ϋ§2ά=ϋΰƒoϊΡ{ήσΧΗ₯?Ϋμ€l|b’Κ(e3‰|ΔΜ©b¬ϊΆ[;©Ή γy5Ξ»$^1P3oϊΔτϊΟσUnzέΣRψ9²Πίg‘p€Ν΅ίžΞJμ₯¨oυŠh«ϋΡ­<– —wΆ{R$-B¬Ήθ’Ητ6oϊΛρ›Ο5½½‚™νχΈ†D?ύ΄& wΥβΈf[ˆ]2ΜCͺ€Φ‘[šŠ ύ94Yα&£ΝF¨6•„Υ=Φ‡jD,’ASsΩώS‘H‹W4LPγA5Ό—θ'ι-} σ>ηΗžϋE7}ΗEΆέ™ ŠΩYΜoζ«_ύά1«žώτ[ό<Œ9ΝdY-φ‚o‘,!ξi™ΝΪ`’ΥxόΥ‰ϋ€ρH8βnΘ#]JαΪΈΠ8Ώα˜'GΈ²AΏ˜hΜ‡ύώίΘέ?ψν}_ώΚK›―{αx17χ(δ΄vάδΉ©ώ'Λ΄p¦#ž8’lLζΣΛωl1AΥ "ζw°:·ž£h_drIHh,“$Z’ΦOkEŒ@ϊΞΞΞΪ½ωχααΓωΎ|ΰΦ₯6ίΩϊ’]+ΐ―‘|¬νtΘ,#ηJ-'©‡]6v9KλqΔΑ΅‘LΆŽ]ώ­ŽSΨ|ˆ° )·[$—}Γ¬R‘Z8μ­•½Άί—bn/…(ϊύ;όΙύόΰŸŸHσδ]7ήx›ΏeQƒΐ<”dψ°B+ή1ΟΥή°Β.`'€©{ξωμώφoΟ_,ν΄σ¦Wρ·ύOχMςm/}ι[²±ρgΩNΆΑt»¦ͺ31ͺ\N«­›‡ΖMš.ž·sAχΏPΕΤϊ΄YTiŠˆ¨eγρ:ZηS­GQ3ΣE1,ύώ·gxΰuχ½ύŸy€ΞΣξWίr{ΎbβRζY»μ![ξζ-€δΝϊ΅QσΫϋ±Syλ‘ZHT©XΤԜΉqμb– 0Že §-ΦΕΨ:1rf˜-Qnž―BIΛ0χα`€οώ·Άlω”€HZ„:τγb°ώ@!ŒΊ f:‘σ$ϋ’x!Κ O(ήf‘t’…Ά‹\ίœτQ…wΧ*•ΆΌ€†@OŽ›s<ύdIO†ύΝ"°SΥμ± I7·eA[’gΡ©‘D0˜žΤH¬Ο₯Φ~΄ε_ΥΣVšKg‚0Ξτ9Bώ‘΄όˆ΄ ύί/JΒ¦ΐάC}l8;ϋφ£Δ ­ύޱυN]UŠ`«SUΥ=MZ؞`ςͺoŠυΚί΄β{s[8ώ|γ0v΅νuΰ˜I4ζρ¦Σ} MΨ'(ϊ UδΥ¨νkΡXA?ebN|t <ηY›΅/UJc!&σ¨ΤQΔb3A+ί₯ŽΐΆ΄θΥ:&Λ/6y6ΩζNπŠX4νcvΒΘ‡ξC’:†fΘΧ%&ͺΥ1σZYrAΩi$ΎS»ρAšZΈfΏ•f?"υ!KeMžχl‘m/Ωό‚μ§αƒ™Ωrϋί.‰ ΅gdξ™΄£kϊ娛§ψbτΚ$„cpƒΏ]H…ΝxΧ WQW΄6ϊόF£ΟϋVTœ%Μ{M o―TOιIΒv{΄έ^"λeX¬ΟΖΖΏν₯/y³ ‹»ζzΗπΏηφΏηη~ŽwΎε-K6Ϋƒ­Eαm?Π ΜΦΕ γ‡ d;ΑΝ–8ΘθL.Ά†ΌλmSV?ύͺ•›ΧΏ%ου4ΞZΣιP“ ρÙΔwΈTΥωaΑ³€uv<¬Υ.ήφNΖ‡Αp-rί½EΪ"›˜0VΛPž`σόΆέ7ί|_παάχ•―ύJ‹_˜€“ώηyΗώ‘ƒŽ¬ώ+Νωs°œi―z(Δ@€ή’O<‰£Φ ο‹Ϊš¨ΗΦZνΥΠ«EKA‘ΦΈΟψ$ID&ωJM’…Bl§™ο!‚jgQωΒΤλΓΆΜMΆΈα$E&"iβΰΎ°Ώ·uΫ7d8Έ@Φ\D†Γ‚`žkG$¨z¦γΈό‹Άήγ\’ ͺ{£Ό{L*ŒΌ=Oφ%š$HΓρΫ΄?·?ΥΠ7?DΔΐ2HτΒ`ϊ0€*cyAv‡Ύθ+€α Τͺ¦ΦŽΔ#έ9%ΠαPt”ƒ~¬λ[Υw0C šžώ­ιΟ}ξή£HΞΓ!Le- ( ­œi|‘ya™FhΙΡ‘Φ,ψfQA6)£š•™g–ŸόˆPDΚp"J L=ζ¨λ˜e€+>‰ΖŽ@·"Ρά±„hL%Z–—Ёf^ΆΔ^΄Z=υψΖQͺΚxψS3½yμŒU:(&Ο σ|δΙΕ sœΑ λ―Ήζ{2Ύcο?ψ»ξσ[^ψBώθέο^LRH”i§ώ<{Gκ§ώ­!ΥΝΖ©;€H›ύLŒ@uaΛ#˜ϊ―ΑΙ“‚hζTDχͺZ‘©Œ|ΏU―Dύ@ŽΈŠΜθ/bMŒe7ˆΘ‚οήτόηέOš―Ν8πΪ}ϊΠ·1 „ Yfƒ…-Š2"ZυŒ˜ΛΔ9GΘζΣκgΡs¬XέΒЁ[œQ₯Z΄V”m,¨ΤβNF}›pΨQλQ‡Κq"Ak‘MŒ[`|₯ΕΆΧϋ­m/yΙA~³˜›{˝oyΛ;–κΌ ηη-ΡΌb΄θι/“―;£:}QΥa.:Q·½τ₯―ΟΖΖή`{c›m^ͺύκ"”pΦGmΦFψ q>₯ωv QevD.YtεΤya”ˆΤoπσm+ΌmΔjUWρΩ= ΄TΦ [±"°ΝŽέ²υόσ^\œsΦ—ϊώςψ‡Ÿ=6§Jβ– –œoΌR€6Ϋ”enžΞ θˆ’Ε«EΝΠτΨ*alŸλ{Z5Oo#έ:T€ρ‰c<ωb χΖΰmdZ<°[›™,ίvμ;—7X2¬”­oή±³TˆΪ~D•Ώΰ¦;¬3ΨΌαK΅έΏΪΜAυlˆmbΈ¦Ό2jͺηǝΰz‰ωC§όΣ %ڞ#’<±zbF?±±™±““ΩΔδcm·χŸΧ]yΥ½λΌκύλ―Ίκ,G"myα ߊFuᚠ©U²­ΓfPΤ=*Ή6φŸjL’qοd|iXWˆQ9n λb4cεˆ«/sΝx…ΦώJ܊jJT·Νς}v|<λ¬\΅%_±βi•+ΎΈιωΟΖΖg?ϋ ‹r²%ΡύΏmu!-O°dM—±²?πΘχγQ­"m%Αςh>Λ+’”ΰH>¨±(¦R,˜€>r8ιu―η]φ6ΩτάηoΪώλίjΗΖ._΅κ ;6–•δt…8—’Œ—Ϊ~,t$Kpk€ώξα―(vηΞΝσUΠΰψ-²ά/ΓΑaΕ$¬Α|²»QoͺϊhΈ5β…Ύ4)‘ΩΙ ±Έ'ύH-cTϋRΎUαh—-”“_ͺΕω*σΥΠΰ¨ιι1Ώςc6RκŠΉ~e>: $-$ε(3jP―Ω‘QξjnΝΆ0jΒ@𦁳³˜;pπW¦>ύ™{Α‰HpX€ƒ>θ{¨ν„Cθ&α͏JZΞ0TœZQΗΰΉ™^D΅k‚«-Y“ΩUQTώΓWšD`hPaώ#‰ΰf% :eks„ζXAX5²΅όˆ,•Ψωa{€㱑GTπω5tζΆ₯ΣΒP4•€˜X‘OΜΜ<~Νε—ΏήdωΫ~τξΏyϋβYφ…}%ŽΏΝω·=am±j;›ΞΞΑmπάP ’ ηΩ…« 3Β‘ΧΰHγRεΥ½Sη¨iUŸ¨LŒFP5 ™N7λt{;e8ΌΑΠ\Ρ}Φ³ώu8œϋ?ο~ϋ_ά·HxΖόQZ*3-te-₯,ψcŒ³Άt„φd $Ϊ)GnQΧDψp‚‡ςŒθxEΈ-xŸ³ΜtΦYΑ5s$΅κ‰Q`€Δσ2rVnΫ€Ζ¬°ς‘^ J­›˜oΚί²ΐ#ZΫvtά#‡ζj|ώU§ς‘σ|7DΦir‰1ίΉi…Ίέp0εθ!mεΊ@`{½±ξΪuOΆέΞ_σ΄ŸωΠΊg<γiΗ±'‘ΛF$XΘΘΗωκF&QΚ0ZΤ”=-Z«ΣΙ6ΙB[«UsΡc.€€'Ϋν©-1#"‘z³e-J`ϋ ]β‡­egυšν΅k_–uΗ>»αͺ«~oΛsvόψ­}Ψθl]ͺώR“Έ¨^*ΐˆσ,†Ρη―νΒ樣Œ. 5)ΝΕ΅_ŒΞ•l½j.‘Νσ:«V­ξ­Ϋp•ϋΰ–k―}ΟΖη>sγβe€ΩήΑχacφΩ~Saλ΅ό$Ν³Νx.μbaη 7ΈέeΫ^όβ[²±ήΊ«WŸiσœΪš)Α²Νκ¦Fϊ»‘Zη%΅x›εK±PM0TΚwΡ=ŸkΜφ½Œ¬p+ΊˆSUΕδI˜υ#στ•Zym l·3žMNvύΌͺύάΕdzΫ|” `ξ%Ι“βzAά£Z'Hp·χŽ4-Ηρ,–G8Ύφg‡œ;q”;… ϊ5Ÿ?ΰˆ‰ΔΓ λ‘t‚’{ζ£'Φ?ύŠWφΦoψ“ή† ΏΫΈαάΞͺUc¦Χ1Ɛܱϊ)y€m87χ φaX,pΈT=Ÿ†A]œ/ώ£…υ g₯ i£κEI#ŒyτŒΌ9!r'GηeνeoGΟ2ˆω“ŸOlA"E0wθ†ύ~ki 6k™(/D‡›˜Ϊοb*™Ί•Ρ’QŒ$Β'±~‘έŸš>TžΉeκsŸŸ9mWH1(†ƒ-Δ€M£·ωΜ•°7’ΑԞr%m]Άa^YΣΒ(<₯:Vc֍}φSŽνGΧoLαδκY{Ψ{p{`†,(‚ˆ³M΄v…ό›oφi›Y‡V’V›5έ3Κr$ρΔ¬ε;rώQiTB^θ0π#£ιφΖz6<Ξ{Χ\~ω;Χ\tιρΙϋk™„΅»Υ8ΟδŒ#‡r‰{£ͺE:uhί‰ˆ΄3ΐ£ξsρχQ _ΆpGξΌ4 )}7rvMATΉ΄εˆκμ‰Άο©ˆ†%:h ;kΦμμ¬_³χ¦Q–W•ΰΩχΎχβEdfDΞ©TJς [G,xεIxΫςˆ-(0Ϋ‹’ΛU4]Ν*ŠUUξ.¨Ζΐͺ6ΆUC6¦)§CDWf¬W2:‘β­‘~τGqΛ»ήΕ/~ρžC―}ν―vο~koϋφ=RF&―Δ­ξOΩfyH€“Μqΐ]η6ΥaΗ%μh`w€PΡh1 ψGΕ2JΒpyJ;ζ7ne²;΄~ΌΈxσ‰―ίς«[#Ή~3¬Ϋΐ°σŒš!€¨[γβg–"Ίœ΄g‘εΰ²;ΎR’LΠz„ ΪC?ΧΧ²χ£c}θ‡kΒΰ:‡œ–Ÿ=ΘgΫ΄@žλ{ΕΒΓϊOƒ½{~avώk sΕ``eςΛθυ.)†sϋΆϊσΥ£•{9ίΝΊ»Ε¦ό…¦Π#¦]ά t$εZλT¨σ„jΚ¨Ό!mΑGχoΘξb>‰O!_φH”LC¬­§c›κΟ66’Τ««.¬w$΅šβ`{pœΐι2δ@#3S3 Ψί@ƍ³oSF²rψޟ_=vδξΝCrλ‘Œ«―‡@ggΪί}ώ“ή}Γk^Έ{0q΄b›T1n²{O e‘(ΆƒΑwnnzƒν‘QΉm΄‰u"γ,μ„O΄Χm"ϊœB3_θœς<›ύNq{ τθ€ ΫV: ι[’'€~”jΚδ )››[ξΩσZ™ι}rχuΧύ«3"ΑόM­R| t?"m ―χ=1d ‰„f:”·¨άŽε―Žk™DjιY°Ÿ $ ¬Ϋ₯(­€Θ{Κ{Q€θχΛΑ]W wοϊΥ}/zΡο{Ρ‹ΎυέοήςΓ E’?ΏΨ°₯ΡďγSθ­DA³3+h jl ―I΄jπΈ}\”`Ψ¬PΪy΄μmF…ab±CΙ’λK€jΑH χνΘpΟξvΰϊλ?vΰϊ—ΏμlΚγMΎ:Ρi‰LSΓίAž›$†dΞώ Ί4Όs—ΖΝΎς†7ΰ–oδΑΧΎφβήόό;gφξ}c9˜δwE”p;ΜX¦Yπy»”eΪz‚Zπ>Ψ΅Ξ”ΩzΪ3ΘtΛ/G―νη6‡Κr`χŠ€‹9tqœρG] ‰/ήϋη=φΠ7Ύ§e1sʁ/ΰu3d»υ!O Υ}aΏάgK˜nsoουIŒόrκI£•AόΚUΩ HBά0 *–’wŒ{p#I: iχ΅Ο~δήΌΰƒ]»ή2{ΡώΧχμ9T΁²ΜCFQΚς"ε–IΗ?υι{λΡθf©ͺΪ€3Ž΅‘I~ΉŽ‰ͺν58L’ά$kLpΩ:μ"GΛ’¨ι@ΗΩ01¨§ώ@tΆ»]Œ¨MhM@(²rμ˜Τ㱝A„δΞ#ˆ¦δu₯1νΐ¦νRΟ™7]¬δBFUqΣΓ'Œϋ>AX9rδΛ'nΊι—WώΖ›δ²Ώ‰Ρ#Ώώ¦Š¬ΒNXθŽ—X…τMDΨ ΥJ¦Ž*Š™²ί{Ψ¦gςΚU‹­ϊ«LŠ›θ½΄%bp*ήu=‹Ξ„¬ύMR@ηΣΪ%λVω€F“œΣΎ“Ž}αœ­έx8ΩΔ|ۊx}ε Ε$)3{φ\5XXψ·;Ÿυ¬χΟ?ν©oY₯η„υ’Ž β1΄πψΰ”§(‚Vcmς^Π€ςΟΥMΦ΄’‹ΛЉ3–?sΐ\π½Iš;₯Z¦ŠyOΎS`‚hXΠϊjΗНΗSρΈ΅Ο^ΞΝΞ xQ97χα]Ο~φ›Ÿ^ςC['ΖνYH0N–΅؟ϊΆΊtSςˆ° b< ڊv7υU|φ™ΊJ;ε,AcL GLž]Hά”ΰΩzBš¦a’©kcν«ΟΙ΅5“œωhδlΞ3-γςςvT5i s¨'ς&i†lΟυδ€€σπšΚS‡»Ÿϋά§τw-όΚ쁿4ά»χςήφνeΡtlJJQRτz{Pφφ‰ΟLςžΊλT€LΓ‘ι°Λ6%>ΫθT„g& ΄‘‰qΚmN0 JΉJ±Wζ:ζ[sŠ5•ΓΙσx¦Ρ’p4šXΫ„Κ”Tΐ:ψ?²ζv›35Τ'••˜¦:ƒG₯aNΔέ>]žΥΛ+γΡ‘Γ/ίμ[Έό·;’Θ§Ϊό)ΐΊζΡ1ν` χaΊ§H0’Ρ:“DHΣ›|Ϋ°ΒΚ²@Ω»h³σx›΄G|2ΆžΦC‹’4%h֘—=l׎™N‘DξxθΚ&³§€ξX“pz‡Μ‚֍“€M… cΖΩ¦i4ε15hρ―LvEoφ’‹^SΞΞ}dΧ³Ÿύͺ­ ΊωhΗ„Μ'ζ4@E'§μt­ρd5RΒ1οΟ yΰ,Μσ`μ“Όš/zέ9•5`FΫ@[Π€ΓCcΏ χIB \Σ(ΚߟŸΏdφβƒΏ²ϋ{{Οώ—^ρ·ήϋ^nκ¨$4χ†E6²‚λ6΄αSΊ ΰžͺ4“sf«Ρ˜ω™=[½E¨eKΜ­)¦ΈGg}»ΗL0Ρvζ~€$Ύ= α΅λ&lΨΆ˜V«o°cϋόμΑ‹~bK^ςΗ{^ψ‚ΗŸi ] §―«¦5~ΐΰ)i‚ΆΨό›Ώρvzνχ_QΏ7³kχΣ‹^.Ψ°κ;T:mCΨη^t£κFηrΓΣε9`­¦Π‚~΄M”Ιz‡“G@— ™cvƒ¨ IDAT A›7`'†˜0~ιφ/š³ΊΑ‡©GΣxiρ+·Όγνοیόέ<œ’ˆtdž.΄@"—‡Žη…“Ϋ„φwΜE”@’6ΟΙ…Ω‘οΙ)Ή•ΘΆΦ₯‘‹Π{™BCJ²ΨΥ¦3bέΆΓΖE\ϋ]m;―ΟΌfο`ΟΟμή}γpίΎηvοή^Ξ Νkr … WΞeΉγL|ξj4Ί΅—Θ:fΈΥ2 Ι‚Ρ$`°πžVM15~κ”λh’PrΑνΘΒ9lkJ­>sڞ5S\Κ…Γ΅}Mm:tIΞγOΚΚΡ£‰dΠA—4PHζβ0ι.Π΄ͺM0¦" Ήέ6Ίkƈ“@33Yˆu-Λχάυ[‹ŸύόίmΕ],ϋύ`­)&S3ADΛ:LBŒœ@w˜λD’ΦH3άφρ%}₯ ίίT}7LRΠaΖ‹¬9ήΊπ6eΚΰkŠs~4šfV¨bq¨ΔΔ<:P ¦Π4΅ž/1‹ΣvN―%=,˜πšA*έ| ”₯Ο1³wοcϋσσ7ξ|φ³v+ς€τφTχŒέΈΎ>„TBM$|hαξΖ₯·AΈfs΄qΡ/PΟh n- 𦂐hύhM R[άMX~Θ4 £ΗœDša:ABŠ§… ιΝΜ ·]|θ‡ςG»ŸχΌ'έϊξwσΧΘζ”ΚŒb%rπΝ‡[LqΠ2/ λ<΄NΩΫξu“χ$ΖQ66•γ—‰)zIΰTyωv„¨γ»fƒ¦‘tθΞΩΘ>›fΗ’ΑHwΩvι₯/œYΨωΑ/~ρœI )ΉΏ²ΝύWΞ‰οΑlu΄¬,B4^ܝŸ­.Ϋf_ΏκΥ»Λαμgv.<¦ΡCаIϊσ%;™£΄§[V†c7,“μξ,¨lœk„΅΅YlΉm<‘λ!ίPBf3Τβεmί(³ ρ̝hΈŽu%(π…ΣCΐSΛM €―³·ν‘‡(‰’«MφιgžΨΣMΟ4”œέΣ4@šύ„ 1θ₯%ΰ»ΪŽMn ¦|Krt>4t«€™7.Iηα΅λ™Ο<Π›ΫφγΓ={ΝμώύίٟΫV@ΕΌ¨οeoFJμ>3x@½ΘρxEκΪ$₯:½σβF j‘…XmjDmbCkΠ¨ek©“Υ.Ν&Lνι8ζS”όΠcΘΛj!¬˜₯λσž' Υ„d²Rq-ΌNύŒ(΄LlͺNΏι|Y»ϊι=€ -]6V5I­ŸΡρϋ«OΎnΛφ_] λ±’Δ±έƒPc|dް)4ΕΪ΅ΈšJ[ΰITg L#<μ>mt"P@P–󛣨tnT8a„m@*ύ mΥl… }AeΎ„ λΦΆ‚™ŠŸΒƒ{.D.Fͺ»šΊ„„Β­Β(gZΣ…2aj˜$0Ÿ ƒCgΠ)!ΚΨWα„ '`θδxwΜξΫ ;―½φ?‰ΘpSΧ |(²Ζθ*τπƒ·κSR“΅%ƒΣθ‘Ϋ{1G*υΪ ŽλšQwΑ΅νΌ½v?χ9W ~qfϞyfηmε ―ŠN|η΄ΰ»_EQΕΑ3r―Ζγop<:ΒΊviζkvL(0i"iΊ–k…)’JΘXΟΪτ[γ° …Χ|–OΥΎŸhj$G\§‡€ΤI2™SXνΨ2ΐ¨λ³cΡ“²tδˆΤU΅Άbυ`|†β―“Ρ·ΐ…=-"οxΒθqΐΉwA11tΕ9©¦κΥ—ξΉλu[}3YUΤχ PLzaςc…ͺρΙ.ΐΞύ―?ΨvρtLA.»&ς&(Κ]23Ψt·.ΟωXΖu§Ϋ°½•:Ϋu@„ςAΩ’wα ¦ ΛΌy¬φCΠ1 ± "`ΈϋΠ֍°βγSΔΨk0:nΙαEΖxsz»Έ#θ΅₯1{πΰO-\sΝ[Edϋι_΄"’μ˜½p<;z αΨJΰθ-ͺΣήAPš;:¬/0έ ΄oθΈΟζσ»±n*Σφ‡‚άUΉ•A%ιIΣ„ Θnj ¬SƒŠŠ%Hq―€ΡΫΆύΰΆΛ.ϋЎ'?ωΊ-8Ύ‚DŒa8D^Βd―h;Γ5…$'ΐ•€ρδBΫΆΰΥqOΟ§:V­/€ιž69rζY`XiΞ*$¬±©$ BΗ# ¨vdHDzΫΆm›»θΰΏ;pύυo‘ώΦΟ“Σ‚ŽΑΦ•Μ«Ώ†ί£ KνFΕΔύ>`Ύ“ΉΆ]rΩ[gvνzŠYΨWy™P±B#αίHc_&YcZκρ$ηξΖΏgڝ0Kπ¬ΰDΎCLu’YJ›r1­»Γ4¦ιΓιtsF’™/.ήtσοψθιGF›ΟͺΎGηN˜~DΗδ†)CΈ―lο8³₯·TŠfο Q‘›μ-B―†”ίT©ύH ¬3ˆfj"χFv€φΉS#ΕSΟ{d}m#IMe<Ψ•’ΞK iαΪk{žwέΥύν;ώΓμΑƒ―ξΩ»½b']Β±Š.Κ²D―|θkΩz碊‡λqu” €hYͺΛ9Hd'χεZΑεΖ} ΜAn—Œj!aησ§ŽνϊΏ=¨‘ƒΕŠςŠ,E:Δότ?$»%ϊΟWπjqQd<ΈHqwh½γ¦YŸjά€?²ή(WΫyπ`”§lG¬cJ³vWώτg>ϋ‡[Ίj²WU³ψ΅“>LμΜ}PΘΈ–hšΕοh5š-H‘DR­|Ί…,Μ~Η£ž±eKP’Ha€gš>ζ`‘1’EϊSΫ9τΚγΘ(ΫmΚ25€³ , HqZD5ˆκΥFpYΗΎ„xcΖ΄|^ά‚ώƒ€yg0ί`e“!Ή!KΣ€eξΠ‘7μ|ζ3ν΄ƒIš8%ZΛσKΛ°ρ1¦$[ƒ±°Ν[˜3λΒg"6 &ΐTπ»‘:2±-Τ™3κψ"Α‹l30ƒaζ†Βρ\O—9«‡κcΡ6z3Γω…+ψπφ'>ρΉ›@ŽU V€ΝΦ‘Ιι”ψ§2ύk5fLM$€Έ>‡±ό~]Ώ‰eM’XΊ CΆω©β™χ7w„Σ•.]IΘξεο°ω΅?χz˜;xπ§Ότ₯Ώ,"3[›σhΡ[Z™Lv8*’‹‘‘ΐξLOO²΅¦‘@lPΚΑWΏκŸΞξΫz”½"Ν€Ζ°· L£ tγΠ> ι† [»z`q'΄l΅xi΅0A+{ρ{,oP‚Κ™K5€ :GΣ#ηhEϋΫg Φ$0λ|―φξΤ΅ ίϋβf£Ν`—φ‘bž·M']˜ΑeCŠ]mυJTR_'I:HΉ‹—Σ9mN€ΥΊΙƒ§³]ηϋ‚\­m¦ Δ6€™Ož¦ιPΊfU^‘5;.ˆmŸ‡@άžΠοΝΜΌr°{Χ†{χ½Έ77'RΐœLΥέΊ˜•Q@YμΗtυύuτγϋZ=έΚΊͺ½Jt±θw;θΡΘέ’h1όpœΤZ¨‹ΖDl­©izԚ©³«ίΣΧΔktb ΄Z6H»ίzpQοΉGκj<νΠ‘6wk~iθϊ OD|N5Žh $šiˆώη# žX\-..ύΨǞΆε7³kŽΗ+mΗ‘©λ««Ά€GNKfΖι3³Q@ΥiœMb5c’h7³!(ΚΉrΠ{ΜζV~찘U£ H]μD˜@ΆΥCο—F‡ZhΫsPτc4#”9aΜ‰›x‘)NJ“ΨHΕPθκζ[t±€c-ιγœ.&M‡Z#ŠΘίcrΟζ.Ύψ‡w^sΝ/‹Θμi†U}l›8Iη+8γ ­G<ΎΤ™Ε„,ξ₯¦CthI;’ƒv μj]M# /q’K‰]kJ~K`VDίΙΨ( ΓN&ZhH1\šΈοG;ŠΑ`nηUWύαμ£}υιn€N‡)¦^dχ'–Ρ 5ΚΜeVΔ1udJqλ—i……ZF/:λ£—§φΦ·z_ΜΘnkϋKΩ>D7I5άΤόcΒΌόμ{4χ²(eξΠ‘Ÿ<π²—ύGιmΝ=Pn& <³Ύ‹9CΏc=ŠΘd‡W•ΓR5w7αΪϋ½ί»v_FYτb.~ba¨ΑρvΜ«)„½ ΓfZwΚ€YˆΠπλuΪxPY8³‹Ζ΄":zV2ϊ ·neΧ¬ή-Ι*θχΎy0’+:2܌₯Bhٌfοs±©\FΏ‘tښϋ₯”iρ4ν€q‰©ω$Ξ; qΗθ¬ΐσΙ£­p)'EI± ˜½7γ6Σθ₯*va-"s—zΓΞg>σ?ˆHyz•¦Ϋ’λύ€Ÿ5Ή±) Λͺ3οJ%h9=jΩ:ϊ¨·Ež¨ttЌKeς.yFϋύέS΄ra%{‘“œΟ…[=J—Z†!΄ΆΪ“ρν±μγ3³{ψυ/Ώόa§―~².t σΨN 0xV|¨IY‘ωB›Ddζ‘bTΛ‹€c;$!±KyΔœΥaN†Yΐn O £ϊεωcά₯ςηΆΣ’D–›©ζk›?2w𒟼θe/ϋχ[s@#kR±MΊμDΨeuξF€π”–ρͺΖn ζœ;έΧάΑƒŸ/z½a ƒ(ΠΙωΥξ woΌάtΊ₯ǁ4«©Yƒ«ΗΗu˜Ν³qšp€ f°‡½š‡έ0΄‚Λ’˜OG¦ιuQs±oιψ›jeεc7ίxγ6a5+½&¨ύ­pέϊ…˜ΔΑw»nV¦[5Ε’0©Σ]£ H΅š ]ωYCΗ`χS&φcΙ­}ŒYζπtήόϋF£6ΤR1ς`Ÿl;€€…g\3SlίqΓΜξέο˜Ω»χEΏΏ†ξ"-(#jK―ΊΘGQHQ–sRΫΞΔχβh|k]F"΅œ΅•2…©7Ιή52ίxΙΝΉ¦ι.G+ΒΨθΑ09hΘ {‘tΥgΟiά& Ή€ΰϊΦ”š…@vO*œ›ž”₯»ο‘Ίͺ΄I‚dΦFCb’hζ3ΟAa!pˆΏdGƒv τ3­Νu{ΨΠh‰ŒŽΉνΔ_υΏ9#·¨κj|$K₯Π'>nέ%VT*”Ί‘Χ|?Ί£°ΐsφ`νž+z½’θυnΪ= άωšΒ ’Α‘τM۝“Ζm ι;JZOΘΎ’5¦L6ήfόMgaΤvpˆ“<Ζ’ŠΒ„“bΑvŸŸQ@Rτι`ίΝ•ΤӈHIξβΧ$pl΅ š°YSf^όS;―}ΦΏ:M›Θ‹PβλzlItΌŒZγK=M‹¦$Β:YΥάώž+η7Δ '¬-@ObιMccΟη¨Τά‚VcPŒ-‚y1£ ~©΅“XΜ𜌬ε>gΡοΟν{ΚSn‘…ΣKΔ ‰·ηΎ՚gξGJH§ςΟ2e wq¦/fŸ†«`Χ+Βάώˆ€ΉΐŠ!·ΟŸz MΟαuΨ€·y0πePƒXΌΤΰ‘v-lF KξίζΧ_3[$‘ΥίT8cǞ4ΌRPΊgƒΡΡm$ͺ΅«Ÿhβτs’Ύζ5ΏάίΆν€>㽨8F.oV³•έ2Eα™Ο ΘP F7όΨ²6¨˜ƒPγιNH:f¨Ÿ)@žΪUqβœΩŽ2"―flΓ‘ye αf-šάo4fQφΏ°«™|τZ|ΤΨpγφ€q>c’B+~8b7Œf σϊΝΣΎθΗ¦}υθjz€ΣΚ$PΎY+ΚјΆZsΣ=ΨΈ›T£ž χLgάΦkš‰xA#ιά‘½¦ί›}iaηΟΝμΩsY9κ5! Ω OήΕ·ΕŐ„~eyρNHU$- —‚I˜4ιr3+4‡§dΚΆJxL:1ΜΆ`‡w£¦Wšβ AΧH”3 [ά7ψ wuv|ž_Υςς€KƒT¨)­„N·k]’κΓS±υόΊ’Nμ…FΣ( Π:"FŽ„ Υg!υκκhρφΫ―=s7΅ͺXΥw™*­Šx±^*‚K›(­Φ LΥ€„S[ow†zΎYΘ4‡.ύޞͺEƒms‚²§@j±ο΅£Ε-σΖ} Vπ0ΞB°νUg©al_»ΐξišPΧθQΈ8υH¬"‡B1ιξš9ξjA; Έ΄θ€€uΞ_Αϋ vξό™ΟzΦ«O½ΦKΐ0Lς‰”vzCb¦Ajš4+eΚw$ςι2ζ5$ΤΨ*XkjP5€²™h2Υ‡J~WwΧN_ΑI[Πνw&ΣŠΘ”~=#bς΅zL&aDύΛ™™}_ωŠΘiX¦xžΓR§ͺ "·X7ƒμΪμβς)¦ΡΖλ,θ΄αˆv‘d'ώ‘ur¬Ζ¦δrϊΌfD¦bΫ³{/BΉΠΑΜA#Ε‰`"Ξ43¨ΣgλΈ* ύ~ٟŸ™ύ/yΙΛ6GͺEQΘΜγfgJ»‘ΝzZ+_Ύ:·V”nOΠX9Εkχ}ߞΩύϋŠ’ΘΐRέ αΡE0ΪsΒΐ‹Η8ΪG53†ΈύaξΥځvLye0IVή‹’k/q›­7zμwˆά˜Ύ+G|qΌ|β77Υί7QκΨuφEq Θ„₯5+,Ž₯F•5po Χe;μYžyΫ j—aόT ?Ι0θΝt%1*ύnjqAͺR_‰θ{Φ©€8‘AΨ Χ9$υ}ΚώΜχv.όΒpοήG”33­†….¬¨4†WŽ΄)wη#% sg€–ΏQ―cU·¨ˆρ4 Ɗϊ&`‘ΈΫ’}ν8iΕμšƒ”©X{ršάΩkD( ~Η&NcZ΄>εμ₯…Z?…|pΜ’²tΧέŠΈιYRΧίD¨ή»bkΆ>•θ-΄ΰ,t·<¨zαRΜ’YH=sιξ»?²ς•―|νŒέΧZjVΥ­LOθεSΕο4/$1·Ι‘ eUΪξd¨xk“8)Vtϋ{( )z½ν›v‹@¬§IΫ"ƒšΞV_Š*γO-dΙސg 6ίa Ζ΅E!ς$ί:ΏPB‹έ² `Ωužƒ""d δΒ–Tϊ‘ˆ'χ&I}IV/pΤξˆ‚N»›ηRΞ·½ήΟ.<ύι—ž–τΨ1Žog: šm+€Sμqε8Jό–Q?£ad!8«+S’΄”^8Β”οS18€ΜdsΚ#ζχ¦Έb­ΎC…ψΙ4F άSiB8I›"E!½ν;žΊϋΉΟύ19 $Œμ{5ΐΔ1―:}ZΕkAκŠ Υ8[Ζ’=§Ρ ‘t†˜±!nΕΉΔ7ΥΤͺ„v4μ0₯XWŽ˜"κߚB0ØΫρμφ\’_8δ•Ž0˜ŸίUΞΜόόξηNgΖ9wΡE_,Š’ΰˆφρ3y_λz\±Ώ*!KV E²+1 2#Β$ο: §ΨD­xΦ`Ϋ.h˜\υAQΜΛάάM PΙ&;Jθ‘,†Ϋα ¨Α X»έ¬  n­ΎλK»%^œΦ1‚2mΘ,°ΕAφ°ΖJΊd†pJμJ ιB•³Gΐ‹πxŒ7έ«Δ`ϊ§φώλι±ζ; χξ{4ΚήΏ?ΥυΡtiΘht²\½›Ξd₯u€ &Β>i3ΆB'Žh0ΐΫ•±΄ŒJ.Σ9Λ νžitΣο ˆqk 5‰σΛ{Ί ΄–ύ@†ŽQΖ»C‰jοΑ ή#«­Ϋ"De!Ϋ.½τ­"rθ4γφ5<:Ž‹(€-Άˆ˜ΕLͺψK¨’JW“q)‡δ,Ε:fπ§ς;‘¦3―8'¦YδU™ ‘5¦< `Θll-ΐ«5vτ5»ΐ£»vύκζΠz|šβՏ4yΕΔ{ΦΪ]2œ˜€^H¬ZR:σΩSΉ.zωΛ―(·Ν]Δ ŠmFσΒΡΨ K’‘Tšώ4³oΆωGΗΆκ/ŽςΊ4zˆ`?F]m,’½zZ¦±’Pπ)>τω)Σ]Ω323™w(΄%(ΥΚκHŠήg·*τσ!έnηYO‡~έλ$ΓΘ3 xOm£­"ΥΊ΅vπ „)qrb₯Υwo'ν$šerR5ͺ4π ob@Ώ‡nz„ΎΣξZ―3ΖZ˜€€sκͺΉήόφŸΩ·χ;z33‚škQs­!Nν^Œ ©«Ζ )Šέ³ϋζ·Ό˜]Zώz=έ!5•5©Ρ‘΄/ΈNB―†nm@ŸΆΆ ¨D!Q°Ϊ1mΒΜΨBQ%`΄d  ΕάΪ–U΅1O ύ&ƒ gXh›”Ε»ξV.AA‘fd(±/ ΄7²ƒU2³e³tΊ©δŏ‹bz`Ξ>ρx<^ΎλΞ7ιψR/-URΧ'1l€Εv™˜{(Σ±ΒΰUlgͺuδю"TνLιŠbΗπΚ+ŸΉiKPΓ&/‘*|¬&=Ψ5:tΛT >Έ3ρxsΛσ±"(G,#ΰ€rX+ „!ΜM-l g₯+έ$Σρ¦‰q2MϊMθΜnΣ†Ξ(fΪΈAΗό‘ xΝξη]χΏœ"ŽdΖ*τzΟΨJšI„@2D;+ Ό(5“ΞM’ήO19¬5›(-θ©AςΘqP³=¬ε²Φ¬²λ†Φζ@VHL³6“f‹Cα21ήNΰΘΘq i=‘₯”zвwθ†>)"ΫN5†hφ+•@~σž ‘#Άι› -ՍΓΠAΫΡ,&8»Η Q±λθfŽYν½4v0Ιr]χaΤϋy³τ”’u΅ηΥΏ§*΄τh&XX…š-θ{β0D†ϋφ^πU―ό…M;xL|ν2wβ τΉ‹G0Εέa₯»Ηό|΄žαιmξΫχ)eΑl‘[CͺΡ/η«Χ6ΌλVλΒΫ‚=#/L§Y3ΡαlaυϊΰŒ±Τg£ ΜΨ€΄1‡v`ΈmBh ΑI(d#φ ·Σ‘Z™cΩδg΅ΘΚαΓ~ΛGώdSΖΪ’]Λΰϋύe4ύƒ#βE‘œΈΟ ΟmύΪΪΞN± U°{ό< γTΪI4Z›€e‚ͺž„r”ξψiΟ΅g΄ΞλΤʞ`;φ|Α΅ν½v=η9OΏκͺ?ξή{}ofhέΕ ':1a:αΙΛ^―({½Λ‹αΜe[ύ=}υoξdUίΓΊ&A“δiw uo]¬6 -‘°;?νΤΜύyYU²|ψΎ/ψΜg>p¦ονΚί}…’ΈiέC]EsΘ1χΣvh¨Σ»A!”“—*Ɲ Τ§ΓΓώ`πΈΝAΫ΄ξ†α)·1&Όkj$V`Uήt|`– Β&¬]±NΕ€f·ΥΈιJfšί™ά… ΎŽ½$ݚNQ7γ»4ΓΗ>½¦YKώ t€jνν†sΏu€ƒΦφΚ’$λv=ϋ™l΄z-ϊ€ρ=6BΘXˆΆ=ΪΒODI4½¦ό( Σ•Π«δ ³‡αι/žξ AWΪχ‘;ςΐ1kE’πη~Ώv6Œκb̍ΚXS ˜gHP03sΙΒ5Χ΅·}{p@Qg±Kwmν^ιœ •{ΤδΐDYΞ’Fo«Ώ(οZYαht3«ͺNφΓ υ€)Ξ6tέn3¦t΅ΝΨ@6‚Oσ!ΌH"9UΕ* ήΙY€4­ uΒs)o΅γ<§’²tχ]"¬ΝxœυXFγĎ™e1LΗQ°·nΦ„4ŊΦ#˜ŒlŽΏγθχίΏλ¬ ½ήqV•Δ#‚λœ65?rυιdΓ­4ΜώΡΦΎ°γ:&θη8aC (ϊθχ±)K°ν$iΗT`@±< \3‰Md&σe#ιί·]Oνώ‚ΌΫ “ΕBi f\±Π~δͺ³r=4OyΈΧ7 IIΐΤεŽ6΅&IW ή¨ΞœϊΏ4qF»ΰiνœαώύ‘rπΦS-ϊ¨Š‡PλQ·³‰ϋ D»F½7οU°ΚΑ]Ο2·³`CRΘPΊjΩ{΄Ζ±%#ΰά΄L«υ(XF™&v6bΗΪΝ=4‚IeMέθΝ?μaΏ%"ϋN=žΐfͺιg40.δ΄X%²AO±Z‹Υ"ΣaeπΠ’›­Έ%w32k!PΖ Υ(ͺδ²Β&MƒŒ§ΠιπΣδΆT‚ʐ€'go«*ϊ΅₯Ό3z‰8ΡΓ}ϋ13?ΆΣ_yS5‘ινНΊ6W†Ιy)£­’œ3Y‡)ύ‹SΉψ—?αž=ΠΞΝ2o(ΣHΉ‹ Iή₯£—β hκcEΘ² tή’ΉKD₯KΩ-Έ}:ž*ͺQΌ–$Γv„/g ΦΔ›X-o’n^΅Ό|EρΙMOι5‚ΘuΞ;YζΓΛϊ»ϋγ/ζ)vε©’ƒJβfΘ uͺ˜£tŒ7U˜GSŸηφΌœυ+@ΚΙ‚ NBλœ™¦e ¦ΟV‰κλ 09 $νΎξy?ПŸΏΎ77[J‘^Άc!έ"’Ω’‹ιRν6A¬,€θχ/*fϊWžΌ >ΖͺͺP7_¬R1i’iRΧžM‡5ŸΗ54~J+Ξ©Ο"pΚΫjJa . ˜υƒ5QρηρU--):² σŽ :Η½lΖ:‰6ΜJ³5’c›Ό|±©Ld²Pξ­.ŸΈυ[O?«°:aΝΊr“I^+ ψώEξ*†0Ήqš=’Ζ»Ϊ±Υ4*Eqf"τΓ $*KΨ²,Πλ܌ϋΓ.VVεαmΝa4Έτ8”(wž„ŠΏAΔ"Ϊ°ψtΰ ˜ηΈx0χ­–Št FcKήΊLe¬!;&ƒF_@M³#ΡJλμξEϊδ=ώΌνHY€Ώ7LeΏ’έΟώU§’ ‹f@j¬°ϋ ©J8”ά~L£ΤβΪ€†Š±—eΕ‘žzT “δ9%ϊN PΦ]β1¬0©ΑΣΌξMΠ‘œΦ¨υjh½€Ω± θT!; Λ ±Z₯Χξ}Ρ‹ώΩφ'>±<₯5βΖaΡ|­qcpBΣάlŸΫmΣσ2FL[λΆaz±ΥΊκ€E”ΉϋiμΜ Νg{ŒςZ+λ~ΒβΩ*άx¦œ€ΟΙ\WT2T 4•Α‚IιΜηMd9>ηΰ«^ρ=›sF[³‰Μap£ϋ"86ZD y† :Ό!OνZΌσΫ»ΛΩαό΄²€[Ζ’bqhI8"mδεΦ­8ηΣNμΘή4ƒPLXšβœQρo ³W‰Χ'‰†”/ mŠZO„qXρ9ι”ΰ2©1¬Ξ•Γχ}ψ–w½λ6E‚žTš^œ€Ϊ5!"λaι3Λs«ιφΓZƒ΄#θζ‚έαϋζύ;±4ΐHMΙv΄›&vvaιmC±^­™EWΝL‰σ…υ΄Έ.Igι΅λ9ΟyΑ`~αΝƒωω=θυ̌+άaΊ›ΠY+£HIZΣ‰)EΩ;Sχ«WwΤ£ΡλZihˆƒei:ˆμ#όΖwsΊ€ƒρt°JΙ ό΄9έS'p,%1ΆŠŒ[€Ν ,ω`Ε„IYΊχ^SνP bl5ι-ύΙ¬ΌfYΈ Y2†ιθ‚ΦϊΠμ 'JIχ–««\Ήχή?_ώςί~ν¬ΊΝU-7l@WYu1Φ9Xl³Ζ¦QΞΉΧΝv;ŠBt½(ͺ>xBΠλνή”ϋSλ |f²ΖλΫ0‘dlPΒ‘y£m«‹KͺX η9¨L'toΉW¨θ&½ξ€ΝGΫύώYRoZθb.ΌcCΌ6Μς‘ £ύ’}$3XθΖ5Φώ«ΏsηAΦυΟ=0x€VΗ―KΜΐ‹@ΐ«˜ϋρ fjFΧ4½ˆμ=•Ίw tIΌV i΄e’#p 7%γgF1η6‘:ΌP£*Θ@K«‹€]ΓQoθύθΔ‘²QΏΎν\”I?ξίΣχυ_8y4Z2w%γ ζHQτβmλXΝ}‚ΪW#‚΄Ϊχq 7‚NΆPTΕ;ύΙ6ή΅’,Θ F-·³Y-$Τ²•£†††‰Ϋ&ΣΛd œΥŽ4ω\¬…χ'Ρ›ί± EωΣ""yΓN[Κζ­KVϊ˜λ @εXωΪ•Ρzžϊυ°7½©_Ξ 2:ˆ΄ˆ0‘εψ¨§ς“.v”ΑŽERΣΠ ΧMσZ2ϋφΚ­UM;DΞlΝΉΣF  Ζdz(λίoT‡†~ ~Υ•½ς‹›žkm!ΫdkΦZx~IΚΥL&cQŒΙ¨ΣΪ›ž§"Ί_>kΟIZ†g–χ°34f[AΖγ ΕzOΊ) -3» δ$IΌD‰"t)Ε€σvbRο\ϊ°Ϋ―~ΑΝbΈg™²…εGO:œΎZqAŠ-P\"ˆ^9»Yγ!λkυ]υx|‚¬ )_qΔΔ”(αiŠΘQa"{ έ3 –ah­uοέ@’αγ*jδ%“΅$~ϊΉΡα‘{ž\Υς²°ͺLDnZ„#žΗ#‡–Ι” H˜ƒ•ςjc₯μίОq °¦ŒG££G?ώ/=Ϋξ3 d]E8΄Θ²ξΨ[}άΤ@DΝΐΉ ΉpZ64I±™οτ­Cρ&‡d){ύm›£ ™R“ΞΎ©F$OΠYή»@a’m=>1Rx[X“T΄6Νϊ½A6ΛΩΖsC§;v0TϋS1ΊFγzf@}ξ°„³BΧEΓzΟP1%’P9₯μυž½η…/|Ψ½ωΘ7N% σΘφ,?£§1 ψs='y±ž…]?²g°&μ(L0Τo:=%UθΚjάΖ ιPTb²€IΕ>τωƒ–­M²θόŎΊΒ’βιv˜°«L±§@Θv|Aέ«τ8Σχ)Šr°η/x# όΫ{ώδ#ΥIjή€Υ“Έ‡Hc-*Χ tN γΔ…%}>€-V#;«₯’/ΕΞ4‰$ΉK΅Kυ™όHFŠeŠ₯d΄Ά˜?ψ/œ Ρρ?΄Λ™vΙMJI€ΎTΞ„lFίΌΦyϋO ιΝn{ΚΑWΏκ₯7ΏύνΏΊg0wΙmžΪΖ™N†>;` ͺώ¨’ΓYΫN=žrν~μwόέ—―L±Ο LC˜i°Μ`΅…•-i3ζ^ΘYšβ—}ΫRc©Ϊ³Ιρψx]ΧwJU}“δ­ ό”ψ&P.ƒ{€(FΥΚΚε¬ͺƒ$ΚژμN xϊ½‡E±KŠbVŠΙϋ θF>Β?"?η}l7ν1vGχίKΡλύ·MΟ5u˞Μ2˜fΒ3Ζ•‰“Ωίε―₯éΠ.°–VΊ€:§0…§%­¨>5sΦΫFοRήΜSR“ŸuΈκcz"Ι7V|bBνΊ9]’N5³{ηχvμxr9₯λTh+؎*θQΫEšNJ“&ι.-uR cW °{πΘGτWρ«£­όޏΏΕρψ^ΦυΕν‘?ωŒm‚«ν†cϊΊˆJcQlmΪ‹ Gξ,Q–™ ²£±.οkΚ>§Ψωζ(q¦ƒ9΅ Ρ‰nœΦλŒ9Ά‘²tΟ½-E™Js„°δt莎 Θ‹,ε‚n‘ΎφyΡ]ΐ£τͺcAΤ«««+χάϋσ"2>λ‚N-5ΗΥͺˆ ΄kM³ΎΩ&΄!"κ%pΊΞθ$Ρέfkœ€ „S¨Ί™ώ‡@Xˆ°ΐv™‘₯ΣΌ € "Ϊ1GWγ₯€!ζ`_7V°FΗ…™‡ Ε…`?·©ΒV c–Y°σΊ‘½ά¬€ςa΄Ί Άί­eΥΪdSΔ6NΦΞ*αηψθΟΟο_=zτ_‹ΘœL¬J@«Ί?HΝ ˆ΄šW“q tΛ†"»ψΕΘ’|”t"ƒ±Θ½’ό²ϋ-ܝAsΓΜYvύ– γ5f\lΕFŠ)ϋ½ΡWκϋ"G«@f€†ڍ³ϋφ½ωO~ςFΉυ€°F- O Ό·DͺΌΚž«¦ιΔUΦν=U1₯YI€™%:Eλw8°Πε?œ‚ΪRkͺ;Š “±"qΦέ‘¨τQ’ζ”wtΜ€/šŒΩŽ«°Aώ#_‰JϋΆmυΚςOˆΘι’H£}G‰˜Q‘§©dγY Βhj¨YRΌnGiςv*₯εΕ7άPήρ‰Ώrv~›’©σF―Hυί,r(3Hl'QΣΨalœb…dΫ³Ί­G•p4:^Wγ•33ΉθυOkH]W"2&9°LΦ«υ¨Z^9zttηοόίUσ‚_ψΒΏονίί+Κ²'eΩ“BJ œaΝ‘€}©λR’θχ–WΙΧυƒ«Ρ+ηP”ΣΑ/ZνΟ dΥ’9“)²zόψ‡o{ίϋ>·eι½ϊ<퐭kY Ξθ―B5ڝFι­[…ηΗπά°·ΆΪάΛ«]κβ3Φ'€i ΑHP€Σρͺι­‹™$α‘Ψ½ °οJ«ΩαoJ#γαφα…λά’v^sΝΥƒ]»ώioϋφνR¨Ρ(€- ’ŠjέέQ­˜3Twpν΅‹²Χ+ϊύΛη]vhυΏϊΝ­όξΥκκ·λρψa­ςAΕ€²–;m2φZZ=έκΟΫ μΨΖP‚°ͺ57y©f–Y9ξ d­T•Ω„ΊψS:N” ‡9―7|΅΄$υx,( τ:š’­2ΰ…½'‰JwΨL·S%Z3ΑΤ―ζ`:§^λ"RW•ŒO,ή|'κ­gγ½λZͺͺZΫ³2‘؝5¦έ›'Ω”ŽŸ£.4‘@‚TψJAϋ²°mφ±}ΖΎπΡΣάFΛNΠω*V)wΝ Rτϊ.t˜HΞfQja~ΉΉ/ΊΌ―걈,Χδ ΤΥ}BΉ_(Η)zΌwχRIΑš–Oγ;Θf=¨1#*‡¨Όˆd–ΐΩY t€θ ιb0GΣ@Ζp ΄5ΐρψi'·>’;XRցxΈ*λ ΠέΘ¨ΡΰΨΪέ,Œbτ`:[­‘ΤυjΝz 5Rδ¨Τυύ9&δςNά‰²Ό½(Λ#uUνr^ȝ\λ ?"‡€ΐ)нEQξ”’Ebλ"ϊltΐ‡U½`ζή‡v€ΔβTlY ZϋΞ4]]ΕR Y ΚΣζ½tb²·1lοmΫvΕκIIlυ-υ9ŸetŒ0qέrf•Vq²Η]άͺρOD' RΧIΘ·ͺΗ¬«ϋ₯Χu}…wΌUP|"'€(Ž£(ŽJY.s<^ΊήEr—ˆμ‘‡δ!@±E±EΉ€^9C4LŽΒ‰ϊ0ίΙ:ικηuΓGωƒƒ4†ΔιΆΖvΝ€m% ˆ£ZΊ‘Ϋϋζ !({?ψς—]ςνύή·NΫδ@dμΨ<—ωΗΛ‚sb+Σ(GO}ΟΌΝόΝ<;vΜ°ͺήβ μdha4qΌψΌ7l\tlΓf~3΅iBΓ‚*’‹Α4μ£ͺ’Ρ‰ί¨«κ-½ΉΉ›κρΈΘν_{λ[ο=Ωον|€‘JDV6ςοχΏβߜݻχΟλjΌW3υhυEθυ~’μζQΉSœuqΔyvαδΎr<τΚΟoYΒ©FΖ„qCΨ΄UTήoFϊΫ0―" υˆ;ΓΊ©‘Υ­)qM—ν δηz$#ΖYŒ?'ν΄MΐόU D²NίκlΛδ…D7ƒΰ₯³HšΚwΗ>υΙzαΪg]5³sώσσ—½^: Vb;\M=σi/6€YΎτxY@ΚrU΅ε|ΆcŸψΔ­Γ—Όδ6VΝ‘PΫEξ(i4‰ΏΘ$¦;ΖžΈ)₯λSόlΆιy›15ΕX)ώ3‡ρ=qͺMδBΐnhόΊκZ–Θ xAμN•%Ί›ΤQάhͺ}“Li‰dn·kIfή.Nθ£^]=ΎrψπžΥ7ΌαώpγιO|χ³―yυp8σ½eY.OΨ§((¨k¬Ηe5Κρx\VγͺWUUQΗεΪΟ€VΕZžFάυν;η>ρ‰›vφ·WEb >θg—ˆι’¨>‘ξ1λ™‹s‘MS8hη=ΙΖfϋσσέσΒώΛ{?ς‘_:iœΡ0Ξ`Fh|§βΣɝ‰Α0ΧYndΔXΕζ[ΎλŽw χν{Ηγ©Θz$5G‘\a]IŽΙzU*Ž„\;²zδ/ώ²e$Ο^vi9|Δ#{εάά@Šb[Q–sRc %‡¨9+@_Š’ ¬9ΎRκYŽ«‚²|vΡλοFY¬­‘†}ΕΜ ·¦e\xz¨ž•@/Jςϊ^ΌPφLΐx4 Rάo^{αΚ+₯7;ϋάcŸόδ ήrž­šŽg #Ο1’ό¬ m>}³ώ•nΦή§Z\ͺΠο]ΝͺZY e\³^•ͺZa]/\WΖK«ΗX>v¬ZΌι&Γ¨ν=όαεΒWτ{³³}τzsEYl#Š!z½>κzu5’(DPB„¬«ƒu]½²({Ο(ϊύέ(ΛΙ8KυΛζiΩ]~§?Οz—7Έ­dξΜ'~“Ο$:΅i¬\ΔΪΏινΨ±»ΖADNύΜoFۊό;[ΖBΌθ©’ΐjΈZ :ΦΤργ3΄@(gg!uύ»–5sŠ ΝΜΤ 0lΒ _Lj9T18΄ @Τ0›ΌjαΈ’Ρρϋ?BώwVυίήrγ_“-f”ίυΑŽd °nAλ‡όθλΏTˆό^-˜e]_."?SΞ ―BΈ²κρSΝRlό•£GώΊ7;ϋ_·βϋ4ΜΘΒLΥ8²΄+l4ϋ¨aΗΑΧ[z]·ΰγ—Žd ƒΙ©]η;](—“aGw-=8Ϋ€†`|””,v–v―εωε:,0K«#±YΙ™ΚA£Έ,Ky0_g54w菉HoΠq~α»ΛΩa }ψiϋπ@3­; h†χ“­K5ο]Rτϊ‡ΚΉα"ς­Ύ¬λ;λͺ’'kΊ‰Λ)$ŸΣ·ε„v pA$i©LΈΒ¨[BλζΦ‘‹›έη ĐXsΖΞνΫά‘<Ώu‘„”ρς²T£‘ (l>˜9§Έ Ξ+˜3ΠΦΥ§KψρOΞΧE‘‚Ϋ„=’ΠC˜Bžͺ£ΐ©ΥJdLdj$/XΝvνf²±Αδ\13`qρE"ςK]Ίι‡μ LK-f†zΆϋ©ΗžœΦSΆŸ&7yιΫw|επG?ϊ©SYώK·άZ-έrkΣE?Ύ‘ίΩχ’—|θχ/#e‘¨ ΤγρΠ+_WτϋCQΆ:[΄°u~Ώ`:=*'HΝ6ι/Ρ3IμH©χΜi–½δ ύνΫ΅zμΨ~Ήσ€nf&DΤ40 FΘr 5cα±cŒ0Š-€vΆΠ¬+~λο}ΐl„ρΧΏ^έϋυ―W"²ΌΡ΅qΡ«_ύqισR’;Q}!±ΊŠ’)ŠŽ3Μ5η¬FΗΦ`+Νψ±ƒtζάγΜ»ΈΕ‰`Τ»θπ$‘5Ρν›ίώvžβαӎD›%Ÿ‰όj›rεΦιΊ]PΌi$›ρΐΣΟjqqΎ·m›iΣ3™‘οΜ”₯!«m,Ξͺ˜Π#χΡFΘɽŸX\©–—O ~ύΦwάxΫΩ”έ|γ;‰ΘίLώψ—}Σ›> ΰ’CπsΕpζR¨=dτΐD¬Ά+)γΕ₯?ΊνύοίΊο¨Q Νφτ:ZŸT;›yνζ;"( ΠΊRνWZλE‘¬>ό•ΑW++ϋ7$ι8ϊ$ Η1Fhθ—ž’e2λŒJ“Nϋ..i›YΡθΜΗ9’ρuVIw|θƒάωΜg>ͺ??ΪώΆmΫQ”Ξι(ZΥ‰Š Ÿ?8!Α¬ƒ`F­Ώ, ΚbHΦύ3RΟVυ½Gd=‡¬ΫΩτΤY¦`ŠψΣ|«ΦIhcAžΔκΎeΛ"h’Σf~Μ:F€2ېrΦ­‘’œ0ηlŸˆIYΉο>)Š>Ώ)vΏ6DζlίΰFo‡yCLrΠBƒœ]nλZκε•;ŽεΌρ¬Ύη"΅T՝νΊGκΣΪ&β*_?2Κ4†ΣZukKη₯Χ¬θR"‡θJκEP”%ϊύK6'ω‘ιfiMqΕg Pš?lΘχΆκΘC²‚QcHΚΓδ$r7Φ"rLDŠ””Zo’]ρ…P¨xF‘BHJAΩ+ΉgίήeΛ“‘β’Λ.9ρΔ'?αθβbοσŸΫωϊsΫXWiT‚ξ±™„0ς2λ°3Έaz^ƒ†ΐΐ‰ΧX6λ²Θ[χκ³Aδͺ]Χ=mpίG?±ΊQ$Iϋρ΄γΨ@ -j|Σ«‚Ζ`REI%T:ΟiXzID€τΟΘY~χόΑ·D€νΩώ„Ηύ·m½όΟΦ@>½ςuε ŠΒιϊϊ‘Δž±££^w'ι½θΫζ”`0“"kXƒ^³gΈίΣw\ρΏ½ϋΓΏ_o0ΞΆΊKΎΎuul|dΒωJΫΙ±΅sύ)*Ν91nc[žσ~ΰ†a!"rπ5―ωŒ—X`]ύd9œ{,zEw`Π  7©Σ;,ŸsΈΉ­jεJ*Ih>[Rιώ33—^τŠW>ζ·ΏύOOWυ=έΑwšjzδ:gΩΕPϊižwŒΈwΧ₯?ς#³γεε·$) @Υ5νXw»ͺG άΑ©αl z`›‘ie?~χhqρ_ίσWρΞε―~γμΣ΄tώ›o{ΫEδ‹""{Σ›ώ…$ωθ²ίsΡο—R θΧ±iF z½/lε†»ίžak6%rH]ƒŸΪ;M@™AθmlΝZτύŸXΌσζwΌγcrαΊp‹@’ˆH93|yǎ+0ψ PΣ―ƒ‚@©59”›@—ΓΨa§'”ΞJ)ϊ½νepιΉΥψžΊ/KΝ)έψ¦€O}£•$!"£‡JEUu&n%Tͺ΄sΟk¦ˆ εΘΞ―gc ώLΦoΜήΟ3Z)Υ’T««R”E*ό¨m`%ŸaΠkbš€¦R§EeΞ­?Ϊ‚· Ί‹δ+D ΥΚςςκ±cΏRέyΟ}gσm―G#–½ήΝι~Γ-qΆφςπ}- β$βuΗD±) €n, ˆ_£ IDATΝ€n”1šnΦ‡@E!EΏΏgσv ’VπΣΈΖP•ώAΓVΨ4ϊqΚΜf³("«hΈ-Eϋj‰ύ …Τ¬₯”MΆΒ9`­d·οΨ1Ϊ±cΗ.}ΘCΏλρΩφΝoή2χ?ϋοσlϋsƒvw)v 5)Ѝ¦Ž· 5Fmi―…Ιυ˜;k‰6i˜ΫΆ§ZΔ›Eδ-^ƒΦL€ +*"iLΝEχ-ΰt™p χζσΛχζσŸ™{άcώtΗ#―ό3Vγ§eοuEΏ₯΄μΣ ΡB£5g2’zN.ΦΓ•βλ4oh-θΕ~SΐŸέΡO.έqηŠΘϊέ|f] Esψ›‘΅r&R‡Δ…pΥΦΫgώϊφoφM"rΣ¨τύŸ“’xœ¬π1Ε`ζ§ΠοG!±ΝΉΌŽR v0ύxγ?'RQβy&ΨP5N ύΉΩYŽG?!"§$™Q«¨ϋiπ9ƒ/|―»ΠFθΑΞ)τ.{3ƒ!)/3ΐW«‰εδλΖωΜΧOzRΠj6NeθΠ»ŠnτxqqqυψρŸ½ν}ο{ΗΉ˜Bγmoϋ”ˆ|jΛ_>»νΐEŸfUςΝεμμΓu}ΤδN+‡q9ώΕVηω^ά?Υ[ΜΜ’lpVF_iZΚ΅ŽΥξΓzxpΟm]ΈΞm iη3žφπώŽ—υfgg Φ•Χ4m7νDν΅}s'{‚Alΰbΐ’8xF ΪρψŽFΗΘz(Uα!+šρσΨ‘!šs׏ΞθΫ>EΏA ώκA teqΎΥTMε6ΤR‘c2εyoΔΘΊ–•#Gς‘6ˆ›.VE•:Ρ]㠐apΆ3"ΉΖ$1uI‹Έ5Ϊ¬†z<–ρ‰_:φ—ρΟφϋ‘Ίλ―dΪφ¨WE;\Χ_λt “ήjQωxF;ήΣiξζ‹kύ0'šVθχvlκT™tΎ!α§W[QΧφ«0Βέt Ήθt_5XθIZΚʚ›έ’ˆ h-/P8Qg*Ί8Ϊ±šθΎ­ύΩΝF ž?zω#/?qΩe—,κ¦Ώ™Ύπε!9ξω@ϊ#VMš™vNGnн¨œΧΏP ϊύjΉψž IJPϊΩ‡Ÿ<°)^WxΔΡΡ‘3$3Έg/[uρσ_\Zόό?&";πκWΏυxό#EΏšb0Ψ•ΉΟ‘aυΡ8»E£θzύšAΖ@“0‚ƒ½B"ΝΪFžίΎνͺ£•‹7$Ήg靣²)MΝξKZIσxχ΄Θ‘KΔΖYL8‰gΫυνίώΟŠΘgϋ»zφΐcΏσ ²Ίϊδr8|#"ŽTφζ%i»G¨<’γ’bΎFΏf+Ϋ<±ύο΅ίο8έ­ 19Όc~η&εζ ΐkΰ7γ?΄ /ρΐ’?( ΘΞΌ˜FX6β°Ψ­šb{λά–™WD’u“Έ:Λκ‘£o»νΏ~ξ]ηz.}Χ‡>΄$"yθίψ%—ͺΎœ}!Κ’}θΥοίϊžχlq£³λΐ£Ή@λ4ͺ§φ›=¬ 4nei;«ZŒ’Ή‡^Έ.\]Wq6ΆήφωΜΟ_Y4]ρ€βu6L¬…₯Bj³!Fe³l£@ΡCY^²σιOΫ±Υ7£o―ΗγΓM;“TiΟ ζ?5φΊλΠΆ=v²~υ šϋέ’˜rA<Οο}6hHϋμP:›ͺK|ή‘H‰΄64՘ΟA7Φ›­xΌtQ•M¦9p#9-™m%v%‘ι|Πέ»6κε•#££ΗνΉpλ!¨₯ͺ?£ΕΙ‰`^άZ–ϋRΖμŠqŸ€Ξ†sO9ι/œQ–Ϋ6 Gr6μTέhmξš_FL9ύ~ϊ!cίNf ξξF—ΊͺyB+"X^£\:ΊψH€ΜuG_ “Ατ˜ίΗM±:3œ©ύΨG}ωΛ_rΧzΓ+£,ZΰΚΔYΧφ#Mqmν€ΑεcfόBJP§<λjτ“†[‡ΤŽαC™1 TSR'°›L[s‡2p<7Zw~ΰŸ9ό•―ό‹z4ϊαzuυ/₯f†4βΣΪ‘iϊα>Ω›P7 dΡ ΏΩ’HŒ@-²’UŒŽΫΉΡΪΙdnJ½•ΜϋbΦ~šΩB‰‚μEژœΊΨΝ\£ΟΝ·ήϋήwώψΗί\―¬όΔxiιaέ·1Xι8WΪK)πŒόθά[I!΅…·dvZk©(SΊJ§Β$ρMϊ_+mpΕkd εμπϊŽΗmBφΣωWQ\h»άΜž 8$&ΡE'ˆΤΌή #YZ»ίEeΣήRΉ|Ψs ·ω‹"¦ŽΈΎgοžΥ§<νIχύόλ~ΰžΓfv΅‘ Ψ±Ια~ͺψ·‚ήΖ€ΉκΰzP\QφχνyΑσyKΕ9ΉΦ‘7¦Ύ–Yh Τ¦–ηF bυK_ZΎγwχ₯ͺ_[―όY]Υζ;‹^¦Σh°η«L;‘»οζ”4wΙ%ίwρk_Ϋ?Ήυ3‚ϊ „™±ηQω]ΓΆ£?Ÿ%Sό>G2‹Ε―}ρΦwΏϋ?W++?2Z\ό‹ι+\ο/f#M– ƒ–MK“)Ϋ%ΧΝΕ,[΅y•ήp8ΧΫΎγΝ§~ έiϋε"s†šΛqˆPR»[%ΰδΧM½Ί:›Ο'¦υiM€Ή‘θf\9pŒHέ›‹U%Λχέχk·Όσ]‡ΟΧϋ–χΌχΦ―ύΪ―ύΚέχώh½΄τ³£Ε?%ΎΌ•ŸΑ†J“2zοΑχ\Ža­β6’±ΫHڟHϋœ \@‘.\η4Τ›Ύ€Ώ}ϋCΠλI8u9¨š ™§‰2¦…gŠ”Š~97{`«οΗρOϊ.VΥνIγ-–ξRn #sΕ½ Ω&ζ°ΓUDΤ7Ÿ°k0ΞΜBu$ΘΚΈσE’TΛΛ2^YQtώψQ 7ΩKb,ι$Α¨§­(ΧΔ“δ‘΄cτ{PΖχŸψϊwόβΉτ†ϋχkφΥJ'o’\Η¬‘r~I )%ΒM  Dd,¨ WοkΏ1SΞmΖ&eAωςsξuΤ.$π™F2­%6Π#Ÿ1pΥ²p3–6p­Θš»’ο7·Σ­¦ RΡvςekσm&:~ΏJ χ„Ηϋιώ{wΞΜP:™Cλ={ –ŽqΆφΡLXΞΨ`)Qνm››CΩΫΈPΎ›oΔΊ'ΦE6΄Ο\&8κ:χ²γΫη·oU«7T«+,uΥv•“σΧ…> ;kΒZ³'μ=νOιyKβN΄>gvοΎ~εž{Ί‘ϋ 0[‚‘­„΅Gο:ΘτΫΉΜΕL:vδΗΖ‹‹B­Κ―GΫ=Σό~Fπ ι°¨“Βxς^ ϊeoΠή©9άXάθx M':œ/RN%χ?>Ε―yν`υψ±ιδΪsSr>v΅n'HΕ4zΌ΄΄T_–ΑuΗοώφ=_ϋυ_χ·ΌγΖw~λ7sKΗΪ"νΆhwbzšgj^Fې!§)ΙXDΉκ…λΒuI;ŸρτK{Ϋ·ΏΈœ™δE,ςό`I Σ(ΘcZ€Ζ²B°©έΛBΠλν’ZΞΔ}aUέUΧU1v(<3zΆ₯+n 0YvAΦ-0A¬™3¦Tλs—Wλσ7Πkl€cΗ”6B(.α=ȏŸZ{L[B‘w·όέn΄`¨©²¦”£ωp\Y—ί΅όε/γžη?œ©ζV.8“”lμBNv-zS5ίhΚ0ςΙ0l΅‘jΗpά!R½bΠ{ΜιΟ~hVJάFF³Ο†’!nΠ Yͺz“HΧOniM^mU„Kω/Ϋ!žυ1έϊΖΏΝQ–EX.ˆPύΨGηδϋο›λυσ£ŠΘχ6»1› Π€C‘vu’'?ΖRφ Έz£@&¦Dω Ÿ)S2h£n`?ž[`Α^‹»χCχJUp=ύW¨Ό~λƒΦt†2%3cpqJσ­7ͺWWl(†ΐ­3jΖΧlμ`ιQΦ-ΌšωTΆΜΔί>cΧ·ήϋ^Šˆάω{ώϋ₯Ϋn{ΣhiιΣ"5­ζ "yΚσOqΘ… Δͺr`’8 sϋΪ§Co4ˆ€]™KŒBk‚ŽΉΪ“ λ­ύ“[9½ΩαlQ―ΘŸfδέΖχ)g ™Π΄Ω·>Xΰ$`»ΎΠθ葏”ƒήW/”Λ›^α™e…“8ͺΰΟΪ€­„©η_Δ΄ƒ\@‘.\η,„απΊώάΆ+Πλy&Φ„ή­Ζ„6ΰΥ*&USΑ—:ΙB|ΊPHΡλΝ½ήŽ3fͺϊ^ŽšŠV>Ί#ž¦@h-~αέU$΄Ω=(j…Šξ–DJΫΨΘŒ)ΠΧƒ`v· !e ν6ιRWη ­σG‹ζmZQd#lu¬Ά£Φ΄i Η΅ŒŽίρ£ϋΨ/‰ˆάϋ§zΞQ¬k©Ηγ*m…€[@³•ΦŸΗ& ­ύ΄qοbΞ'1ΛθΑpCόƒ69FQEΏΠΣ°Υ·‡-EΡFx¦ ΔΆ]„]΄ˆ3’t<ΩΎp-"υHΦI)5&{cΪ€.Μ7κό¬νΚY‹˜{όcύΨ‹_p|8±~§½›‚Fς^Ίe'·gΕ―C"ω°CDj^΄q€Ž-2Na!.ΐt#†mξΠ•4k6άdτϋːΏύΎχσβnΐΏϋ»χΆνψAVΥ— λβφ_ΧњΘθ@7‘Θ%ζO‰ωσšΘ7«HQ’ZνΪH‘ΈΉaxL±άAΰ~L«…/0ίηζuΟG?ϊ­Ρργ?U­ήŠMmμHcU"J`;3›λ)ζΩ]`>Χ€„u}j Ygθ›Φ@ΰ>άθ°“Ϊ¬nκ©Ω2Υ7?Ω‰‰žά**Κ^Ώθυ4aΦόSŸέ"JHO5ΣΆ–‹'χκμ8;ˆl\ΉοΘολ·ήμBΉΌΩ…/¦Δα ΦiαtDnHŠHόNΜDΊϊΜ ΧY$Ν=ακΉώμάχ–³³Ϋ΅C•ίT~ο­u“`«αLBΐμE–‡δ›FΤE!EYξ@Y^u† ΪÏ—ς1KB3Γ΅$H‰^#ΘΖ„ϊL?φh³V$ΈΓE­5 ›1σ{―‹Q&pjΪ!~ήηXs7[=v|’DΕ2P²ΌfΔMLr ¦&‹ ΈŒτ ZWŒ{^pU'ΰYWkαdΌ΄x|uqι]"²z= V•p<kρ)₯ZΤάσ‡rn “η‹Iq§aaϋ’z ˜’Έ:=!Πλο?ύ7H:ŠQͺqXΙΨ-Θ-Ή ΏaEL)ό5  Ϊiι$B±φ «˜ŒΆ‘΅υο ]~¨ψ«Z J9y”QωΆκb–Ν…ΖYη9Ο{Φαο~δΓG†δΧ8QMUΞ²έc4†έ88m20yK]Uλ‹)#}~L„žΉΡEβ…0tϋ–‘Σ~_dγZι9ΫYρνο{EDΎωŽίΈ“Χ7.uϊάd8£ν=2y9°ΘŽ< Α:’ί@E=ZέPΣ XΟK<λͺΣψ\KCί\o―ˆΛ—’ ώ\κ8τƒ?ˆ;?ψΑ›ΈΊϊŸX—αζΦ1qΔ‹†@ ]ψuΜ~Z^#Κxϋ·ΎŠbvί‹_|ρiJ˜¬ˆΞ]•˜(1½ §ϋn:F"ˏ6x χΙΛ²”²·Λ₯ΎνIΡfpν4FdίνI‘Fχ2„°•£ˆφ0…"E±x‘T>Γ倬οΠΗ@Ξ—f°‹† %rA&ιΒuIƒωOνΝΝ=₯œ™)½`);;sΰ7Ϊ­ν\,ŠΎΌV΄A€»kC§Ε€’ΫΟΔ½γxt{5am]$θBšŽ+5s©Ph»ΒΨ˜Θ Lΐ”€M@"O–1Ε1Š ωVŸ= ”ΐjΎ;°wΑ₯^Y‘ΡΚr[„₯)%N ˜$?/·tkΣ₯c sθbLΉ8΅ ‡/Φ;Rξfٍ+ŸX|Ούυ‰χŸ‹’ΙΊ^i»VΤνν₯ 1¦X’ϋ?±΅uM ^CG’³­τωyWšaέԊ~ώ΄£H:ΆθbŒΚ‰1Ϊ΄&p›‚Eδ6Γ¨ΊΥ‰n«/ΣA©iš¬ŠΘΚδΓ*?ˆ†α]dΜ²oδ^W΄₯ω¦D²ο}ΑσŽΪΎΝ μz€aΊζΊΆc·ϋ“V('„b—x•„`ΚbϋkŸ³}Γλ„όρ §@}yyρΡzΖe©£€ƒΧΥ9G ΐqυž5’ZΦq‰a-F"ύ•ξs”ΉY‰juV6“ύάί±γQΎοϊb/„(`ςs¬ΡAΟ­Wpυ†YΪ²=Ljι\Μ*nϋ/…""·Ύχ7ίRΖ_ςμuΦߘ1x¨kK΄ Rœh-εCHΡγγ’ΩfM„)ƒAǎ?m•vd8βς”€κfβK[Oΐκω1‹…Ÿέh‚Q (‹Y-˜+ϊσz8³ΥŒg1Bφp:™ΉΊΐyεΤvφ^λπް~¬F°»j³vKS72’‰\˜n»pƒ@R9<₯œέΉζœ3ΕΑiΑœ²·0uœ£€ŽBΕϊ44ά”εLٟyΜό“Ÿ8·εΨΒxτ-ŽΗw‘΅‘ ±GΫΒlVZ±QB64Σ—T9ݍDώB¬Γδή…I―>Υ›μuβζ…œ€¦KoγόΈ©G#Y=vLŠ’°·$ͺ°³Ω δ˜fP9LΙ―τb§΄QzWΘ ΩitξΥ£GώqυΘ}ο9gχΊ–ΊͺNXMN΄Ω4Iͺ‹h=­Ωq(gΰDkΫj;μΖΡE^`Ύ.”&†uϋ*€θυNoά ;Π ωH«“Έ eEBξ‹UzmΡαθL8i!rG­Ά‘aΞ¨]GΕΖ`ή…dÍbl¬ EsȚpz²…h•³+―ΌbρQ»lD ?ΖΤ·‰["Ρ!œμ˜[œœ‹™4’jsςΚ™αLΉmxΓI ‘.ΫζG†ŽŠ8Ζ+­'Ή4ΟΛ€&DœlƒηMV|λ{ί}ƒήΟ₯‰°IQί)„ ›XZi€6–ΣςΌ,[:ύ­Vθλ·t†»χ<2>°n Ρ(ΌfΒ΄) Ι"QjLVΔΖ:―-ΰ)ζm;©Αθs|­T£•ί«Η£₯HOλΆy.‚4 D;κΥ0MI-<$e x“n"E€μυŠ’Χ{Βι9‡7:gƒθ>*TCց_jΡ΅Η±w$œžΐI€{ EΡKΗϋ䴘œyŒ>Sξ„œλΖκ)rJ†­N‡I αMΐ‘:Έ³Φ₯ΐMΝΖLS˜]η’ιΥ*…λœ’ϊύA9;{]onv(E‘ηΊ f>Ϋ)ΩPΊ-“Ά§φuΫΏ$aEE±£˜nυν_ϊv=ίΞΊ–Hr6”ΡιQ'wYyζ/h‚”HPΜω™ΝΜA@σ)υ,w―:}v>xu-ΥʊŒ–WT*?±(6"6θ£₯Η¨<Σ§3n’ζμΧΐ>“Vz}z8ύΞ³P%$ΥhΔzuυZόμg?{.IοΥzEI:Aέ?u·έΎl|"bE%³Θκh~πͺΌc·2Ž΄˜²‹-ΛΩΆ9¨³π6’΄LH‚­‡ΩΠλ“νw t2Ϋψp{ΐτˆy’\Š1EVΘ:ƒ^Š„Ί Ρš€Τ pςj.…€a=Ÿω΅ΕΙΔΩ\Σφ3ήHrΝSŸ|ββΉYj<=‹•Œ±΅ΪΊaiYNM »ŽΞ• nΫQ(τz%Κς‰¬σ&cg †n, F‡gZVK66ΕTή€0q~$Ι5W8a\½ϋ„F©ΩLβ~λ°‰Θω ΐ‘€£Α6πu12쟊αΜΓλρxίτΕαΞω 0Τθa’Y―MΠ°d§΅ΟF†΄ήLŒΝετΟqšάώ›Ώυ©Ζ_ρχ’K„WΙk›CŽ ΤΪΨΑή8―‘nhΉE!(ŠΣ6j ΝFση€ΧLκxΈ©ι™X@ld±ΰΑ·–ϋ€ZšΊΪ‡9+[`AιΎz·Xͺζ*ΊVm…›Ÿ†ξΌ“7ν](•·ΰRΉx`¬Μ”˜9ϊjvΌ@Τ>μhŒΉΝΦ‚γφδaΡ Χ ι _σOz%½ΉΉ+Εh#a FVW ₯ΥΟw3ΦΫΡ¦ Z(ƒKεΑ­Ύ?χφ―σgο]£,Λͺ2Ρων}Ξ‰GΎ³ͺ²ͺ¨wQΌDTDί­b_„ *%O Šg‹ήξΎ=ϊ:Ί½zνnu ΧΫW<,ZJP*Άˆ/@„². o ¨‚’¨WVUΎ""#✽χwœ³Χšs΅Ο‰ΜŒ¬ΜŒŠ=TfFΔ‰sφ^k9ΏωΝο«λ;₯iDΡ%ψNQ;ζΖ\˜I¦™%tggJbχΊ˜rJ9ŠDa²ASΗδ!-O£±οΙ?—“f4’Ρ’e‘>>]ώ%Ρ&|TRδ" i–ΖKH4Oίƒ-ΰ3›:τž₯ΊωMης#©ŽgSU_3u>t1C₯75lΓd‚ƒbΧ>°­‹Έ@Ε$ρΛ΅§,ϋp+σίρψuZ7mŽ© xέXWκ–RΝλ[_€θvŽ” ά9A ©Wa<ή†±ψΆ)± βγ€nhίm1)`B»Βεγ­/{‘EΉZ­3+]<ώο·Η·-=κ’‹ͺCΫqAΧ‘žΓ€v¨…iΏ4Ε>ν#Κ\ΊαYςΟ19šU9@ό6Lƞ ŠήΙ™ΝM3V:+―;ήόζƒθυ^eΞοŽΩ ά&:,ž™Ί|΅ϊ:Π’φ9/„±1τϋ»šΊή=sϋκ,/ε욀ΏΞ΄έ‘`mίι'#+φ‡$υ»:‡±ΖΊΉg,ω†ΐΰσΟ–™’UBΕ²4θ©€Ψf–D&»7υ Zr ,G’&š„‘° Z-!LΗaι#•πΚύIλτ€ ϋ%4η›Ι§˜θiι¨9Fk΄’νΤHš *πJšfa»T~0.mRbm Ηyβw: ‰λ¦9 1ΥiΨΗσmRΪφuNI½;¦·0Ώ·(Κd [η»™`’Fu΄ύŠ^ΒFUDH¦ρoΆςδΟcΑힰ9#t¬λ;›ΊnZ*»–JǍ$I,γπ„·œVz1ElΪ„]ΤβΘ@ ωΡΘ’±ΦΕ]ζς™!³σν-δαΦ4R――I½Ύnέ«εζe3!R'CΆφ3mC¦ΰ,‘œΦ`JŽ―‰ά) ©Ž―ΧΓΡΥ«kKης#™;o?YW_―ρ L€4Ώΰ45 Έδ­φ†…V€œ§,§ΒH³G.‰„{ξπ’bΤΤ‘²θχζO< ‘)28­6uϋš @―ρ*΁jX)¦ΫΘηί}%"#“OOžNC+Δ‚‰ƒ'[ΟD6ξσ4“ˆTŒ7 ΤήαΙ΄^ŠΘοΩ΅«±£;Σ*νb γ• ’~RΛνΉ©Ζ’iOX2Mr5Ϊ7Y±ϋ7χ b‘b ΄ΊZz€$€“™ΡιΐŸΤŽRκLχNδ4…ΪIΉ( ‚N©4Wβδ #HΓT°7’VΓ`ΏΥφ‚ΣΗ€YUE_šfΊŽ"0oL²+οΰsϊωF/D›;ͺaΟTmVύik¬jmψ¬F«ΠL”LΞ—«±Ce¬eσc (ςfΊ†‡‘oo­6€° ’€vβ„ ^“£NK*:Aη"°»‡­έ0f°―TΓήm±γό΅_σF9q£Γη„HχΦψ‚Αΰ[ηΎνqεvΉ|Ί‹;'ς$M}υ:vΨ ₯tυΎ¦O1n3’Ά―s HZXψ_Šώ\ίτδ΅$BGoc:κj`fΫa€Σ0Α4—eNk―w œ[ψή3kšf…Υ¨"›Όύi2FΪθτ;=h;Α-¨D ZNtŒ]"ΫΔΡ‘ӊ$Χ‰ΔzΚ£:G‹©ΧΧexμ˜Vž>ρs̰ψv¨ΘΩ3₯ΆaΖz{ZžΦRgυθ‰y.Ώ―k©–ŽύΖ±~δΟωσ½n„uσΕΐ@ŸˆΫδ‹a,‚‘Η‘IβΤΣΚικψ0$ΆŠΎC?ͺi:πzŠ@Š’ίδfΒG”Τ%0θΆ¨.g«±5[Ϊ17ɊκKpŠc—ζΚJΥJ8Α PKYΙ„‘„ΐ*|XΒ,5ΆŒσ$vΕΎ°GDb<ΘΫ^sυUΓ½ύΎͺυέΞ 7‹ωΎ0ή­JsNΠ¬ΕΐΏε,/†ΆΘ*gΦx🠱Aήε’ MΚ£ί IDATφ Άβλ‚F6T“Ί j-°·άUΧk¬ͺΟ΅Ÿ±ϋx!bg‹—%Έ9KrŠeΊ4Εl„M3]€…ϋ šyΙ§5„†Ε”36€Ο„‘L³ΆΒuθcτΊfTέ¦ϊt P$ΰƒVsRm2Ίι2»y‘RύzΘ±*ϊ§"±‡g Α‚Yɐ&¨ šΙί™‡-Ϋ6²άN˜‘Δq‡*Δ98io€λŸ2)~œΰξ ‹dΨILRΉύϋŸΏϋΚ«³].Ÿξό?WoRύOR+±υ1LNhsS›ε‡ιŒ§νkϋ:W€€½ίύ/ϋύ‹Q΄–QiqJΫ7°K>ι6L„ eoέΞlΧEL!¨»bI/¬(½^Γ¦9tFŠΪͺΎ·Ž–ΩX1[f<% ν?Γ'Ϊtm^Š9@I:Β’;rΝlŽˆw…₯;σƒΈf}9²AΟƒH(™d ͺsτji†C©G#'<*I‘οKμ‡R Ίƒ_χ P·i”]‹ΐ.uSpφ±ψ>όjεψίn… yτC"λϊ‹> iy/ΠRΨέH7KΛ€΅b¬ξςpAd θ₯ηTځ―,нή₯›yβr`tu ΥC\“mL†&ž™ΞUμ ΞαTι΄ιYΩτλBΉ«‘ΐHͺ'ο’Ώs€c‘’Eσ3°=bx  M3yPΠς_Ηͺ¦MύΜ―o}ά·Ώlοξ&ΐOΪά€y g$η ΤAa³n_«ΉΓf°QΘ‘‚l#ΐ°rJ³ΘΤ>!,NNη˜ΙΈ9EεάΉλ£$ί”ŽΊϊόHρ·I9Rh&.-Ε>›°+1R–Tdyό³αh΅Nˆ8ξIΙ€Ÿρ/ΤΰΣ!;A·vnΦJΨo0iύΦΫ–€βpȝαtQ2QΛPƒcπEPΒNV«ΖoτΛ£‹ν"γ6Η4:4 ιZ’Θ©zό‘6Mγh·hΛ`ΝFP"MΫ:4 8-gVΙ5…2Ψ³η2ˆlI§?š»‘Kš©~'HΉ66 ΥψοšoŒvΥZΙ(mnΆm_琄’Έ€\XΈ½ζφΛΪΧΕW UΨP -Rј” mƒδ6E±»·Έπ )δA‘kκκVΥQaΟΐυaC•LB˜‚Xll­q3φΊ™DΠGošΠεˆDaLGu½7’$5&(DnUœΓ—b#‘(†QβΗb2ηu‘ΖΆ“ζΥ0AFŸuƒ…*έΎŒ Έ7XΥRGΏΌτ±ώη­(›ΊΊ•iϊjœ"αnΠόZ²YG–0¦¬†;k3ΙhΰλόBsO&ξL-“η]φϋηofξγGGΞι"ˆ‚σ\ˆ‰" fΦyΗο?ΩXpTφK $‰υ&$„λ85€Ζ ܐ”ΤSRŠͺr螻Qγfρ κ[rΰΐα}{šv†Ήub zΤ™“ŠΤΒόωbΡL6‡ψΒ©E$U+"Ή1 Ιy h½ Ρ2KUγ pΨ*p#Αa˜ω guF¬ΆfΤx)ύόqΗK;@ιQ΅q-›°·9‡βS&Ϊ^i6LΙ 3jφT ξγ iΗ‚xΚπ‘G’8JςjQβꬑ«R›Ÿ…³”’b’(Ήg›ΰ ±F`RτϋRτΚ§ο|β“²}Ξ" 4ΛƒIϋ_Γ¨£ξΔ‰f+6'|sˆκλ ΛSNΥςΆΕΆ·―s HB1˜{²”e,Jšμqε 36ΑϊGh:ΰaŸj‘ji­QWJυ’­zΕyώͺ«αέΝht'λZ€°B½Z_ΠT6¦weν.’χe>YPβp ŠLιdΰCΰ0ύ΅@GΝ|@r˜ωΩ«OœΪκͺ’Dγ&Ύ Ξ Ρ5$Ž=™΄I±\Rκ¬ήCމDš·^#:9L4ΜΖZ»ΎΏ\{ΰχn₯@Ήϊ©y@‹»Ϊ©6=Š›κy˜τ0γ’ΐE4ΕwξpŽ\ιρ ΄#d½ήΞMΔ?Υ{AŠ 0v΅υJ£2“ δHΒEzΊύŽͺ‘ŽrφΔ²ŸF(₯ˆH-f΄-χq‘FU²­₯Έd¬ξΒx%Ή]‰άπ\ΩλOΐGͺiορΨ 9τ™ζžJ2’aG …<wŽv­1„ΩŒ σ’ˆΤ“­U΄šκΊ/hαUFή«Ž’]p7ΤB* Pkή™ΡΈΐ­Ϋ5Șθk΄#–°ZζY½:…>lΈοΕ¬κyX&Ÿ€”J*]-δ5=½’Ψ—Ε¬Έ0˜ΊJm‰ i/Žq¦…\ΒΞΜ€Œ°€]`i ΰυτ4 ’˜™†j±μvυFΞ€>tΐU2ξšΝ,θԊr›8½υJ(ΘdV7s€˜‰Q₯ίGΝΤΉzΖ‰VΉΡηΣfζp 9ΞζΤ πμkΨΈ₯u‚:\ ‘^# ,(χ3mFh‰΅Α˜'>:ΛΊiΨ4Λ¦±$‘‘—ΣLΤ¨Œ•d@Ι*Ÿe¨d0  ¬b0ώž=Ώ|Α3žρ”ν²ωtΦ*4Ι“c@x^]2Aΰύ¬γΟL œηφ@ΫφuŽIΥύχWeξͺ’,Αάτ2μ˜œJύ4©Β&h6  ί#^[ZΕ―(€²7:rψΑΎW£Ϋn9ΞͺΎu£ΚyVρΦΣγ!DE‰*±ΐ„αΤ)BΙDπ’.›₯£MjW7 c H|¦6޳Z³θώή³τbΣΧΧ…Uες 琗Ό+PHwCΉ™-#t“-“wΒφ™α°Β\φ )k‡ύΚΡψπϋ·b°lͺͺ‰›¬Ϋγ"Ϊ­ΊV¬/£©ΒΔ<$μ9§ “.dπ+(EͺΧPŠAqώlr±"A@_Ίšρ₯%A+‰ξύ+V’OȝZͺ Η@ι?‘j°±U―Zβ…IμǏ¬™ΔΚry4j'κ7ί€ˆ Υω„"lHΘΨ›Ϋ)6ζΈ)-[]uSβ&ΥΉ Ωˆι λPΌœW&HΑ!³}>SsΫΠNN~5Sٽۊ7±θVvάTΦΦ"aRρ1Ω[ζ L-vΟ"¨BΓΆ\˜4Ο@–Τk²½s’>·c ΰx‘OΟ3јœzν0ƒƒωωΦ ΟΞqΰ$CLΜS1€-3ΪFΦ9bhfP, Mƒ΅-γΪb‡½}2ΉkέρΨα9Ώ9Έ]bɜš3Έ΅ž•‚œ€FP6΄·qHΣLYΑf;²†RUŸΣο9@“Xv·6Θ1ΤΫζ{§Γy!7vF) .Έ€ΏsΧoνϋ‘Ύr»t>Φ eλΨ:a"+b…8Π=K±T³π^* Ύh―ŠmRφunI{žϊΤΛ‹Ήώω‚"ꌘxΖΡ)XΚuB•ΟnΤ`T°›@θϊ‡T( Θ[ہα˜5ο_t˜ 6Ά`: ψ(†}˜,€ΜHgΙ!M‘a`fmΏΕ¬Aφ9Ώ@ N&]8h7ƒa€‘M€eσηπΨΜΣΆHNύV'V§+cZͺΎΗ›θ IZα~ψZΐ/AΟ9 FπΨ‘ _ξΠdμΟθXuhm(b]φkqO Α'Ώ8$ρχΆkcΗe—MMCCs:$“`&Ί¬U©iΫΡΔ«ηŠ:LŠE`m_ΫΧΩ$•‹;ž] ϊ;‹C½–ιxFΪχ ξ†ΆAΥUaΊ=C ؚ}HQΝhyεΆ3qΏšΊ>\FλΪ%-g†γ‰Ii)»ˆ…/ΜCΞ¦§ tJ΄όΘΘ|i_‡ι8Z:€¦όω fΞ>ΖU¦FΨ‘šlD4ό$6OqκΫ‡M#…ΥH€7χluψ­e˜ZΝ‰ΦxψΞ–ΘVkΔv֌s‘‘«£[OήWέH³ΊϊŠ₯›oώΤV –ΝϊϊR’¬Jͺλ" Pp,€‹θTτ‘¬πUάS„‰]]ND9έΜ fήjQ–ύώ6§HQ‚αα}"M²»gmύRό7Τt‰ΦΡ6 ΒXη ŒΆ=λ’Η ‰l•€šΙ6 =κ‰&vZPH γ—ky8Ίο=ζZiΤΏbόw  ΫνZJ³…"Λ05A EΕ}3ZΔ\­1MMwθ=•Θ|Β4“$±mΏ?;Ξi°$Ϊ8ζΚQ¬Jδ~ F_ˆύ΄l|ƒ‰nΊLŸΓQί-μkƒψΉUBΣΌCNAdB―Oϊ8ƒ” %£oΈΈb+AI”^ΫT§tP~9©u₯†DΖqF‡ύ€–*νύW^€Τ@Ž„f°CkNuι4UK+ ƒψψގŠΉφγ3ΗCšGCυΆzbkηž?yWΥί½ηf½‘-52˜“Ή΄ΑŽvΔ …Y“N±V1ΟcJU›μΜ—^ϊ΄=W_σασžώτ§m—Π›ŽϊΊ)³Ξ‘rŽ·yΪœλ@όwd„Μ(<)†έφ΅ $™­S`€²uK°‰κ$*=—ΜL“K’άοВÌ£z8Βτg…Υ$±€HΩΫ7ήώηž‘Ό ͺqT­š ¨­Jލ18“C4ΠΩ}»6€ TBάΜη«Ύΰ0­>Ψb²#94DθΖξ22ˆ‘ˆθƒ°Žœ¦’ώΰ―ϋ–΄²"R^‹ΜF(ι*gά72Š€Q0ΣI#% ‘r5Ρ˜ΦR’XρΙ²vπΰGG+Η?Ί•ƒeSWw'σž^"dfOΜΣ4KG@!ύ)]h9 ; Γ•Œ“΄‘ΐ ¨Ν*b‘φxΜy; ΰ!d3Μg“#vά@ ΩEφDp`’‹χνιψJΩ*j }”ςS<‘ GαŠV7iς©Ζ`ΨH ,y2wW‹Iκߐ4(i+[Π;¨}ƒΉ‘6ΓRΚΜ¦θpϊ{Ζ†|›ž+ΠΤ/‡dnΙ… f2ŽaNίCƒ˜β˜(νΨ:8m‹ž›8Œ›$.}}Ι^ƒΘ4L_anq­<Ρ•if.λ”™d΅C—Θΐ%mΆsΆιƒ‰θ|΄yίB cQτΊόω’μ=‡Htξf’ ™λ Jύ’‘Qβ'䔀$«GIΨqΜ#!;P ΰ—οΡψ•JT”’qΒέπηhΖSšΛgŠC%Άi ΡJwf‚„άέhχζ “kα‚ ά}υΥ<πμg»ν2z³»NY0ΩόQSΣ7ΦαΨΗ`Β*υέG[Μu`ϋΪ²@Ξο|Β@ ‹(JvP€.„Υ…AεΘΰς%d εF‘§}œ&™₯T·τ^Β¦9#ΩλκήfΈ~λZ±«D1Ά<<@γ@ι87“F*+IMΣB0Λ‚;DdΛͺq## liκ+ʝŠ^4&WΐΒ:Ίœρ#cΒF’jρQτΒ;*qTδάS*?Q?CŠ<Ξ«„Z'F6O«WΧ€Fqεγ»g+Λf8ϊzΉaלΟΖ»‘R•ŽCn“„›Ξ„ΡΎΥΙ_ΩΧF`Α0Χ―€²Ι¦€‘>΅CQf`}Ψ!dλM#$‹σ’J Vα…”ΊΒτλΊόάg(&#iΚ!€h#{Ϊ€΄ ¦xη'oX²‰ž‰=@*!Ψ Ϋ)΄ιB½>x6{Ž70q˜Ž΄E082A˜kY›ΕΕdΗα§ ‚„tOV(}5FZΉŠLMζ€oΕ,ΝΤL}{/υΑ,Ψ~2 ‡M£„½-Πι›·IΊO¨“ξθ„q–…ΘΤεΖl λϋ,‘ήŽύ=W]ύΫ—ΎθE΄σΙO~ψv9½Ή»Ψ—S΄*bΛ<:ŒaΊάΙΔ_D]UlΆm_g9TΜ E―wϊύROAι°$ΩP&Πkqmf-“θS90΄ϊ ˆ’‚ήššŠφά†Σ’Χ›+柸ϋ~`ƒ}Οκ΅΅o°ͺξž tΙS™±Rζ\ιΘ8ΪsPGˆΗσνͺΩ¨ŽΰIΆOŒώζ—(I“§A–²ΣkΖ~(‰•Žι&g4UDQΘθψq¬Q_ύ€Όͺ@ξAιq–T—ΐ~½Nc‘―ΏІŽ€’ͺS`_Y»ώί¬–W>ΉΥƒ%λϊ’ζ5Ρ 8'UoΒ!AgΦ?‡s: Š_HΈBΠ -™gN·έPRτzϋ6εžψ$Δ¨$₯VΆΦ‘ˆCζ“δ)HL0%Ν¨ΐυ μν}ήOΑ#!ki§ά$ލ!šρ˜GŠΣŒ'‘…EMΚΡ*aEW ΌΩ ›<(ΠŒ :¬§ ΡΚΕΜΫ{»ΞΤιkz!ΨsP±2ž0p ‚φ½Ήσ ‰νΙȞ8ζ“GY7Οtό¬‰K­γfv J#c²zΓθZ.1θN0#¨d5„˜©j¨jyΤ³Ž.V¬SΡ BΒ T―ad6FŠb`©©/Ω΅%Φ‡pNDΉp₯€ΏlξM#t{" eršΎδΠ8%=ΕKh»’Ž•›ahσJΟ(fH”˜­‘5θfšΠ읍\ΝpΨ4kk‡ΡžσZΡ₯ΆΕ,ŸμϊvdQίw$1Χ…eͺo…Œ  α†­ήΎZYΘό…>ιΐw}Χ—φΌηύώvI}Κ ―tφ˜’=A€Y―ΎάΈ§ԏB\Pι­•#ΩΎΆ―³H:vσΝΛeπˆ’,ϊΩu°Υ–’tγMv,Miο¨ίGε¨Ρέ Κφ6tb‹’(ΚrΡ/|εΑΎgλKK‡šΊΎ£™0’Ζ4bΊ²2&ν ώz Π…‚dXΑ֍ƒνθ y*`1aE­ͺˆωδ.M½€VHφ g°€©’ͺΜωg”f8vj“TΒO'’'mgN·LN₯χŽbΊ΄Edp`26¦•ΤSgœVYkFλΏwό“ŸXίκΑ²©ͺΟ7ζ!xΕ8Ql:JΎ…faΧ΅f'azΨ¦|ψw&{΄. Ε ΏssSZ6φDπ6J<0ΰ ‘ U₯ˁL"“Pl‘‚|Θ™fΏΠyNΐŸbrLʁFGαα@ˆdΰΘq [—ΆΔŠΧSi‚†Ιš άZYFφSvJ&sΓήυL"Ν8¦N†ΛψOq±ι… 2†vΣψT°k_c!P ™’Σ[²q°S8qΛ5X1κ?lSI”^—ζ R·,`L¨ρfbκ-DrNpφ …r”ϊΌ™ `RΕ¨4C³€θφ€3•lσ­3ΡqαsžσC.Ο &ζΰv{Yχy7σ\4> μ^Σ¬¦₯dΈ)'P2Ύ9K “Φ‘.Θ9θs3ǞνͺΰyJ ¦Z]]©G£_ Ξͺ @½ξ“ΚΧΣYm.ΙG¦}fL χ―€” σ½Ε‹.zΙU―yΝΚeΧ]χΪνϊ$2¦SΘΫ;x‘έ΅X¨ZΆR+­±ΝHΪΎΞv IDeΉΛx”_ƒR«ΞHͺΩCι’)\Κ`ΕΡ.Ψ.ΞdƒƒΉ χ<ζ»ρ IŸϋάq6ΝΧ’Sλ Ǟ˜ ΡΡWu-ƒlΈqˆRp‚RL¦…\2ρš”&‘•Δa™ΌΏΒ`4YŒε•ύ ΠλΘ<僔Φ{QΘπψq_AZ%c("[#f ˆN^zήwδsΜT±ΪτΒΠ ΙA”“ eC;ϊ²fεΠm…`Ι¦ω’άΘ–ιtυ›.š³Bκέ2ΖTηνΊe"IΗΡ|?Μшƒύήά&έKutΕ…Ρq‚ΏQ.nf~ΦK¬)F‹Χ>QΩUD9ΉZPΖ#PL|&L#6ζ6Μ©OΫΪ}ΊBDЉ»­ΚβSœŒΒ‘hΈΓ‹--οZZ)β=Ȍ»Μފ=‡Υ΄ΰq¬β » IΙόy §ηΖmγ >γμ$ˆΑdι#Mς›‘α=-θ›e Ά”ζΓ₯ΟAΏ^]}:gV'΄Zˆd`k ΕδHhT]itΑ9€ŠA)Φgn=%=d:•Ÿ΄¬εD ΘR‘'„¦R$dWMwN^½Ε/.ϊύ ΄f]ͺ°™5Ζ½M‹Τgά0騃Φ_fŒΥ5 CήF9΅”3 g0g³RuŽ’Χ] lδ uΘ†ά‚7pέqγΓrnξΔajό4 GnG–'£ΨyΠ™DΫ|Zƒ†Tf>Œ V!θχ₯·°Έ8ΏΏΉϊη~nως—ΎτΫ%φ‰₯ VβΝ5`7:ομšAS·€cu&βw°šgΫΧφuΆI$+«9’oΫ’4y]2‘FNΠΡΉ…:x¨‹ρIG"Z›‹E4e§(€,ΛήΪ‘Cχ‰{ΦTΥΑf4jΖ#™ΊΞ=€?Ρ#γ*{HΉΧΥqδ‡Η‘(¦H29WΌhδ[2…€y’PŒ‹(δ93AΝW?κΏ§VXœŒsλZΈΎ.¬«Μό²(ΠΘv’M‘ZτT~*―(XΒ«Μ‘›.›ρ β­?pWκQύξ•Oίς8j8ZFάJ9ρQ?φΓΤ VH±‹4§*m‡—F=tήΫρά<hάήΖ‰fΡο=β‘Wl 0:-U ”x¨Ξo`ΫΔq)Π¨<„€• <5±ΛΆŠ3‡“Q8ˆ §•ƒh…ΙΨ2”ΖΊEFa&o° ΄ϊJMܞΖΑ™­vΜδ5γίΗΦ^;~ Οξ ‹w=Z]l·ιΕԐΤΒMΚf]9eε³[_šΩ‘υι!x"ͺh’Ϊ0&Z# ɞς¬Šeβ瀇x΄«ρo―²|Ž_τφ ,οbM@Ω’s22žsǘ{ΤS„™ŠγΤ―άEΉΎ e1œ΅χ Ρ­zE‰- δΎYΗΥPPΡ ϋFύo$i@+Ϋzz τ.eYž₯ψ¦žJ΅ ÜΦms²οiΞ-fς—h]YxΛ<\έΌλYΑΣ©eœ¬Χ(¦έΖ©vDΒDΰΪάO1ΪφΫΈέkνX&jΆ &σ IDATE5kΙM%(`}RΈ—s{vθfg@`dqR }8ά °‰؈°³ͺaP‘hα;*NΠΆ-^εψV΅―Τ΄›ΆMνfD1n p=(§5A!Χ7Δ16Υύ@Ρς§:'τ€BΎxΛ—ηξ][W³>2}ΖΒβ1ωoξpδaT§Χλ€: ǟ}mC+HzAΘ΅ΆΓΦ³κLμV[+QLPΛ aΞPdΛΜ·厒ΧrtUγœTƒ{έ;%Έj§=±z4ΙΟA•=lA(ΆgήΪˆ)Ξ™™rΏΟ2υς ½6‰€G5 Ά…VDΈΞϋ‘Ύ΄\>fEΒεΌφ.Cw0τύnΟΖ<9{― βG½IΈ‘¬c^yψ”Nf(ΧpμΝ”ΥBφmƒ#jΑιbΌK Ak²CΨ{£W΅ΊΪT««_|oˆ[’λ”«%fΰ{Ί©Ι°φύ™ 𐳠Ι:(W°–ΉV”₯τη»v?~αόσίΥ«_}ΟεΧ_ΏΝRšY$HZ9Mϋl{* Φ0G2%ΥZ¨ΫΧφuVIE―χƒ<Ϊ“Θ,ζ£ΉΦRBΆnGΓ0IHfŸ`j΅δΨKθxδΉ3KΒΡκ'P@²γENσΕΊ–zΈ.­γ^λΞ`λ: ぀`Ρ εxD5™Έάl$[†KΝίuζ]ΘΪύχιΝ7ίτ‹——ˆΡ€3>`œmEkΔA1uB*a”x j&Š™ϊνjDŠr0xΒ&αGf²Θ|βR˜hB3Κq-ЉΪ5νΗj‘OΆzK'Ί₯'€¨"2m ^yv#O! S­I”ΖΨ3™H…ŠΤγΡ7˜œ §‚άθHi¨±].*Κ^»[@I£ΐ•}caYW€)&QTŽc7,"GfεΔΘ‚9„ΚκΒ―}υ­©΅6€ž§™χέB‰1P 0<,€FΟ§Œ(ψΒΎžπZUœΡœ BΨ“ΡΉ¦i€,§λHΆŒΖUj@™€Δ‚φ¦ 1L ½ΖΝΆ¦]’lΉŽόό‹½ . ύZŽe—χc›‹„yΓΧ ’3NKaΰψ#UΕ¦ΏxΚ‡·;ώυζΕΜj±-k›TgUšYFοš―ŸJrtμΨšΧ‹yFΉP 3žΣ4Š„ν―ηXRΖΙ * kΆusв Θψ1ŽDŠˆ”rq‘μνάuα`Ϟη^υͺW­\υͺWέrΩuΧύ_Ϋ%Έ?Βa~š‘nΜLΉ›>G»Δ‘ΥIL’LtT`ΫΧφuΦI" lέ‘άάDg―m“98˜-ς&Ϋԝ'μmΖ%¨rθΌ (ζϊ ‹ί5χψομ?Ψ7Γκz4ΌGΰ‘š΄jˆ‚ΌL›΅“IŽ©ίΪ…M1)&-+"Τ™ΰΖαθLΜT#»‘ζ9ΘΙζ‚~ œŽƒ’(d΄Ό$υκΪΔ \Ί™VίΧιάXB12`Ζ 6Mψό k <Θο|΅²T­9ς Ε€ΙΡ¨ρΛ—β4” \9Fcn©1Mr§ηΠsS㴬a‹τz›£οΦj5X}vγψ¨όζR©(«zjφ€MrIMλ²c€wγ R%Uα$ΨηΘζN|2P`›1οΖa…Jβά.6ΩφΗ‘#Gϋχ>pΈΤΙ§Yͺ‰&Ζ΅GοΪ¦uLΪρh¦ς1ΓΉŸNŽΑ{6€1N~1– "hάέ¦ιώ@Ν%±rΟaΰ’›ͺέ’ΑηβuΙ ^8W―=‹ΉΟλbΌTB΄˜˜Ό4Ίƒ ½‰ΒΊ:^τΚ₯ΔΌ ¨X•βΜ8˜θmΨΦ|:&ΟT&zΟyiEΩUΞ ΎOΟήSΦ`†a‹ΐε‹SG32sˆιՌF›ζν§tτ„ηΞξά¦#'$hγ“ΊzOwQ³T½,#3$ΓΛ]οxχ<φ±Ÿ­ΧΦΦόΊ·g&Uγqrώ‹½η@F€9I”² B/Ϊή„GοΡ°Ϋf…goΑ`L΄{=τwο^μοΪυΘΑž=η•―xΕ}WΎς•Ώτ…/|ήC½·FΦYΉuΪ4ΰ’ΦΏkŸΤx±7Ηpy:·³νΰ°Χdϋ Χ5ώίm#kξκι70ZYωBQτ€(ΤNiΚθ6…<½Υ•ήϊί}#EŒ&€§‘š YφΠλ]³ώ©OŽμϋΦΤΥύ¬Fw²‡’Μκ6©2§=8#”₯cY§s0J{uΐN¦ŸŸ³6‘Uh(ωΎΤ c 53λ3Ί\f!'S‹ؐE&λFͺΥ5iκZPΦn™‘Ξ`ΗͺτS΄–ARάΕΟεkδ‘ϋκλ’ΆΚϊό—γŸόδέΕ€Ω¬­­ƒΑN‰,›Lžύ‘›ΕΡλέΗ9@rˆoψΪ?[Ϊΰ¦σΑ( ½ήΓ6 " Ϊμάφ°a™b Q‘‡Ξ›¦+ŽΔεΡO5ŸΔ»—žτz I«QΑφvΝ8ξDΕ’ ,l’Φ@ς3‚4žŸC 'OJ¬ψήΎμ½υΠ‘BxΠΗ”bN`{!J¬_9Π8αςη-γ9ΘͺjH~yΓi²πBrΆ*}8HΆYβξF?Ε1Ήu4’ΚωΑ…MUjζ†:§ŠΈΟΰφ‘’uΥARRΆ dΒf8Ί·({GfΰΠθŽcΑAc+Yδ°Άnn?§@Υœ}5•ζηb΅pιu/Α7o|3/yφ _Αΰ²6―?ŠQu³ΩU< ‘ΈŒžFψφΜ78EΈjͺjυΞ·ΎυοO½Ο έth„w€%–§‹@™3μa%;·τ#Ÿύ,DδηΛωωΘTXŠΫήP.θΛΤ œn’£]­?4SΖχ%Έ"2—ƒ¨Ÿ ˆVžA[ΞΟεόάωπ|τzpΕΛ_ώ›"rλheεΧξzΫΫήΠD’θ˜ŒΉœrrφ¬τ›°έμ!L*΅i t€κš?όΌβϊ—Οσ3=X™9p|™—K+°ί=θΜd“’ΣBARη΄±O7B8ÐυθoΎϊΫΟ‹·‘£³ HZΏχήCΈζŒ“Gͺ#’_‰5˜-Πr‰qFΏ'aλκ•&g•Ρ7)P ίΏhΟSŸΊχθM7y0οΫΡ›nΊgξ™?q‹4|z"…IgϊL;FΝqtrΕŒgwœN>)™{«‚!EŽ7βpαx¦S’ΡTΧοZ‰Ρ"ΓO˜=ψ–Όθ¦I( ="Νp((`ΖrLCή8€Δ@ Χj'T2œEσ:ŠtξnY?$dœς2sŽΓ£Gύ‡όΧ‡jΐ¬G£C}‘YΓ$\ZψUBK ΐZ@†3ΟΝXb!@ρ!ͺςΐ dϊ―θχχnڍA&^ˆul3†˜ƒ0½oZ @ΗkΪ–Fqθ1ƒw '"ƒ°£·$|ΕΣΊB\ίPμ»,„Δξ­R2ΌŠ(Vΐ§oωΚ`h· ƒ–‹Q+qΦζ-γΘ=Ν/ΟίY½˜β²^_‘Χ{ηFΧGώ‹γΌ­Γ—L{ΠΙ @³ύ2I#Μ{ΦΕβΉίTdU]„²wώΤγ΅e$“΍K  Ω•θξ3fœ₯“Ό hM5(£εεθοΪ=“΅&~‹Ψ±*ρΝ#J€Fδγ₯}HΖυ—9‡ΟE¨ρ’½ίΌρΝΌθ§―}qΉΈπœ’,‹lˆŒ\¨sΐkσC(ьη6·i™l΁Ûsφψ¦³ΛI»Šej0 YMš,ý0ΎYy*‘εozSsΰΗόΝ½ΕΕΚ^ͺ–Yβ6ΨM1ιτfΠ ,]­DΠΘ4E˜q1‚.ΌΡ­Υ–Δρ λοΨΡ—;/e]_Zτϋί}ΕΛ^~›ϊ“λGόoχΎλΟMK(°H₯λΫQ »ί“‰X§qέ¨†qδτΙ~–’*½;z½;φeW!ӚM΄M ˌ…ΣLέ§‡qšΤu£}ιΧ%­!G@Θό@Ju|ε»Άa£τ:γ£m£Ϋo_kκz€;€gΝx”t½ά…n΅€ε2|eΊΞŒbŒτ’s„€ˆ”…ύώώz΄>&jZVυ7šΊN>TΒΪi»“΅³οζ3κ(ΐi"œ©l€8 ΆΑTΝ' #3—lNaT‘κw₯£j³)λλ>‰€Ώ₯:Ύ6ΦFς ½ϊ3u•ΩQΧ ”~Ί™€Aςt0w;ωDS2Ι}3qύ^ύP˜υpψV-'uΒr± pš">Β$`ςΜΡ|Δ§ά•3·τtΐυ°Ζ‹§θχ7Aί­CW9‹e T'ώ™`–!Δ+œ]ΐσςdΪΒHΗ" «u2 L[uI4IG‡ΘKΑΪ»QH|έqψΐϋ?Έο«(γΖΜι »½¦F(xθyβL²‡ $l’τbXUΗώٟݲ‘άx|ξδ€/榱U}8MCΜ‰(Q]B2±VΞyBeΧΏτ@SΥΏ/œΆ$¬C™Έ1%nΨαΩΕ¦ζΉPŽέŽυΓ‡oΊλm΄6pR7iζθω·pi‚cdIpΒ” Ρ•Ϋϊy–^—ΎψΕΈσ­oε…?ω“Oθν\ό­²ί_°ΒΪ’›¦H6Vkp:Φ ,sΤDxbϊ]€Νs<φnΐš9‘τd²aη§ ’‡ΒΊΧ ˜Σν;ωλΰίόM]―­ύj2‚”‹«& vŒ%ΟZΩ:ψζΉ…^Ӕŝfζ˜EιCΤ΅(KιοΪ΅0·Γηφξϋιω½η}φςλ_φρKφgaτ| #Ij 9hDζDρ™Νz†6HRγΤ²Nς9υ›Ο§όcn?vœωΗC?Ρv5’Z… Γ9γΐ‰LΜBβ£οfΞΊ†ΓmΨθ,’Κ‹φ”4U+έ™ιε”―5€4m8Z »Α4"‰¦4'xΒAΏ·Ψ[άyή™ΈwMΣi&š-Σ2Aΰ s› ‚»ŠΛx6CHŠ&φ0σ™!‚Ϋ•Ρ*vC+ΚJ₯”•š9‹DP2\^–¦‰Ε„ρSRtŽm 2ΘYb§λ9oEΓN’~φ@ήΩƒ”υΓ‡?ΆτOτgε€Ιͺϊrc »Ό.W'•\Ψ0ͺr KΝ―λšg‡{ΖθςΰUAΏ?Ψ¬δΗώš.+$EΒnm₯?‘3%Γ  ΚΪ'QφDd.wφΠίi€RφζΫ™Ϋq&-bk…aE”£GŽφίυώνX+˜$½ΣξL?‹όI†Œ–LtΩΫ’€‹Ε†YΈ–kΥ‰XRςw7ƒ! =¬3€’Υ ;χ‡=ο…ύzmψ³Ε`ξ±.ʜχLφQ'xλκ:cp§ΑR="–ΤΟ7ΝΑ Dσpu~aΏ‚ΩΩΣΈ«›3Ζ0ex¬’Λ^ς|σ-oαΕΟ}ξΣz;vόqaαPΨ܍Ϊ`¦+ŸΣΐ[€A@ς¬΅l‘ΙάZLYΆ¬kJΣ|jsen(‘ύ;FvNδΑ槚·ήούj΅ΆΆΒY›&§ΉΘι RΌeΆCΖ€YΓHŽ<†q6v—ξ]‘*Y§ ϊ=™;yσηνΉ½{ι²λ―ςε/yΙ{.}ώ ~DΘΥ―ωω-₯YCϊ3?Šΐ›`‹ΫX?ξI>δΜPξ‚ήοˆ™|>·ΘΘΘΖi Ϋ0Σlό¬¨ΏMγ1§¬atΘν:‰Η¦ξl@Ίρiζ›ΫΧ”δωŒ^;ώ­³iΦ"νxΪL£’XŠžΟf4sΘ‘²šΎ†ό ΰ•"τ’65 )ΚrN(œ‘€SΧ‡šͺZrΑκC΅ΙA€ΰcΒ) 0e*-οaΧIηοw:JΧU%B'°~l― -OJΝ“ '0β6mΌmΜFZ‘¦d¬g•WB„— §ݐMΌ½Er4[]1Œ³ΘφDμΞΉ’LΖ:υψb½Ά6ZΉσΞη?ΤfSUŸ›T'ΨXqΤ•TΩ΅ qΕ|2_Ί…θt,R½&eεΧΎͺΦΦVEπίO}3tΡ{7r xs“τ 3΄v oˆ?λαπ/ηηߐ+{§š b†ΦgΜMεδf= •|N3τK»Ez$βώξΝΟχΚωω+Ω4WŽ––žvΩuΧ}‰MσΦςίo{νοήΏ5"; f&}2‚θ’.rJ™€E₯τΟΈ‡jeJ¦¬ϋ|ΐΙfͺΘΕωœs"υ½:‘fNw‚ώ­γbfqY…šτ…ru-©kM 0s+Lğ–λŒ3’P”`] 3PΣΦ­woCŠˆO«²ΨqHΘ¬©Σœ =°΅.Κ^Ώ\˜»ς I4ΓαΫΗ›ž*"5R»<ΓάsΊΐΠΌtFΧ˜gη@iΔz:τZΓδ“’ ΌΙ*aM£bN ξ³Ώ±«ΐ £ε%aUΫo?f`ρ#ΞS+[¨C]ι<"σ#υώ‚Γδυ©XNfΪ±idνώpύσŸΏν‘09ͺ>­»A]’…œ,A9ΉC^‘ρ)P’$.‹’7EoΗΞ§oVς“―iŽή–€ž”§ϋΩR˜3~=££N°ŸL€$±0z˜Π^r™Τ'·‘ν Btίφ¬BΘίώΥϋΞϋ›Ο|~V_«ω†y”NνΩΰΪ5‘h Ι³Ώ`cΖ8§Ÿ<Α†Θ—7=‘ˆ&»wE•ΩM+TœNaθ‚ηf¬LΝ4 ΞΑλ²½θ V£‹~gW€±uΆ ‡Ψ£ŸΩuC§$Ώ/MΓΧ±½(ŽFΛε`0{| Vͺ C.Λ€Θƒ,οMcHώ…ΟεΪ`ο«}ϋ™kοώŽ7΄ R6.Γ†%ΊΰFγΟ”΅3βΏΫΟ’j%‰ˆ°iξΊγΖΏpšκπ˜ec’]”—Ό9δ7nΈαΥργŸOV3"U΄μ_nt|~$q¨DŒΦˆώq18Ο:~³χΚΘgU( μέ³wαΒOž?οΌ_μΝυnΎμE/zλ%/xώŸϋΩ%&™Ηό,ƒSpDQl"­Ÿ[ζ o)Ÿ/ΈZ ΜΧσT£hτfV’:sm=sΪ¦ݝ1””gLiyΘiΨΦ[ς:γŒ$²YmͺκY‡·ƒŒŒ˜IVrSg‚²”Αž½ϋϋ»w?³^_1Dzi… }‘‡|Xq­&χΣσΛ»BΤΝ­'Fœ6ŠgΞΐ35 jβNzž™ΑSΥЊΐΞΚα‚g>swΡ–ΊΎ¦·ΈψΜήββ^”EZΟΡB5—iŸ&•ΏΩBQ*…~N_EΟ–n†Γ―}σ-oyΟ¦ίύΡ﨣ρΗ¬έ9\~o,."hΥ—mα+bΖ9qF`ΎώΖ7ώ‡«^ύκ§φŸLίk€’Wv-‹ι‰°₯ Πΐ©’9{RIώKd'2KμΦ‘RκŽS―ΗΆ&νUFλ=Y˜³±Ε}€ΣδR‡R'Ι0ޱΦμ;?‚a™h£λ*Ωn;§γώό©7Q χΠ›'©?˜NhN—%vdl‘UœΨζžάΝΎˆ,θΠ²‹ΤS.Ϊg;ξΉhβψ ρ9Όχΰό½ίΌkα+_ώꎷύγΗw²iβΎ©ƒ™½έƒΈΪ΅$ϊ§ς QΧΜ%:g}|υhΩλ½iΓ±’©TΦ*γ;œ™Μ¬k«mFQ˜iRœ&=“S½.xΦ³.`U}w3=IPΌpαΒ ―BQΆ’_ σ7>[¦£ΰνH΄Ί)v] )Œ΅Σ)Riυ-m¬X@yΈ΄τ'σ.ΌϋDOU_Lw>Œ›Ά£b7°#œδ¦cσάέ“Z$Ψυ”'χ{χ•ύΉ…Ύ”ΕbΣΤ»@Ω)δNŠμΉ‹δ. ₯αbSΧ .‘oονάρ¨r~~ΡλEΑΩBD‹υdSθ’°Ν“,‚‚Vh73b0zJήωS,K©΅v³n[V–οαpύΧ7­άN„^w‚Lΐ„‚Y³φ€π(•νQ9ιdcbZβyŠΧές'_ΌμΊλώ#€Χ—σs{"ξΟlΟ"Αzο8QΥEdΜΝ™ {ˆ€δ-ΠωωΚ ͺ)™Μ~0ŽΎa₯Fΐ Ον”…ΜνΫΡ`οή_ͺVVŽ]τμgΏssΎϋοψΫ³BšύSSY‘;3]@ϊΣυˆ¨oE¦2αΝsPϋ]Σ"wζšθ]ݐ…¦ fGv“d*ΡM+ϋΰ‘DFnIg3$Uu‡ŒF«BΞ·FrσψΞρY˜ξƒφdf†„?h;@©Ή±(?˜’θƒΑή3q뚺:ZΧW„;wI‘ Ϋiϋ"-@f€ΤΫΒΪςz·ŽΩ.‡€ ’Ή'ΟΆ%ϊ‚ na‘Jž$ΪΜ”ρ5’†ΛΛγΏ—…²‚&&ˆξΨu ‘ϋσ;Κ!Pg j~€Ξt!Ζ―ή¬=pψxφvˆΜάΚj΄$ΒέΖΊΦ!/»B'v1N1 Ygј«²-G{RcJ ΤŸCv\τϋύMI}2£Βν Ύf)Βqΰ£ή΄0“νΐ Θε‰AΔ7όσΪ?.ˆΘόxϋ6-ι¨Α… x @«rֈU^‰κgH5\/ξ?xߎcGŽυοΏχΰά_}θ¦}·9V’w…ΗAT KCAΗTά<>]Jμ4Βλ ƒ΅‘ΐ.‘ +(_ΧΏpί»ίύ™‹―½wΏσΣΛ¦I‡>}ν\χΫΡ€”LΔacl4Δ½(ˆNΥο3vxζ³^ΦΫ¬­¦yώάή½ί‡~Ώ΄Z/bΊ’š₯/fTuϊYή €•Μ0=6u²RύβˆU31€°οsœsϊτ§ίΡΧn[ίθ9jΗ3λΚΏΗ ŠF¦FιtzP˜%XFrΟSŸϊ‹ΕόόΞ)TžφθυP‹(Š9)Š…†υŽBŠύe°½r'ŠrGQ– E―7’(P΄9A‘awθ‰Ϊ—™x(’6°›ΪΠA·žψ ‰#“°ύ:k.6Π1Ψ’BςCwΎύw]ρ²—αφ7½ιΤ!·–»˜‰L’˜>ƒΰ XΦ~Ft›šι4QxZ@κ+^ώrά~Γ oΏόϊλ-Eρ”ύΑ\¦‰δΩc‰@X3Έζ’;cέ±‘(ΜΘ3(fΖōƒ±FσΪ@ΐ,ΧεŽΨHoηΞέ{υ¨_­¬όόg=λΏ•ssΉϋψ¦³ Bb>mΟ€1ξ¨4 0Ӟρt΄D¦™@烦1‘ΩoΡF:€³»gc'…4ΣLŸm Σ1χJTεΖ₯Ž"‰†nˆ$>κzm{¦ν¬’š¦ωj½Ύ~˜δ>q ιB#3›O«ΡCο0·ι[TΊ«‚T(·σ γxŠe9WφO‘|ΠƒOΣ,Ιh΄"ΐ.Ÿθ$"¦Ύψμ’ΚIWGAFZπH&tTeΖ`οu«Bt»Ά‘ΡΙ*΅kΤcΥTI LŒΚ=€λ‹¬l¦te?;Ώaxjrθ… ›όΉ”zήΫρ~Sα'ΣήΡn†ωAζuϊyc ‘PΣ.ΝpX­έwίE€ή‘9 ©:DΚ%pβΠ0H)"}jMKŽi›ςΡΜΘiΒΨ€‘»iȘΊf!(Šbξ[ϋθυΟ}ώK§Λ›XΑ‰X ΐΨ/Α{Τ©Dͺp‘$cΧx…)”ΈaΧΆ—>¬ύ㜈, ΰϊiqœ„"" ‰"Œ€`4+KΗηŽ// ֎―υŽ//χoύκΧvΎχŸΩut8Δ<ςoIOη€mλΡ’l©Τ…Fp;CΐE θΠ€iΈ97)ε`πq‘™ R²Ϊ2Y뺁vτAR†<|ύ‘™r0ΐCDηƒOQίύ=Oμ͝wαf4Ί„u} ©ͺ½(ŠGJY>~ΟωΧ μE±σ){—PZ‚ΐ†vz»hb™αΐ2#v-°rΘΛ9=T£#;ΈόΘ‘―έv‚H΄JMœ‚¬=&™‰T±i,¬_gνK±bν:.υ‹}{άo΄t2£ •Ν1” €XM8tδXΊΧβΝ“£΄νΊφ&Ip%vΛ#Q?€Φ‘£) νόι>„rhM“h$?Ύͺεεƒ(ΛίΩ 4ηŽ—μXΕ#1η“‹!ψ@σ}[¨ή°ςΑi¨9oΏα^ωͺWαλoxΓ―\ρΚ—_Z x1z½žΐw`§δ¬ΜΨΆ-mO₯Λ9κ`‘fœBΔ>yίβθκ>9‡/ ₯(žΧΥ8CͺεeΜΛΪ’wηŽ{σ˜ί¨Ž;rΰ™Οϊ₯rΠΐέϊ§_’3|YŸX5fšI“Η§ώtΝχδωi@]ΌΓ—(ΠΝnδ:ΝΑόψ#•†6ukMΊnFιH“’oν€šQ»jAŠiΦ‰n ²‹–Έ}]@pύΦz4Ί‹usUΡΛ·M¦¦L{d9ύΑΠeC ˆΜΐΝ˜Κ„uPEQφΈs[3έ_ ‡_c]]„^/!πl›$ά@:εLŠˆ°± ‹ϊ“]š8© ΩκΞΥHQ U'#άp.i&XΐΞξv½ΔΪΕψaζ@₯™Λρο)})ϋ=a3‘K¦V:U€΄κŽ1mBυ^`ŠFUˆ)>˜VΔКK9ay6”jeωΆc7έτKΫα±kΟUwˆΘγb“›φΐ³Z LΞ`_sΪΪ4k˜lb·‘[šς»#r2™»°ψΓλ"'•H‘c)a5»όΤC—F+iu’θΖυMφβΜήP­Α€ˆeY„`"«l€ š¦.šjTΦ£QΩΤu―©›’ͺκr4φšͺ*šͺ*λͺ.–|ύΆΫw}φ‹_έρ•#Ηϊμ•"M­4¬2ވZO¦Mψ`»¨‘ΩιA‘”yΪ‚l€œ˜h@!ϋT‘佫•εƒΕ`π»'\σ)§Β.fq4q°@›δϊ ’’ ΅™&S`=MrύdΦόξο|bΡ?Q ΚeΏίTΥBSΥσ`³@6σ€,JΣ,œg]/°ΛωΉΗ’,d°s磋~ΚΉN+Χ¬ŒΡεbηΚΙάΓfŽΞœ9Β΄σδd/€βΨ–Ϋ¬€³H9zh°gﭬ말O–g9!YΣΪˆΩ׌QjΖ\ςΌ7ρά§I`*Κ^1?x’ȃρ_>8Wνϋ Šς{„–n¬r‰Όη«Ά6@˜#χι"CfΜ¦;SfΣ%₯―»pβ“lͺ$+Π`C1ν™Hyi**Fύ Ω‘@;χ†¬­K9˜“¦§#’ͺCΰ©σΊŽk­ˆ@…ŠΘζθU*#\¨ιΚͺ;ΰςN©ΧΦV–οΎηίn‡Ζ)Λ₯}IΘ7 gΝ.ɍπ@‹ίζΐΜΥvμͺ+ξ5M™8¦"άΘΈΪ΄/QτίqJ‰ξκ@„‘‘˜Μͺn}LθΟu…Z©Š{c¦hQφ.»μG{{φœη•βΘS}ϊseχΒ<^ϊͺλΎoχώ} ΧΦe8φF£Qy|yepμθάΡcKύcΛ+ύ₯γkεΡ΅υβXUzT5‚<“7QW’d±ώ-8,ŚŽΪ¦cFq€f‚ΩƒZ0RΕN Y‡oCDXΧ½χ]οΊυdV 1#«`D€ΆyέM()tχ9L`Έ ž΄γ Ox¦;:J-σkQ–ύ’Χί]τ{»κj΄³‘fWQς’’ίXo08E±½rgQφQ’γ{›s3u¬αΆ`Φ}.§(Οι ύ• —”ŽθΐOZˆBΧD*δ›*‚™ίΨΊω_WΌ―:‘cœ"ŒΐΕKλœΥή =œε‘˜ζ4²Ί )θΰrYΏΘˆKJJβ\qΖ¬m5)Ϊ n ‘εm¦Ÿ£]c6ίQŽŽΗ° I5)ύΙh4yvΑϊ±c_iXύšˆΘ¦H”ˆπΰ¬I^εŠ!?š…εθžΣ‹$‰Θ}οyOsΰ'~’ψϊoxω/ωZoaα•θϊΠ"Wj’h]$=C―ƌ¨™‘νθ$ΜΡcT‘ν Χ Θ4†rcθπjhέCά"d›…½E―ž2WΫ/"σηŸπΉ}ϋήΉόυ―Ώώΐ³žuΓΑwΏϋSrπ©isŠΘΠΝc*1@΅žD‰’$ϊ™9ΐQτ‰fΡ{Ευ΄*͘Ξδ­ιΜ,Ί<•{―ΡΊ"L;€Ι€*m"P–=r• ˜JBΒ k·΅*±=έvΆI«_ψΒϊΒΕϋ\]UuI)τœΆdF Ό&' ’«Y+Ξβ’΄”pΪ€ŒθZΘ¨3“€²ϊύ}r–ZQUSΥχΥkλR..š’†Ψ|O9'xξ§t i£²’υ)½ ͺηƒΐ^‚οΔω *ΩΖ%JΦή\γχv沁†ήμ&£8]»±$o݌…ͺλZŠΑ@€ͺDšFΩF¦£MZΐh!=‚[7¦TMΩΌ&ab&)‡ΥšjΑ¬¦ͺd΄ΌόΕΥO|ςoΆCcχΥTΥΗ' Ύ+’퓃­}׌6SVžMsΣŒ•Χj8οβ£€ΙΆqDϊύGœ|φΓ0ΆaD|}z’X˜Y‘N'² ΏQΊgδ}‚ί΅k°ο‰O|/2€ƒ’ΪχόϋυΑΜ½IŸΓ8ω,,@v·νΌor@Qβ¦λˆœ­―‹Δ¬Κj’΅zBT‚%6e΄†@A Θu±˜©Χ‡«ΕάΰžTΝ—‘‘³€D"Κ’½‰δ’ ΌίƒdfW\€ίϋΘG<‚η%Ε‘Vœ gΧz4zMgm¨Η©‚h²Jd=Ω™e۞Ϋp3Ωγ›ΜЍ#½4hkaJ9…Ξ9xH΅²ς±=ίφν‡ύέߝ@ ±LDk&£sΦΘbNj՟‡ΘΫΡ™Ρd‹Π%“§f‰·€+ˆ’ΜHΕ΄ξ†4ΰτn›Fκw%χB±Ζ΅pT…Λπ™½,UαοφbS7 oΎλθ§₯ϊΞhζF‚L@ϋ,BγGY£·γYΉ’Ρ!ΣΥΡIiœή2ΰΰ{ήΣ\πŒg·ίpΓΟ_ώ²—-• ό·½Α`!‚…ݍχ!«ΰϊ©Ϋ–ζάΝGλ £i£&Πe^―ξ£JœZk†„Ρ¬caŽ­…Χ©ΖyΝРōκRP²λαWΏΊZ^ωY<ϋΩ―>~Χ]ο\ϊη^—3pΑˆ>ΒΌΟ–z5fχΨζIš0I–[&W¨™ό΄ŽK™€Γτ,™lο%s?v΄ Ι εiέ9 "_±)°H‹ε'E—«ƒ}6–°z'φXCσw•Ή‘¦“*vnΰ8(g£νΚ„§jε‘υί…yίT‡.NfΕ1嗀ΐ™Α»Ϋr“Ÿsό΅¦iDκZΨPΨΤB6γ€W"E)ΊΆ΅‡@+@‹ΠΒQγj­_TΪΩ έm-,ίRΰ½αx{H(),ΊψΕΌΗw¨^=Ύ:ΌοΎ—l‡ΕιWuδΘίη•GΓpρY‹$£΄FylŠκNFω&(s »΄ΌΡλ•;υL^͞z+‘`š©Ύ¨ηΡ#΅G€ĐωΝε-I(3ΎIίAcΪPpΙ)ΆθπΉVN£PŸ΅ Άš"NφΜ‰„2E²HšJš0“#-ΔύVN\WΉΚcςƒ£₯cπώωμ2ΙRν 7ŽΣΗ!¨? ψaΦ“gΈ:-ΣéTμΏ  ‚ΦiV]€€L?fO•θΆI·%γ˜‰kĚ\ά8Ρι³”ΉLΗθNd˜wCœ1)#"Β†w½ύν?yθοώξψ‰£Œβ΄Ρ$σ ™ΡHrϋZς―Ώ:@•w΄ώLμΡπύ˜’ΠΪsζ,N9V―‘N»Δ!—©Ίλυ>ƒV’>Yh5HTσCχ@ΫΕφnc†_j$&μUЬ9τΆ;ώΰΖί8-‡1}±eβ»΄>Ν3£Ξ£3“gpΔ£L―¦ΈKψ [ƒίχ—Ω\ωκWγozΣͺ––~ΆZ[½ z`=CυψBε'k$Y>“g—γ–Ψ„+ή™Y¬pϋΆmhφΈχ9kԍχκŒDf)pœpM )wξXάσΘGώΑξ«―~νy?ς£—>˜y₯f#’—±€ς[=΄°Χ›I •3ΫxΣ€T ώΠΝΛΊœ0$ΌΛ†•—ΌŒzBƒΎbΨe¬eW3šYΕιjΩ–=,Ω jς6tΖ―£ώπ_Χk«χŽ’\DΠoN΄/Ι:Ωι8’«Η€ΙΆ]Χ/3"Χ.Ύ’θΟ£,ΟΔ½cΣ Z]‘Έ67ηJ«hQλTw€I’—ƒ‹²©›s½ΠΙΝags©t]Ψ ΝΜλ+y %8°3€K–|ζ Υm‘°[7!5ϋ(ˆ‡q'""iΚΙ­@8)b;αl‚κ\ζ„Υ;k.Ιρ$;¨CRHSU2\YΉyωŸψάvXœ~­}ωΛχ²iŸ$A­aΪ,U΄$(ύ &•š ²%v,t&'/5Ε  •ŒͺJ^'£(Dz½Srœ ;ξ(†OiΞθE_θ5θ[‘C&ή6Ιε€ΨEAήΆ‚ΟθgA‘vΥϊ=tΕY` :*3—βΫ䌌ΐ†°Ϋ‰ΧOπ³=3‰€υι=sΝ³ž ¬ͺaoaαΟOΆλ!μΊFHЧΰpØ€…Oj‹Œ ΐ—"S κ½ < Kš8+œομκc4Ž‹¨uͺHΒ·mcF”ŽHŠ<¦g6ς0—^·š‰³ϊ¬v:2ΚβΩWυϊϊχΈΓ§ΐξQο*0λΣ‹ξΓj«΄M?X%6²θrΚpŸ€θZίiϊ&6J»ΠœΣκχ™‘ qG>Ο5 qŽr£ΖzZu±„€όnX‘uΝΔˎ%w5Ω©“Ÿ=vKo0_Oίiμf—t.›±‹’q?Œ¬(S :?U“ΚΒ£ͺΚρςAΎΎώϊΧSDδŽoό“γχάϋ΄jeυ¦6Ÿξ>(Φ=t“H{OF›QGfΜd£Ή17AΪΡI ‘[鎟vB κά‡ΪζT©ͺ? ΖνΙ{* ΩyεU/Ϋyε>πΏ>λ‡δA‚adΉ`F}iνΩpΰF]ΩΫ²‘Ϋ}ΰFΜΜΈΎͺ~Δ±q‰^`ΫSθ¦zθκGf`l@š“‹™u‘£₯λE¬ΐEΊ<ΘΔ^“Žp@:Χ€$iκυυ[؊$)π’ύ;’.$³2ΜB”Nεn€J‰ΦaIκŽ;Lσ#θκφ‹Ήήόά#ϐTΧw³ͺ†γ‘ jύπŒuW°‰£ΝΌ^Ω±’YϋKΫ'ψΓ τa‰€QόˆlaDν«NfMχ΅n($$ΧΖΝQΎ)έ _’4M|?€ιθŠΠ’*PΊΪ1υeόCͺC—‚‘¦·:=5zd ŠΒΈμΥΛ+χ~ο{x;$n0XUU₯c’ΦcIFv @κΙTΊm,Tξ[|P…7Œ54ωέξΡλ-£qVΜ Nχ‚0 Όσ)Κ5†Œs’aβ#ς@-λ†ΖώΪ)«fA*‡Ρq€λ^MžΣ„=HΖ΄ˆI±‘χ$ΐν]¦ ]ΛL¬iÈ₯jG¦’E͌¬€^'ξ0ΓΓ‡ςΰ»ί}Γ‰/«a™„‘uΆΚ$|„$μ-J‚$€Ώ’Ÿƒ]:3^Ϋ‘κο¦P 6«ΨbΊH© Φ‹ΖΖΨPϋ±ΫŸ)xυΉv}C2_ιζr²΄–©Cργ¬ΞΗDNhφL2›rόκΝϊΪqώς7ήόζ/Ÿ^Iε‚Θœ™™ηΕ’Ε`OΆ\6C-aόAƒ²C5υAΟGξ}Χ»Ύφ΅ΧΏξi£εεΧIΣLy ͺ8FŒϋΜψΑAζζ(dΤή’k΅"ΫΔΙE'šG'»5Y³ν"[9πΰ|™6ˆM…€AεcΌGηΟ?κ=W?όύ»φΪσ Τqϊ„Ρ ΅ΦŸUŸ9ŒΒ†]νfGΆτ€}یΒψž‘K]ΓΤLg;θ;‡PS p΅³jlIbτ:HqŒ/Ϋ5 .ρΈq›?iž™‹ΈΤΰΪ$?Σ°ΑΒwH:γW΅ΊϊΑzm­bΣ8«@š„ώ Ÿtφt‡]ˆ‡Qδ¦; ™’(ζD‡λ€†j¬(Λ^οκ3RΤ6ΝΡj}ύž¦;ψΆΈΣΒ·Tμ `΄Ιp贇.ΖΏEΙw¦5!“ŒJƒ1AXŽΖY#°–,,mž[ Ικ‰PNμ~?Ӂ» ‡$š1§^ `•a\­PΫIΡVˆOC`†άa2qrAFυ€ι(hW1έ­kεjυx3:rψΖνpx{n8ZΛρ,θF_Β1ύ!οƒΜ`Β"ΣEƒέ―Μ€‰ͺ"π‹ΣEΩœJ-•ρ†MΤtΆ¨υZ”ZxF±!Δ%YŒ>ΙΥ½8JtgEΡ•ΐ7ζœυΨίt°5aίWŠEΠ!m˜ΥΒ‚…°D³*B^»Ρ™ι“ΐ0‘FMθφžΥλλK½Εo>ωτ˜’ƒοsn?]v0€TtΗ²-ςα:'ьF.hχγešΦ&Lς‰ οΡ΅δ4«ϊΏZ›Αχ”3Ÿ>gΡ₯™£:Υh†ϊw€{ΐDΝφρΚE^Δ’‘κψρ‰Θ៽7·νͺΚDΗ·φήηάώ¦!@Bzςΐ₯”Rτ‘θ{ZBaσh  >,Q±Τ²|VαΟzυκ‰ H€  $ƒ4OιD%h"$—άδήδή4·;ΝnΦϊήg―9Ηs¬}ΞMr»°?γΝΝiφZkΞ1ΗψΖ7Ύον: X)&Ο¬€³s΅ΰ5ΕΎσ₯τF£«υͺΝZ@ ΆΏΩΏ IDAT—λ2jlΓιX±#nš2Ό=3ˆ`S’@Ό)βDj[§άΆ‰fGž<ΛZ»šw­GeK°I\bO72^Y~Υξ·ΎυΊc^}ϋNe]q\zΖ₯¨!DFXU€Χ·>ΧΛ/ιθΰΑ™,/ονϊPϊ~μδ…d6“‘Ίlά,RΞ ρκΤ’fδΤΑ?Τ5–tŠoΩΚιd€Ν¨€xΒΐώΩyδύU-.VΫ.ΈΰυyξsχXΏ;σYΌ6Ν ΩΜPΝdΰ4βΟ@_8i§YΪτδ4`Ε\Λh0<<Χ; ΣίK±μOv¦a%«/ƒ΄€vDj_mΆEρu‘(C ΛI!¦Ί€Σ?·—Ώg^χœ@R=\ύτdeυp{Έ'mΈλEcFAJ=P‹—ΕSŸmΓBΈmOqDδΌ8ΫCzΝΉmΣΣNΘƒογdrz=‹—)@’ŒΖΤ²=½ΖΜ΄ώ6€•Ρ ΪȎ‹νT7t(EB׎ΓΡP*UωIίΛpXf "Qwπh uk©Άφk*Q"Ž0£;PŒ;΅ 3ΎδώηGv˜ζš#g8gz~’Oj©WVώεώΏώλߘ‡Γ£X “Ι‘ˆ ut`;θΤqK±|?oVΪ•4>”θ+Ε­£΄"%Ω|‚Wύ^%ΠxΑ3ER0"R Tƒ§­;ΆN 1DUθΨ}"FΩXνΒΧ‰ΞΡ*οJTR£ΘˆΔ „oSj‘EΒoGBM`―^‘ LΕPІ{Ivw!“₯₯·ν{ο 7>(¨ΡJ Iκ4uj­ΐαŸ…ŠΉΒBPtv@% θ`£2ƒυTωD’ΈΣΉ~­Sέt#*5Αμ©±Ί†PY^ϋˆ2yο‰lΊJžƒDU˜4³εΩ­¬έΗ{ΉζΗDdς€—ˆαI©HH38₯m¦˜4X’ΚνhŠ`ϋm ΅ιΐ8žͺοœ dδ—° «ζŒY̌ΔΔΥYμ›YΩ“’Τ#ΣbJt ~ΠXŽ#ο½χΏέωΆ·ΏΗψΆΘ,αΞπt!ϋ¦―Ek‘­ ²b€¬yέ~Ε7μΫχψρ‘C―c]wΗυs―2²ΧjΧTΥ–ΆHW:GZω΄”έ£GΙƒ½ΜٍF\“L5ΣΘX:ΤΞυ“,GμΠλΙΦσΟϋΟηΎΰωrμW³Λ.΄\­**χŸΦC&c§³+ ο<ˆ}fX&£λ}›ό»›[αΟiDκI1Zή‚J>Ω£ΕqRwΔt LΓ·ΚBw@ γ«_!€MσσΩUΜι€aš<—\Ω³οίΟCαQ‚ήγΡέβT5Ί%Q=‡ h‘²;”p:tٌΰ-ZΛέ\paFΎω­ύΛ–'?ω¨diJㆣr΅bZΑp„œŸ(vΑ…8Ϊ5 ι49»΄_ ¬“0tνHJ;–騀n™?*WŠ–Œf²I«υ»kA~“ί:šΉ¬„C4!έ ±ύ»ΡΑƒ7 Άmϋ_μI·ŸΓcnψβΗ œ‘•Μ£Ϊe‘.˜Qˆ‹ίDn€ID %*ΈN)gχα5Ρ™DPΰxxR»zϊ0ΰ-H΅[!z “8·£‘4¨ ] Φ²•Žx™ˆ~ΐK"Eϊ^θhδΊ „{‰ΈΣ³Υ­1jVی¦f1v©=Α|ΪQ©ϋ€i*˜LηWTΘξVS―ƒ€ eAε=ϋσ€LˆdtΟ=Ώ·ηšwόΦq;Œ)ͺ\EΐDΦLBd‡―6οM₯ ς–CΠΨ+TO`@ΰ`*Ωϊ˜s_xή₯—Ύ_DzΗd‹•#A‘d;ΞλbΤŠσ­οu”ά@ΫFU^ͺΜz Ϊ8Ρι9ς‰ΧBή$tηyt5M‘iξΗ(δ,ŒΈμδš™a~ξ Uπ˜aΡΟA₯θκŸl¨^:ςγΥ•o ϊ ‚W½ToYΝ‘k!<§Μ1c\XΟ­g±ΜμζΛLyv}:‹zθ-.ž+έ8Ε1»ώνίξΔ3Ÿω TΥσΛξ£-eΒD6ΑΡρ΄’¬πJΩ«³ΤftΤoυC,hΫ?tΏ½υΪΑ3ή·vΠ €Λ›€€HΣ–S·ΚΆSΪ­Η)œή) ̘ž>Π:]x±Ο\μ:λ[ήxύζkaϋΝƒoάώŝίπ8Ηj°α6ζΞRJӟuw;'k’Ί±Σ’ΈC\б§­Cq†$β€O΄HPβ:Η€\Έ’’^OΕβ—p*›π[ϋω4Bκ0ŽŠ4.}φ'*|7%ΊTVΦΖΠ;w_£Ο  ·°π]"ςTfy1βΕcxρMEf©%ƒΐ;EJŽJIέ/":&Ύ'w,X@Jθ<τ uA™α”n]Τ =%eύMΧX‹"•±χUŒS<σ€zN YΒ•~'“±ΏwΧ»ήuχƒ+ς¦p ͺD_fˆƒ·  ΩΓEš‚²Μ’Γι­‘…s¨ΛIζ 8ΝDp,AΧ'ΚΎΨψKŒ€4ΎΐΔ$„³ά6²g:Wρ?Ί„n{έ5P<7†&ξfmŠ΄g‹ϋ^{0Νpτ/wΏϋέW<ΨNXθι—,ι₯oΡ•† ͺΖχœPy{ζRόx rΑˆ]&Y@Ίe‰ΡƒNαδ›-œJ²lB$u.(Αώ’ϋy%ΙX§w±‘δˆΠβΡΐiΔFoΟψΥ{ξy-'£ί< iΕ©Ϊ3•Žλ&ΐ̍²~°ά φ#‰sθΕΖsΡγt-ί³S;Ξ>η[ΗΛΛ?TυzΧχ6oΐλ:‘ά ΏhΟ₯ιήζ&wμΤggΩΤ€s‡V¦J(ί^7DΟΖ΅¦4y,Ρν#³υdŒƒ [Ξ>ϋ‡Ο»τ?έ}ΥU?φPΏ«ΆΑ•Act”@%§ΠθώRiΛ:v*]Jδψˆ£Κ;Ξχοβ§Uτ9«Ξ-Ι„θwmς±ςbDœΥœj—AbΡ‘ΨJxΙL/²XΤΊΝ4:%€€ρ‘₯λ†ΓK[·žΕ~_ŒζžΡŸGδEvΜΒθΜτί6 ΝA›€·\ι ;DdQDVη3[ΌπΒ)ύzΈΚώ–­…#CΰR„œpCwξ}‘±ζ½Πd{πΠ2_‘ΰβΒΊFόβ‹ΓθΕ=Ψ¬›ΝΝΦ\ΩΒ $P’x-YˆZjrSμεƒΎ€1!V‡ΰk cυΎΣθB šRA,ͺ3G›Zΐ„D₯¦+σ»sWί³!τ³€\{f=θίko±|‹κ&=)ΔD•tƒ‡ώμInlV‰ž‚^χζίΧ*T…l0(πΠ‘]9jέ+{?«€JΠ|ΣƒέQpγ™ΈMͺήίmq<ΝFsC:Ϊe6’“'QΘτ›Β,3Ίΰ<˜~ ϊ=PΘΊ>.}6q6ZXΜysnΔ2HΔiςZΏ5ύΰT<ΆŽ[%σͺmΚ€(υΩR<:xπuχ}θCW?ψxK)λnjΒ‹²[YkzύΙγΨφσ—qΈ}š•RƒGqp ΨoŠqΧ *¦‹σ«ΐ)³­;ENPB±( Žt¬ί"GUϚrZ*"Fω₯R8Ωχ“’[S—χμω.y #mΕ¦r»z€>R7me2–νσ‘φ‘FO‘FΏΰφ Κ› ;ͺζ₯~ΞNΟ…*7ΜZ$>υ@gGNU`͈+%žTΤvf?vmbΖη oašχ ο½χwQυ_΅ηϊw<Έυ°qΙ8Π.κ-΄οD姆-A7 {SΩm ?Y{nx_sΘ蜟ώιΆn=‡= Wχ6mͺ λ*¨₯’ Τφ"ύ%Ybbm”_!¨'lq`4ŽXDdUΦΩXƒ±Θ.n^Ώ0BρρΕwnόdσΩg»s/½τš;ΊκΉιj&»7;b»8ΧmŠ’ P&U­ΈΆ•P"©ϊ­ŠTΑΈΗk{θθxΙ*+mχ˜—νLγˆφ]Q4Δ"†υ½²Κ O“_AØΚY[ηχsBRxU'Ϋ:τ‰O|frθπMΝxLΈ"Œυώ²%BK_ Ί2z,Β0²Rβš7‹l΅ˆJ0lήρ΄§=ϊx?³α]u3©OTPΙ`9ώ’Αž¨ XXάM‹AO7Έ‚rAοήέΊΐCUt£rŽ—ΣG›Έz½€!Bš&‘€Τ06³άv‚vνŠTξν±1άJέ ±Tή–Y`άήυW3Οτ”ΠbδIΔΞψ·σΩ^(4τPŽ˜ΞOΣ« ›©|ήΡ_ΐο΅uς4ˆqε#uμG˜€£#ω+§€BώIPӈ€wΦLjυ{—=²’ΙΎ±_=Θ§ζ­RαϊΔ#ΏQ ϊ>ˆt^ Ψ=@ΘΪ]2 )κΟX˜Έθ}i&ATαŸυ£Κ%Dƒ%tp‹ϊ*ΉE‘“€έ΅Φqͺi;€΄‰ΚKφ&iq§c‘Α˜‹1;E’˜Σ­=DD%Tͺατα}χ]?ΨΎγ!g°π Σ–;β„ό>‘RuΓΰOjl©« .ͺ°Ÿ%«Dg³ρ"πΕ•ε€tώg _#"ΟY'ΨeάΫΚ©PΧ€MS‘kηqύ|Pΰ43(¬λΙθΠ‘§ή‘xθV†ΚZf]α$'(₯€ΉYζwΉροκ™”n…yC:Ƙ+Fuˆήφ…—DH˜‰΄LΜ€Wbάpϋ,Ι4ΔVΰβ@·$4Ϋ±‡Β•ΓF†χέχ+ ϋΞ«ίvœ@${†9Τ΅£Ω"¬”‘ƒšΣ δ·r^EOΜΞ4ώ“@#)ΊφΌυ­υ7ΎρξααΓڌF—Œώχ“•ε‰χ…aή QάioTδΩ%=ΨP°:Ϋͺ3Α†œ,-ύζέϊ§Ÿ{Θ@$ΊΞ‡ϊύ†Ψεg³<ŽΠ$M‹Γ«P 3,λ&1ΪΫZ:²Ιήxe<˜œ 1B3]˜:΄΅Fπ½αxŒzΑW_ύ‡{―ωΣγ\qΉfΊ_£ϋ6ξžλmε«ΟηvΨ¨: ―=Χ\3ήuωε·ΌύΆχ6γΡ7Χ++O:τUΦt£dŒTMˆ¬f#Zΰ`=Y‘vόH»rλ_N–’ӎRiPΈ}Δ’ν`˜w’š±ΜΣ3vδMωk―Ε3NΑy/ΌτΏ|ωώ½τ₯ΑΫG)κ„θΐ0+KAUγήψ4¦ΡRd2,‚'8‘6 ½SpW;‡Ήbη?£1žςξ€€€Λ Βθ ‚F‚ZΔAοq~"@pυ/Ζ‡έΖρ8η~𢃴cZ…ε \+Bί·eJΎjeΑ5 Zυͺ~5<ρD<³¦iΥ£Ρ}¦#ΐ|Xjn †”˜}Gρ,]R崌"Sf]EšŒ›f£ ΠΟίM=X0)`%1b!§–Ζ¬/­'~-w ‰λvΩ<₯ΫOΨ‡b!ΰ[Ψφzμ¦QT¦ζ3"b0…VOΉ`±ΓPtΚΪt&η OŽ4 ŠšLZ[O·Ο5“:>°ΣόΕχϊ“,Ϊt€ρru"ƒMWΘ–-Xˆ]ψιq„E«Υ0wF ƒνEZo›gG½¨…oΫή½Υ°ŠΓ E2ͺΝ μΚ7Š/ΚΰζFMSάdŒ,”l‚ͺoJx•8w™oΫ― TΘƒvkρ€4Υ‹ΠX½²Ί[D^²ο†v?Tΐ|iπΰ AΐΨ8‹ΜΊΝœY’JδZucaΞu&₯‘¦Ο£ΒŒsΧqΑX―k΄‚θ \jζ•φc™³zΓ€–¦šέ΅¦D΄τ܈cΑŒΙΝ‹―j.놓₯₯+ξ»υΦ?”‡{aεΕYΨ-£psάDcŒΙ%5»©˜Π3μφΪ7vGφkΦeΩQΧA&“‚]wXΌΚyƒ.Β,Έƒγί’ΒΥωˆ5υςm“εε}χ[ίϊφV,n“‘HAXτu*8Ɍ0ώώš°@œ4bΫλ]χΏCυW/σo½μ²Χ£ρΦ««vxΰΐ•Νx”š^Œ?§νΘςBNΐsΙ.ΆZrmn㝏-{ΆŒQ‘c,KJβΪ4ΐ0tΒΐPHQ…€ϋξmΪΤοoήςςsžϋάοΌν²Λ‚XH ƒt°΄sγΫ@α ΔΓΤ†)^*³²I)Ιω:Ε6Ϊγ\β)o+iδ·ΣΉ›ϋΩ,²CSo‰ω‘uœΕ΄‹™ύ˜œtκI?ώρώ~7Ηγ:Ε£’ξά&€€gAtLyd}A]Ω"ΘU`ργμΖ`Τ5Β(hGG£Τ/0‚bFΆ:†…ιEpΪεζ M³₯x œσυ@·œύΞμ{Ν)cȘMΏͺ—Ζ‡ŒΤ“WξΎκͺϟψRvZ€ε k#Ό μΧ¦\. §χοidtgέ)|νΉϊšύ»ήtωGΏϊΗoώγzΈϊΓ“εε8π‰f<ž>fΜύΈΎ{ξπŒœΚ±ƒl|Q@Šbb4[~I  @Όv"" § šφm:γŒ'χ’])"ς`Fά²< Ÿ»hΓYΝ…Iεω xΒ#-πήΦIμΦκ4 ΥFε\\>­ ˜† uΠςΉtΪ2Š£ώΟ`hΎ#n%΅@am1ΏάΥ?Y?ΨκηnΊmtφ9οoίφœͺ·₯'¨§%Τ*vώ1w ΄•TN>"ΖA,αJΰά± 7<ς„ ‚ƒώδ=Νx,ΥβBv‘Ύ\ιΆf&uΧΚιr8°GΫ(S%r~d76Xa8Jon Δλ’:žGbπ–ΫΘΩδΒ“pϊ,s—,PΤΌ·Ψ.žNkSH[,Ψ%€ˆπP#Iše@-KkέαT˜ΏΌ―XΗΊ­’½Β|f―]―`ά6ΒyΎBP ¨‹jΐΒ΄Ί£‚,v™&fΛά"†† l^š\ΎtS^΄z΄Κ£5(¦:‹Mw)ά°΄–“EJfh< ˆέ΄υž©lώ–']°ςŸϋκΡ&ςα™m£’’cŽώΛΈ€@)3DγnEκ­ϋ‡8[άVƎ&0Η3±SR¦hτϊ>πΚ](5e0Xέ/λ–«φf θ§pΰ΄ρ`̘ν6UC½Ίz[3Ώμžoόσ‡ΎΞΫ ϊ¬Ϋ€Κ¨/όB$ά‘.u…J‡!§ψ*Ξ >oΰ2Ά:ŠήMgΦώy?Έ '“’΄γsΉi‚@5kώIεΏ¨έ IDAT‘εd2š,-ύ€ϊε½Χ_{χ±\#Ιy―Œ&ΦMVas”ηj΄ηA #όIηNf³aΖ)γζZ žEξ€,ƒŒY΅πͺ”bGf·η4K‘€a₯‹ˆL––nž,-ύ‡Ικκ+v_yεI"©BΡ.uΟ«rk}n’(ί¨΅φ6ή=―;ήφΆ/~υςΛίυΥΛ/νduεω“ε₯Ÿ}–& ,Y³έ3αΜ_lΎ\βΊ6Ψκ@2ΨΆuΗΒΆm―yμ/ώβB!]₯Oq"HΙ±†OωC2p‘S€΄,sCvΒ¬=_=ΔR ¦λΪΝ‡Μ¬P:N©v”φn ?8i―jρ]ΝT즍؆Eטj{ΘT[ζ°QyυOζwπ―?σΎΑ3ŸφΓύ-[Ÿ;ΨΦ•’'ΫC–‘΅·Y‡j|@wZ bψFFDύώ¦ΣΏοϋ.Έ#Ήε<²ϋšαpΜ-[Π]NNηειέ½θΔp™)ϊΤ I¨©Ÿ­ŽŠσTœΫtG:Á'ϊΓ Μ4c=52ΊEνΜρ ±3CΘΕ; ςœθΣή<ƒ¬c…½) ΨBςΤιnh”]7ΦG*™δQά²°#j[h uqΣ‹n½ΖyA3EβiέCΘvdΟ°ΚheSAŠ,ΎY9%Νϋ‹φΤ‘Qš+ζ3NW "CPY"lDΘΥ ωγ£MJ v'\šl_}P¦°λndΏ,υƒΥlžœ“’¦’§˜©H£hα^„˜…ύ|L<9fό¨uόR؍jΠ,l-"[ 2>rδJišrΟϋίϋ±+τΔ«ΞΧθ‹ft—€e%ž;J‚žEr+Ε*΄δζxt¨…Šd4ϊŸf›$ BZΣvŁθού%Ϋ5Fΐ€ψοήG\d‹p4Ίo²Ίϊςϋ?χΧoωr}μ†τπh2ά0ξ\γΑ€ΟŽ7NΝιyNHΘζŒ‰p9άΊ²O‘΄±‘ƒΨΣ;&­aΦC²teΠ‘§υ6mΊΈZXθΆν# Xκά@Ϊyu7‹¬{”eP ΅#"¨ͺ>z½Η‰Θq’šΙdT‡‡DδΜ’CΠstέL:0Iš d¨pvξvΌΘ:#Ϊω¦έ†DnBSBžλΡdΑ™μΆF‘„H€/΄h2υ΄vΚω@t΅윊9΄œW ”¦#GϊΝΐΛs3 p£Λ±W#P’Μ*\–αΊž Ί}‡«M§•ΆυDw‡ +‰τ.f‘ΡD•–r|Ά,:4ΙmB”ψžΫθ¨i.‘tΘ ώtF=™ͺπQϋΪ}{—γ υΟlbψ ύώγH Ψ‰^{UΠ‰WkY·χ4hΗμX·+ν8"F₯ˆ’€GπΑΚτyˆ],ΟbDLiΗUυ*7cž†±W}Α0|NFU’/Υ£Ιοω³?ϋuiŽm‘g‹„UΊC ±~‡Α ¦ΆΊSW5ΒuΖyοΛm™t† Mqt2_βs8Ώoΐ½ΧD\„ΠWνZB§·™)“ακMœL~νλϋπρX¬σΒθΪΪފίη’πox2#˜Jpގ7ͺ5J­»UΞmΐ&¦yΦmν­³ΥLw –NeΌvŒΞ!Οd%₯³‰*‰τ‘Z4–*™‚Nh OC‚‘‡L§`Ο†ΨAc%ώζ³,l΄Rα†’UO ρ Ί…ΡΦ oB1Άΰ#L’β5zθΒ WΓι’ςυœ}ν\ΑβΌsΠUG•Ο<GΗ‚rΣƒƒtΨύ΄cμθx,©©žεδuJΒr€ƒ ޽ΕYPNξΑwnDSύ7–φκ΄#wN»‹~OΖ’φϊiΓ ρΐŽ7O;—ΏUOͺ~1 +€N|όte?Ebν`!Γƒx>ρ‚I»p(Ξ$“„ΩsΑΓιο΄€K(&'“-«/ΪΗλJq°\νm§²^ZώΈTΈj{oxΛρυΦύXoύϊ“'~0ƒš„ΤNNγt³±$΅[άΰY%Ζ!5³%»`Ρ.,m¦¨Ce#²ωΫFγΓυhψŽͺΒμΉφΪ/ο¦n ΔBQ«#! G%hwZ†šE^hV|₯ƒ§Ο-ΈaΜ¦Φy}aξιΙξσPΣ™X°°/2NVVn‘ΊΎ‘l>~ΗUWέ "rαΟΏ»ήtωΙ[4yϊ²Eά ίζF0΅Γμ˜_“ΘYΒΈ―π©x³?‹Ϋίς–;dΚbΎπη^rŽΟ‘ΰqΎ­·yρ;PυJΖ²ω3;›.O³?£e0s‡Q\‚΅sΝεΣU%ύΕM—œσ“?ω·ΎώυŸz@η€/Ζ: ls>Ι(ΩQKΈpuμ&σ΅Uo0‡Jς5‘NA ixσΝ+ύ~vμxVqρ›«ΕΕ Z”4ρ“βώM+λvd™αφνJDD€BUυίz"ž.7γρ.|ύŒϊ’°šφA[»Ι„ξ‡zTM%}μ(€ΣμjΛ†BάEn;Uηz6[!²TˆŠ =ŽTR( œIΊ΄•‚―³H8ΕXd–™ = `>f!UeAÎΜK=ΩŽF΄9“”“˜JεΊQ2mb*ϋm XςS<‘a΅ΧοΘ€HF=Š©BdF’“…›%Hw³Ϊ€Tfρ$ dk˜Α³5ξμ؏A ¨z=iƒΣ,^υ ΠY];T0‰3Α’'g HΜ‘΄[šFNγΕ‘Eψӈ°‘™CWΜ*‘sO7ΨP^(j+·₯Ιδώf2Ή^κΙoνίϋχŸΘ3έL|±cΦsFΨ§z&Ϊά·δΕ‡a›uˆ3ŸCα*ΊθAˆ²ΝxFβΑdΡu͎b΄{hŸ—1όˆg( 6%7[¬nXF75£Ρ«χ^ύΥ""η½π…Ψ}ε•3λOψ9͚o5‘:ή]ΑR@ΘfT―? ΰΊύ7άπΆUλΕ!©tﰎI;!LSΐάώά 7ύΜ>…Ϋ[žcM@₯μόωtc0Pz^x4>”Χ5§κ –φ`½:άΕρψ½•τ^½χϊλοhώxHζ6Τ3Ÿ9TBΏξ%ΞΉT˜¬P^”BΉŒ}”ΠŒΑ}!bΠrΖ`Όύ »ΧY8ZιωΞΩη` 1ŽΪ|Χ»ήυωΗύκΔ-―ώ=ξzΣ›Nς‘2ΥH Δ“£{ΥΣkl₯2@±ΆχJ“Ž2dΙ’5ω΅q}ωu―1λeΧήt›ˆόΆˆΘE/}ι·OVV^ς’j0ψΞjaa‹•YθZυF:&_F–ΏGwσƒέYΝ-ΥrηwWϋ†!˜IA―'Υ¦ΝO\ά²mσpωΘΚΡ.e―­+3)εΠ~Ρ1dhVαŸT€©†§ΛΛ0G’ζΧ:Wuͺ|ΠύθG‡}¬^]i€i:c«h uλ·•Π+Ε”Μl ˆΝΏιολU¨ gˆgT/-a]ߌ'3°t¨R§N0˜Š%gmewžL»hŠ\PΖ[yνpΗ£Ψ1Ϊ²Ζh` "ω―ιzJ܍EΤ΅C’w΅² ³ΐPKJ©ΊdDFdΰq-μμ34«σ’…αKαVd‘•ιη62ηCβ΅Z"o©ΰѰށˆa! DfΓq;Ά€›σ4xΓΡ@‘έΪΦ9 ²ΡQI³Δ‘GΞ4¨kπΜψπ"Οί@˜;΄:U°„Ρ£<œωY`ϋ'τz‚ώƒ 0cfJd;ωtρ, Vfδ ΨiλZΔ”\2΄½Kθs-€1λ—M]Ί =¬Sd4ηΛ$ŽιFΠΗ$“9kŠQ}c2:˜Τ++ŸjΖγ±½ού‘}' Dr@{Ω„‘τŠΒ,|6'±[ΥΟΈε%asW‡NΛj λΡλφi†f$d6Γm=b,ΩQ3Δ ΓrΜγ³σ%β'H§†Oω.fƒTž–_0ρ ΉHžΣ²iFΝpt3€νο{ι€8Έ•[e‘Ό—jΘaΔ0ϋΑɍΑ­Πa”ΦΠCής\­† o‘Œ\Ή«ϊ¨dή:Έ2 E£Œϋ€L]L \#^9ΎΤΓα^Š|©~ϋλ―ύ•»―ΊςΔT °iγ£ΦB†’”€3Φ= ΚF>bΊϋ{υIυ=ϊ|šΕ!₯;PΈ¨uΙ³yόtuVj=» X3~_5θχΟ:λΧωμgvΧήπΎSDbΐNΫ€qƒ:_€.57* )› ’Gk7ζχ΅|νzγ_D~Β—Όδ "ό―\T-nz"z”­³Δ¬2ΈB~οΠnœ‘G)Q¬°H*ά`6‚©ΨυΣτ1ΨΆν9"ςŠ ₯Η5WΩ$SϊngσμΛΰ°ακ’'8_Ξσλα$ϊ›¨zϊΣ/ξmήςχƒΣΠλΩ‚_mΊbC±μ’ad†©΄:ؐX‡”ulŒ!"θχN{Ϊwœ_ώΕΏΟηS „M³4YYYνoΩ²Y‘3υ&Ί€“δ,B:¬\s€ΌMρe¬μw|NψΡ.#2ŠrmlΌ"R₯ά¬tΐΚ`—VΉξ9₯₯Όu‡1ŸλαPΆkΫ²,B² ƏA1τ˜h—$S$4Œ.?­|Έ·ϊ-ƒTό6B6~²Γ«α–•YC©~vkύ\°"±˜œχ`FΧ+υ•'»PΞLEahp>‡†Μ,§θ’d?yCŠ4ΔβEηχd]=*œ@1η²H»-φ‚ *dFœKcΦ"Š}1α­\v0ξV,Ά|7—Œνϋ7ϋάvφLO’(,gπΤ¦kΪpV_ίΐ₯Ρύ{Ό™LŽp2ωWˆ|x 7όΖΙYχ’a.Ž!Ε€p8ΈΛq=QUοr#(€Q‚(Εqͺβ‡6«ΘφhΆ Z‘Κ •—(LJ’ˆ¨QΗiόAη8Mn^LXYG·ψκd4ό₯»―Χ?žT‹ƒ,#ρ.ͺ’s?Ž2›EŽbς+πΉdΦJ2ΦβΠω‘G,sΐ4₯ϊD»dY#:ΰX8Υb³œ%Hκ!jYρXi)η2,l:ύτ«υ£?ϊ„»ίχΎέ§DQ Η ŠNdw‹ΐΙέΈ–Ε6šU³Ž³ƒςόZPΊόςΟ‹Θ³EΏτ₯W ίfτzO¨ύA0-š²£.Ιxy³X —φ•K%fyFοΨζΊ,nU zύ£Φ›΄iaΞ‰υyDΞfξΜ@`ΣM:sΆ†">6pΞLš_ΑN±«^^ώΐκύχί8:|xH=βζΖΒΩ φŠΦZ˝CRJZ”L}];ͺƒ`·’ƒ~O©Pυ«AοIΗϋΩ¬άrKΓΊώr31”Γ¬±ˆΧξ eK2:kδβg°,α=o…λ&œŽ£*yŒŽ΄šž5D™EYf pΤΩ‘½ͺ…GΛ[ Kˆ6Xer xΩ:X—ΕI›<£ z΄Σg΄¨‰tœM›>ΰΕω°ŒΌπ€oγά½a•·GC?Ž…‰\‡Œ#―k@σ4­²kCμΞfΖ h9~ Ύ­ή―YοFΚχ€0¬)SŒ¬§†~›Fͺ~ιGH©ΨηHΘhΗΓ‘a]«q­)°C„ ‘/μ½όΒΒΖχ”²pΌΈY&„Μ:‘”` .`ΤHiέ§ΕωΣEgMΓΊφ5£Ρ§YΧ―ΨΓ Oήw²‚HˆΔ­Y:wRf·@ 'νΊ· $š­Η4¦˜" -ΦN—7띩G΄.έΆXWΊ€ΑaKΨ5Hu.tk{1»k–3{Ş€ͺ’šΙh©ŽΎά G7`2ωΊ;ίώφο9ι@$C8Λ- §,ΧEλ”I“*\Γψ^šJη.1`SFZΆγΚpΤw3tφ˜ςΈ•ghέηλtnΤΤF{¦ΨBΈ·ΈxZoϋφλO Ιg‘Τ *)ͺNM~?’r£–‘nfΘhί»Π²ΖDc^|Ν[Όυ²Λ^τ•ΧΎφΫκΥΥΧΧΓαMυd<ΖΜ$²l’)ρHϋ3I2ψ­¦A‡"'έΡ’―ιωO‘ͺͺ6ϋΌηQ†@•'PξP€Ψρ*ƒŠ Σm NAύΎ’Ώ8£œ_σλT’ϊΣ»ίzλ― ο»ο―λα°άΤbέ·\ŠτΤϊτ0b΅^ΊΩνΨBΐ₯«U+ΠΦλυͺ^ο©' k«G£½qΨcwŽŽbp&Έϋ°ΧώΨΜF Ψ XMA ­ΆΣ7I‹ΦΣ!ιVH{q€ΘjΕT,N©Ÿ~fΩ,3π S.xΛ&]l™ΙT•Θ¬εƒB )'ΕΠŘΔˆΡaκ:Ηνσ#dξ…g "tKΧ ’ ί‹ϊOΥββ·?\ήMρ¨eM]LΙK΅s©‘ο`ώT—G€έΓήp„Z\W‹MMΑ»‚5n Ξ$\ΛΈω˜–’²τJtριΟSWˆΗrZ6CFZ،Η9…“ρΫϊ=εQΟzΦ«NŠΆ)„ξU²BζƒΨw~ZνIΐ’I„ζ…χΊn{Γ~ε+―yν“κ•Υ?š¬~IšΜπC6_fn,θxHεxŠέ«1t)–EIM£aΧB΅0ΐΒΞ/{pΐzn:±?(·γ‘>κ2Φ\¦e=’l‹€ψ9νn~ΝΎϊ§β‡ίrΛήΡ£}Yoqρρ›ΞzΔΥΒB˜tΈΑ Ρyh%έ!7U-‹Q¨Φ)ŒnMήp¨ͺͺκ{Bΰύ¦>ΘρθNy‚ŸΞ.ο₯eMœ¬ΑΊ&9ŠhGΔjΉε;Q‘ J―FŒOμaoxΦ―Ξ?Y0H‡₯΄υLsΓϊqθΟ@O‘§‘Ψ)55`’\Ή 4₯h UŸφfΐ4ς±IΕ3”εWg±ošdΣΨ+›eαX1μX–Jτ2δ,Ύ: Ž3ί‡ΐXΫ‘φϋCmν˜ζ¦vTΦοQΕΕ`ΤΝ€e:ΦΓζg%Ζ•ί0α¦ό9ή”θ3B’V%εNkiΝ Tƒ Άlω¦ήcΟ­κ―άΡl&(Ά']¬„ƒRτ}Ά,*b$εˆV•k‘}ΧFσΦΏκ{ι`trX¬ΚΩΚ©0ݐ½o)CB»&›¦™LŽ8$"_ς7ξ~Ο{«δμŸψ μ½ξ:ήuυ5'a CO°ˆ! »II8Tσ€ΘΐB«%Jμ7)EΒΖϊP‚EλӁΖi§8³™TŸΩ¦“\gpW»€½76͈u}@*άΟQ}γžw^σ«§LB§[ϋN+¬Π³φcinΥυ(Μiž΄ύt)Ιc‘¦i€φ¨PL:bƒ7FJΣ[œΑjͺΕ0K ζQ¨|E‡ŠΨ€ΐ₯ Io ζŽa|ΜZš‚•¨zΨςθGη3~π™ΌοCόδΙ½xΨκv·ƒrοM³i€_\\ρΎi.ώˆltmΜ―€+Eδ•ΏμεW£ί{Foqα,ι]3KŸ…ϋ3Ίwd_ŒάЌ&Κilιs7&•T‹GgΊδλ/lnσy‰π#ΈPh›Ξ©²Ν}¨$Bΐγ†ω5ΏN@’ˆΘΑ}μ}όοn€Β›7ŸuΦΩUΰκsυOU—ι…Ϊ8Ё€iμz$Hλ”PάxOΆ­/9!ΗjέjΖ“i&“ͺz}ρjNτŠ₯©˜SA8Ά° ˆ#νƒ5T ύZρ1Y`[π18Ψ5Κlμ΅•s*φ=aΔΩατ˜η‰ΥψcωsΑ1ε1Α²«hA6U$Θ¨ΣνEY[Ϊ΅NžΫC#:q"  q"βT‚ΐΤkZ±’XO$iSΏω^΅ŸuΡίvν‹KCWς/!΅f¬ώu£ΖκΔr©ΔΞΧ;} ψΜδ"ι6Œ)0Ν ,@¨F} ŒΤ!βMΡ‰Γτ- ½νά‹Ο?ψ•;vmΌτΊ!9£ώΌ0πR›θΦ’lΌA%"ZI!so1jM)ˆ―¬(vxζΤLXΕ°”M‚Bί±ζ2ΣΊΛi"ƒΘiύ>ύήπ₯•}π½^•{―ΏnOϋγύS?…»ήωNŠˆμ½ξΊS"ΥC¬‚­€Y*ΖN¬ε]ίL§zωπ3Nι+Ξ7%δσvίZΓ;λ[i§KΧ„B$Έ­ι†Ή γsHqN„M]sR―HU- Ή—γρΫχΌσ―>sΉ\ΌΓκOΒ*/€l3!5S\£$[ ϊ[Z/ι½QDκ₯₯ρW_ύΘ‡βΟ}ρ‹ί_¨~μοv“stu¬g©'‘ͺN IDAT&-9Δ₯2}ά4ΐ,lHεNΩΖΜφœοχzΫΞ;χ“§~ΗΕ‡>ωιϋNώ„Β­Γ{™šAFέ€jχ΄FrN@Βτ™ΥyEΖ8ΏΦ½n}ύ=ο1Ο?Ξί|ζ#―ΖΒΒSQU½΄£5“ZS υΓΤv₯?‡5{1bXD²-+v¬A TOΟ―MG§ϋœ“φΟ6/¦‘KτψΛ4ίλύHΦϋ’s$i~=œ$‘ρƒoΫςžήβ¦/μΨ± ύΎ/Ί6dQ€qΜW}šœˆζDR坢ΰνU¨ϊύΣNΘ™:™ŒI¦Y{»DP+j0ΨΦRΕiΊ¦κ°.φΦ- ‘ΧnΕK4#}hxœVˆ•€9oWLΘvΫZ\Λ+—Mρx€Ι‹"—­Υή Η§`LΣ5ΜΊ\΄6m¦ƒ†(ŽŒΆ+^hoH1R³φϋЁΘιξ0Œl§}γgtYJΜΝ0JfΆγ™A=ˆ§I_§xΉξΗ’WT*¨pο_sκ(HΕ$3Σ PTαS‚DœS|έtΠb)ΝA%ό Ω™>8pC BΆΕΥtϋW=a½ώwŸφτ§υΐ_ύΥόJhXD–MӊJCΉ$J3“Γ œˆ½OŠ χΎoχŽ ήv—ΎN [˜ ›^Θ{”Αš™„Ίv«Š,Tνύ·,.rη–-|μΉgO~θΏσΞ?οΧEδΪo;σάβΉ· )…&v”m²Œ€ .hC―–ΗzυΪQ~ηtl6& ˆTΜϊƒώ:ϊΩ‹ ΰIŽWΖε°=P “€4¬krT‰ ΰ>ŽGΩΤυ―έuέuOω,΄ΈeΝxuƒΉ:u! δv·ŒX2€h{΅ϋ{κπτAw\qΕϞχ’žέΫ΄ω‡΅σ«νΕeρlw©uδ(ΚΩέW:νςΪ% «ΗβDz›wn»θβ·ϊδ§D¬hΑΙO—ξmξlˆσͺΌ!;}¦³Υ±"#~}β{±tœ_ΝuηΫίq»ˆ<νβ—ώΒοφ_. ; )ΐ4$m„’ AAπ5r°7PΈœEiΈoU58ϋΗό{φ^ύ_ 6M€{€!6˜pRaΥΌΟ!0+ς΅Κόš_g iεζ:°rσ?ύΗΣΏϋφ±©_ΉxϊΫ['·²Ψ₯ΑBLš‘ΊŠIτΤΨ¦h>Μω› I˜ Ϋ§Οwr\JUMXΧ_¬Φ ƒžγςΉCΙιFκ‚ε6Ή>;†`§€Σ"’v0WRtN"OΘI:¦IρJV†Βnΐ'νLUŽS‰£ρ՝δ-Jo2RA΄vυέΧΐ&Uͺπΰ•6Ρ,‡4‰ΘΪΎDsJ—Άq"υMBΥvyΐεΥ«†ΦP/¬Tΰdη©ι;L©ΐ€½u²ΐι‘Ύ‡‡ΈoaiΠT7#‘ώ=8ϋqνcΨ)1K¬|.4˜ϋ΄€)Œ΄ϋ¨ Z…²·οͺž("Wo8‘WIZρΡua ŸΚyη,ΛnJΐž)¨θ£Φ«aΜ ԁ³u±τxγ,₯(—Υτ‘žˆτ+‘Ν½ΩΊΈΘGξάYσΧ]2|Ζώ―χnήΌΉύ =τ+ωTσpΙε ΖjΊ0λζΘΘςFjœ€Λݎ8c”£tFFMλ(Α«¦”`ͺvZCΡ\ϊn―f1iΦ΄A8TYfέμ’ΙδŠ=ο|ΗεΗ$4©ψΖ`ΖΫΜΩjςcŸX S€b¨³μέwuρκk|ΰΰoπ49ΏΏyσ7ΑqŒΐΥ2”“0­₯ΐ@!9τ)0NuΩ|#Κψ•ͺs»emA ‹§ŸρCxζ3ŸuΟ?ψ§''I+΅_ι’}Ώ>°0εmdL.VvDb΅CέΪΕ²ηΧCu]ςK―ΐ—_σΪίΊΰη^ςƒ-Ν[ͺM›[4Χ`Σ9zSρη(kτ(l£œΣ΅8β˜ς"ύ>wξόO"ςΧ[Λ%vLk[|ŠFλώζΕ(}CXΒω5’NΒkyrψΘU£…Ε―«zύgvμ\¨ͺʍYΡh(›z;m|mΫ+’uΚEr’ ΖT½~Μψ‹ούπ‡o9žcrπ`ΣίΌωŽΗ!z©Λι_鐛g”b9›L86ŒX. Ά‰γifΦ“AsΒ²Κ,ŸCk›δδw†ΨɈΡ$΄.λ4¨N‡θ― EΞ6A…¨’ΐΝΓΕθTώ’C’F7ΰ^Ÿ~OLʌ9AΤαγΗΌO‹"¬‹μΨ ϊٌϋ™1VPnxbt}ΚΡ”ό>ς( »Α˜hζΑΪ6]Z]¬)ͺΡΏΘάA“^ΗX ϋ εΨlν½Es€±ΣvΦΥ>‡“υZ+ΧΠλ=~£©€Ι6ηt ™ΕIcŽXPl-–ͺ–0²­~ŒΦX·ΝΔτ+>/€μQeύnάθCϊΜc ’)`Ν½’'Β~―'ύ^% ύ· xƎ­Νω~Τψ OψΖ#OώΦ'€΅;εtΰ·Bώδh ΌoΘW«‡Ma‚ΞΞ©YnΕlUn‘9;θŽI½  Hš C.%Όm΅Ή¦›b!Ι΄μ"u_’„'i(h„Ν„“Ι 'υ>©δοz‹›^sηΫήvΣΧT& _£Ψ£Α¦’Vͺ±m©}GCΕκΘΘSδΨL'έυžχόΣ9Ο{ξkκ^χ{ ;Qd4mψ§aK…T_‰ΐI:― R΅ ΝΩΪA*¦΅ϋ Ά]tΡ΅+OzEKŸϋάdωžΓΉ4?²c’’)’ΩΓ¦odζ‘ΧζSmΗβϊςk^K‘―ώρε=χω—>cρŒοκmήςd―u*:@Eΰb³Σ˜θtδƒ…ώ4›™m TU%Υ¦Mί°qTt:!QΩ±οξοk“1IdΤ€¬ VhψA 2Μ§ΩζΧΧ $‡?σ™]ΝŸψJΦυaŠ\Ίxϊι )ρ3θ’%K-ΑCŎ§T·‚΅F€Βΐw‹Θq’6=κQBru2ή· <JTΠ & XΚΈ­”ΫΣωx‹¦fΉδή€E(±υ"Γ /ŠΔ’nl Κώτ£ΪΉIΊžΥ=yΐB‰~ΌΡŠ1GΙ¬a:ΉQPζΪ‡©g`?μΑ€$€'ΉΣ£)šM”Q/nš•K`΅’  ΊΏΚwΆxl R’0ΡΪLI2ڐΓΤx©Π,‹J ν/ϊ5eךvζi5X¨8Ι²>††€ΘE€\ΈPbΑj0ι.Uχ΄}>΄°ͺQ€«ž)‚~ο’ œ&ƒφβ€Ό5Π«3qΩ‰j¬Έ`­ύ\–BKٞ&…„ˆ4B²φ’© ½ͺZϋΡͺ£ά~I]ΠCϊ|¨ UUI~―Η~UΙ¦AŸΫ6/6§oί6yΤ#Ξ_pΑωΛ_χ ?pϊ§M ½5(bνΒ*ΑΠ4Ÿš§’•P8‚Θ—DDž|ΖcFXA «=–nŽR@Ϋj}•H“όv7ΒF"l’(“Z=£FΜΈdώΘMR)KΫ²ΙEΤΩ°α„M3dSbΓ»›zr 'υ‡ξω³?»^Dκy ͺOVe$αγΕ ό #σ‚ΫκEΡ7€ζ€+H`ΟΥΧόΟσ.}α%μυ~C³ηg‰δ&Ά5:`.σ˜σQ56)jŒ9Ό·6‡QςνΣ―zUοŒ'>ρƒ+{φ|{³oίςIOΩS‚Η˜΄Ν*Ά˜>|¨f›Ξkπcwέρφ«n;ο>g@ywλΦ'₯MMNΤP›άγξΤAl‰Eλ΄ΚΧ£―z½ν_̝5Ϋ²)Ο#δz  Ry σΆ!­5DM£ZO~D£}σk~} I""K7έ΄GΎι‰Ώ΅vNΰΕ ;ΆχYυ:Ψ6,!Š1ˆφΗ»š›Q\Υ₯F%θυ*ιχΌŸΓύύ(O{ΪΣξiF£}9[;Q₯‚?œ‘sL#4‹ͺ ²Κ‚„$Ε‘λύτB8§ΐRH~R²ΟΰGŒ5ύ€›΅μμ’ δAK/‰Όj‰n¦Z³m‘?Ϊ(b₯Ÿ N_Tƒ!α‚aΣθnM‡άE§bMWΛtνbζ4J(ΛY[[ η°(–i%ΐ1ΏL4S…™Κ ί’{ν5Η݁υx€’`·«š]FΞ‰θ|iζX ΐͺ˜Y-xΫj.•βτ~DT£ΘͺΧ?wγ§ΡٚzJRΞR Γμd³αFWr̒ѐ2l€”Δ―½‘α‘CΛς#[ΫOτ;ΎΠώρω"|V%²΅’*-lˆT¬…¦ !«΅T©Z‘jΝΎ»ͺͺτυRMί8 @΅φ3*Jm’ZωϋJ—RtPQ‰ŒDδŸ~ιΚ‚.𸉝Ά ΙνΧ£Ψ’Λά‘Ύφ_|αζ—-Γ?ΌažžHR²³£˜7h£7@π¬4 NYβ«uδTΝ*Κ±«‘v_uε:οE/Ϊ‰M›ώCδ fς¦š-λ ‹²ΥDζD*8ΈͺNNv*F Άo†3Ÿς”Ÿέγ$'_ ζiΰ… «‘ΰ2S–Ϊ|IL1™©ΎklΥ9”t,―έW]yΫ…?σΏY//Ώ₯Ώeσ9ν†ΡΆ-¦Kμe5ˆΨ³‡y΄QΔ5έT¬aΔ²χBΥ€HU Žz«ΪrνV˜c’j’@JYΧ6χ4 xGy€r΄¦s+.#Ρόš_λ_ΥΓιf–ώω¦}«χήϋͺε»ξΊjxΰΰM#ήΓC:NΑ6θXγi`JΛ,-Kύ“U3$±ͺ ½……§žs΅οηhτ%2w ΩqΎjΣ@ .΄ΑYΛLΊΦΘ‘bΐ€-Τ€CYFϋ¬IΰKj[›˜.ŽτΑΞ$怫ΦΐΝgΊμX€0@€ρa)€%θ:δλ »*}!ηx•‹}₯‘͞Ngo[σΆ@ϊŒjσy­†&¨iΊ}>Q€T’΅β΄xJuΙ(©ϊTq+LτΈ=*jI{A‰GΗ?;ύ`¨$8p*dWZ6˜ά<¬& L·4AΰOœ  ί_Dƒ=η0”γdΆΧmψ[ΖΥ²3γ’Πλ\-ήςN$€€ΚΜϋ―Ενπq‘H΅ƒ«9e„@Γs¬§·ΈΤ™N‘Vk©Hi„ly­f8†Κ "’.a#Β:‘`œκ)«ΒoUD>)«‹™Ήgφ Αϊ%ӍӦbAΰ —Τ/ΫE)Π*@Ψ6OOˆ,Ζ=Xά™ θƒT€ΰFΆ0­I¨M(΄3‘ΡΉ:†xΐκ}χύχzeε#…;_GΨG€[/‰ξΰ§Bέuλ›g­AGΒγ0ΡΩv»2ŸJœΧ<+NMϊš€Ε?ΕΜγζ§GŠρΫfr??ΠςGαή§UΟί O‘ΝΤH(mž¬ƒΜΐ.^ΓfdCζ˜;‘XJP ΜPTώZ O•€Σ―mΑ .- t•¦ΈP >ηφΤƒ£=H–GDΚ' T6kήZ›n¨uŽ"Ϋ —&ΐeώiu¨Xj¨ΫjοgL3λ§h©΅’†„ΐ&*ƒGΗ;μφ…1o‚ͺ{†@&θ™j-λj0Xμmژs-ՈZ;^؎ζ"Ώi€ >1'hkΚATϋ†-¦;n •)B—2D‰Θi¦λ«Η !"bόFΝ΅€4XIΙ‘Mρ¬Ζ‘ˆ4Σ"N―΅΅]β½” <$"ψ0ƒ \$ ϊŸ μ‡Žϋν3GU†:Hλΐ6OŠbžό€ΛĚm3J»όΑΛΠ꜏ͺIΡr­K$’λ>VΧώχΎww=]QW fθ(ΆqΝ[ˆ•v³‡ξ\žξ-₯@šu£œ5VίςϊθοωޏmϋφoΔΙO{yXθžyl‡QD’Γ³e†£ω­°d§ΘΈf~Σλ+―Ώμ“εεXΰΚ9.$†αF»‚ ‘Λο9G‘NΊΛ3ˆMοΨΙɝKuœ_Δ"Υίͺ'‰ύ˜ςL6τCxνΉζš«9i~Ώ™4ζ^νiΰμb߈³΅S‰ Qžy.₯(vƒf=OίΊυœ-gŸύ3g?η9ύ“)„D'§yW§Πrjψe!r8½?ΪέFBzts‘βγ7ψ6΅€cΈm„#+1…N33>?£’H΅b³]Y₯ώyΥίϋ½^ΉΦβZΛέ`€u‚ΚΌ¦AΝ€nδ9mΈ˜ό³’Τ0EvΫ9¦0ΏζΧΓH9ς™Ομ[ή³ηUGnΏύ§–χξύόde…mιξ:Αu™Mfκ$+πζGHιυ·~Ϋ· ŽϋΝSRΥ“ ν½rέD«#Ώ ₯¦„”aΙ,&ϋ}j„’ηŠΫ`8-8³ΓH‰£ρfξxy--΄₯ljΪfΠΔΣ|Y“Ž―θ‘v‚αΙI]1‘¨Ί-ˆΓ3Mαƒr:₯tYͺ PFϋˆμdZCνΆV Ο:’ΒΜέ·΄X*[+Vίc‘˜4UΚ™~d~OmaΪ‘»!ŊTˆ0$εkpΚiφα‚‹ΣQDv=εΆPDU μΪΦILΟτC@1άςs6r“•|λ`Ηv}θΙ#LΪE©ύ.ΐd ΆhΊλ{Τ 'ε’—φͺg§λ ]Ώϋζv•_$"§Q1„€ΚγΏytm »‚Π«₯dž]3ΕΩ­_0ύ~‰P‹΄:¦,@{b`MqID†"ς9‘_™Ÿεϋe‘μ΅κŒnλ@cΒ- πXt-‚ΩrNvƒm~ ‰ΑAC?ιG’idz€½  h%³2_αβκC}νΎκΚί­‡«―λJNM6θνtΕ°²ιΪ Ž!S‡%X£07RΪιU}ρΆσΞϋοχί|σγN.0Ϊ΅I5³=*a£s«±–Ζ±iΣQΊ–HZ\(ŸχΌξ>ΧpyικεΥΏiAS1n™Σ5ݎΆκwς‘( ΧN}νΩ£θjiΕ/<Ε• ²ιœ³ŸΎ±΅L!šμΛ¬Ψ2λ@Ϊf₯05§‘@`θάVα]€%εtβξ̞_σλkHYύΒυ6-~pxο½/_Ϊ³χc£ƒ' gΔg ³hτ7[η­Χ"ίft "½Α`qσ™g~ύρOΪ›FšζzuΈb=½Α"ςDΔt¨όχhΆ8‘Av±'όΐo ΤΟ`Φy)Σ!v§{Ϊz|mτdšˆ5Ή`5ΉiLgŠPιb™Yβφ³ιyijΪ|„Ψ‡FŒ ΦΫΜψ«Η4ήΌo]¬ϋ#nL0'‰"° =?mάΌ02(Ώ₯φ^έD6}JΜ =_Jΰ9†’ΐŠ’cݎQ7tšŠYš–ΆηΰΑ­rͺρ4=Ελ#ΫgD»%%<±£P§e΅ΦΤ€ŠOΒωτ:"ωwWϋ¦^o°ώωˆ‘£(Qς^±‰ ΣΧј€ψ‡–Χ­ψρ(Έψf―Ρh‘ Ψ AΛξnr$© Ž)š&¨¦;ΆšF.ˆ°NNkZœ…BrΝΡ«ΕσΣ}ŠαΊQi•PΦ΄–ŽY…πΚ_ήpγΓ$0ξβ…BQ„Η@…jώkΗ-Z†qͺ΅_'  ΩϞYD v’h,3€ΕΡΕ”Ψ*)±7 κγ†5²^]}g½ΌόEzνΝτ TC@‹‘h$&I°y]qH« HΞζΒR‹;§Ϊ 8λ©Oύσ3žρŒsNψΒ‘Z ώyΐΘζΣΣ5WŒ‘Gΰe’jύX½νyΥ}";―Όκ.~A£œN!°IοΟ:AJIφ· «Ν=Α€—+™ηδτ ΚΤ1Yυϊ³^u>9L©|*―EZΧϊ΅Σ4ΙPΈύoDΗ£3“ε|mΟ――m IDδώΏϊ(Η|uίέ/^ή³ηΝ+{φξo&γœ„hqX™QΣͺ" 4—ρΤ)S£BO*|λρΎηM§ŸIΦυέΝxx efJκδ€F Xœ£-ξ˜s·π™AΡ~iŠJ&f‘bz ³Ρ°t`ξTθ70RšυmΑƒI-υp$“#Kήwοhuί]‡–vοΎ}υξ}χ5M“²ˆΛ?‘οΝΑTˆΉY–Ή¬§yΑ,±έ―€T †i%+¦ΰ²WFΉ[dj8.ι@ΜšŠ₯q(Υ&HH_£sšm>‡išVd΄fx. €se’&ο¨ΕΤQΛ,D˜Ρήbΐ{Fμ,$₯p¨)>J€A™Gƒ’Tbχ@Ν­Ί5Jϊe…@Θ ;τz—^ο1’t«Ο q[ M@”Ek#TΖ.M‹~‘3σοRΦΉκ “IνjŠ;ΣGNΕΆ)ξ°NTόΣΓ’@{τ*§Γ5y‘ύT}$s†BB§Lρ#δ‘d6m(B†υΌ1SΠπγx8g œŒ e*ν<υτˆb”K„ΚΙQ½5£Τj‘θvŒ?¦ΧήwΎσSMΓΧs<ΆeΛ ™¦Rxj§δFZ ` QB2Š3Μη:,f›φfλΦ z‹›^xΞO>χčΈ5βb…Ž%μY8ΊΩveς–πκt9‡qZ_Ηϋ:ΐ¦Q)sφb4BΤ)ί.sžbt6Ο”Ή±h±”~-Φξ*BaRUΥ#7ž@!IWh6΄Υ€cυCY₯ν™ξ¬i\C'Ξ')2m›_Όϊ_ 7ΉόϟoDδΦ­O~ςo7Γ᧚ΙδΏ.μΨy~ΫΦ~ΥΊΡ±)D[Γ†²:…[[οUύA…ΑΒχŠΘŸΟϋ½χϋ0w>νiχΦ£ρ^<&kPXAΒ68Y—:q ;ΐdvˆ΅JUΊH‚™p¨ά” ”Lͺ`F†²uyϋsaC‘†ΤiΖcr<λΡpΉΧGšΙδλΙύœLφ5us7›ζNNΖ»Ζξ>γ[žψηUU©[`Ά¨·ι’'š>'†f±bqλF…dGͺ‹((–•8}Μ–_γΫ©5€ £ψΤd©‡«¬Ž1τQυ€ͺιυ€ͺZΧ.ˆH‚JΊϊœšuΩΒm’,s€Ί·μ 3}ΞT‡5PڏΒNγ„Zi†­7H +…l%–S`D'κΰ6Υ§oMBYΝ²ΤμκτUΛ@mώό0©G— t*Μ »λ΄£wP,AGΟ—¬Yυ_΄ΩbΥ«.`…υ3ˆ&R^sβ­’;zІ­Έζ€t|Μ EΪ#„bνί―ί}σtPM'";[…„)tSω—£ŽUž i£B?οl tΡ“ΖμhŠH5}‘k6TΡνoBxΛΓΡ>ΪΎ‡€)‹ΠRNΊΨΔ6ΤΛΗlΫΦωuR­«š; HΚjŒΦXЁtF6e˜p<―;ίzΥΞыϐ~wLŽ!₯ψxLCU@YbΛdmI-'e΄όΨλS]4“U½“­ηŸχ;ϋ?ϋΩ‹ΘgOΤb!tΣK/"„}—Ž”=η[S@‘Α‰–ς —ϋάu`ŽοΕ¦ΩΗΊ™ ΧλΫΖlό23―3Š…mσ‘ZdΎe‡NAΟ ͺΞΪπ‚F4QΐPΣ̚ εΌΈ-ͺtΪ¬σάv.΄’ΡΆ•δ±·―œ_s ιT»–ώαξέϊ„'\[O&·ΥΛK/Y<γΜμΨΉ­·Έ`¬c}Z£ΘφΙ Rͺκ1""§=ύι½υWυρΊΧf<9$“Ι>NΕK[16 t'5Δ"ŽωamΩΣίjvOJόΨ–JkΞόσtαo)O֎ž …M-li&©G£I3 ›Ιψp3žΰ€>ΐz|λζžf2ΉMΘύΝd²‹u}7λϊpΣ4KŒ*‘Ρ‘ΏϋΜςYξG’·yσfIE5¦Bί L’΅yΝΕ7 Έ&Q³Λhˆεˆΰ€†Ε’ΰžr­rL& '9²kΧ― δ¬jŠΓ IDATͺ?xΌˆΤ‚jGσ¦'TƒΑφͺͺϊ ϊXX¨€jΝ•«ΧKΦΐˆ\ •p£Ϋ+-V P‚ΔΖύ0ι‡Ή -ο]d[ζP€οΐ’B*±VΊ@df…χEϋ‘ΐςΔ†α,³[ͺι@X'?υωK Σ&* q–ΙŒ/’ƒθ”›l°zDξ>χVΥ£7TN‘Ι±£¨φ2`dΗZwύα0iσό‹Κί‘hΦZπƒ/!›Π΄/…DHOR΄RΆab‹'«ΉΠ2’ͺΜdR;Bλ&AD*‡’ΣΈ iθKό½ˆΘSΞ<χα–ξ—οέ$κΎΩαί1Kς…^gΘ‘,’:¦Ο―“h…aΠ πυ.­έ™Μ™l‡j¬Φε#ΗσφΗ«ΛΧ7l~€Ώuλ·§†έMΞ„ΰΔe·ΝP›lP Σ™q~*-»J±ΏΏκυzg~ύΧΏcλ#ωŒ}7ήΈλD IX£}ˆ³|]/V- ΣOƒΛ²8³3 θŽ†Ι5.Ηχjšƒd3₯―;&¨?¦ΉΤy˜s·…{Žψ³€p-‚°c£0Ή•ϊ¦ˆδd$mFζΌ‚΄¬;–ΐi”gΜα”ΏΞΧσόšILϊόηG"ςρmίφδ―L–W>΄xΪΛNίω-ύ­ΫΥΒ‚«τiΝwhš£§f:’ΧCoqρb‘γ "‰ˆ4ΓΥ₯f4ϊ"'“FP΄z‘(“Zo¦ΐJTgΰΓ$ 2±VΦΜ¦ΰ†²Yb8–ϋύ.φ3“θlϋΞοxβ–³ωo«ώΉΨUΒ‘€a"‘‡€˜θ5'΄­)γ€ ’ξlt€†XρU‘.­Zϊξκ=χώλ‘όβkϋž΅©Ώ°ιτF8Ήyάο?UοτͺίƒΗUƒΑ¨ͺΥ`pV΅°x:ύA5θWU―Ώζ2ͺιxArNξ(Τχ<ˆ¦΅4=Ώ=ΌΣA¬q("©ξ7DŽ”΄ω^‘KJψΡ3&ϊ0BπX}@uiςgFGšΝΆŒλ€lŽρG³qΩZΞ±ƒ€Q ½ΏN΄[ ¨ΞλτOίX‘RH "hΓrKΪΗΗΙ£HNɐ(΄SΔ&?dσ9gνΌC-ͺo·†'M9KB01ˆ›•7_Σ&€‚€ρ6Έ[‰v)$…•™±¬¬Ζ©ΌwU‰Θ²ˆ|μαL]:ξ™{₯.YaLŒΤ~pη•Ιλι˜r|όέηΧΡ­2”¦L7e\\Π+ Ήψc [—[Π ‘šG·ΣO―dο;ήωΕΗΌΰ§_Ηρψm Rc]”‘e‘Žσt±šΚ΄P³S?&Ωexbτ·οΈDξ»ΕgώΠφͺ{?πώϊψ―•Έιβ³ΰ6NΏιΈ)F‘š>ΆЎQzpzn—~άq$ςH―Gn5.΅„ψ3(Ζ’8`01τ΄Ÿf„#β;Ψzμvš«V½G·¦!QW–ΪεV#>z2„4ψΨέ.-ώθςΜ―ω5’f^G>ϋ{Eδν۟ς”ύ—όΔ`ϋŽŸlΫ~α`ϋΆΕj0©`6!(%5°Λ&]ιΫΥΆrƒ@-ΐ!N&dΏ―&„r‘Ϊ:tΩ7:= –s €Σ;Ι…u-4ΒΊ–z2nšΡx\F‡8™,7“Ι^©λ#omšζ~6Νξf2ΩΓ¦Ωσ³χζqšUΥΉπZϋœσNUΥΥ34Π€ ‚ˆ‚И¨‘TΗ½z7ƒIΎ\“οϋ4χζ‹ζζw“_’Ρ 4Ζ ¨$‰hœ™I˜Α0υTWwΥ]Γ;ž³Ύ?ήχμ½ΦΪϋΌouΣCuχΩΏŸUυηœ=¬υ¬g=OΪιNesY«ΥŒλυtξζ›ΫEq]gΘ-ίxβW£j-±_D’ƒ;+1Z4λUζ`… ΅dεTX§Ež“o@†6pX1ίBΘΊθξήύφt~G/½{Η|`ž½h@εΤS«ΡΔΔ€1Έλ&Iž†Qt2FΡ ˜Δ§GΥΪS1NVš8nDΥJΝΔ1š8ξΣEφfmΉ΅—J'Dι,!&ά9bΠς(Z$ƒθ‡ΌYΘwbnzχneyΐ'@Biζ[ΈŽ€½/JΖR!u²’ΐUΞΐYCΧ?±Ι:0 ’{ Ά,Ί5 ΪύΊ5Q”40ŽFGΔ4䉑n–LΗΌνΘUƒ‹Ρ = nΪ&ΎΨ³q26λ„.†Ύ™|%[ΞŠz#yl₯|-X‘n6}8‰λ§1μ£ [`󑇰Ή šύ†η6”,@%κͺΜƒ½ΖΖύdJU‚$΅’Κ8yΩ qŽ 9χΌόUW―>Œ²=g@ρϊ.Š’…jε©ΊέόC—@ˆρ5œŠ Q8Kw°uZ;vά:ϋγέ4μ>tz¨ ΣƒάP9ic-ž\½’ψD'Mnβx#³!ͺ՞ajυc’J\Α€b±ίEœ ύΦ/•`#q !ͺ{€*™"™\žy1'Ύ„Κά†„ήAHŸΗs•βΤuRώ~e“Θkξ82Έρœ…W1ιξ+u[Β †o%8qPΥ)ŽLwnΎ1«υ―˜ΠCή}™Β‹6TΦ΅δ„ξωξΚ΄.I#²ŒΓ~υΦ{σ;ψ "š@Δ,o.CDγs·˜₯3ΘΝP1Ϊα›-Q»”ƒHˆψ‚sIq; \Uœ˜%ΡάN¨s˜€iΦx-Da-,ΰξ›Γ˜ƒΌεiˆŸ’ΧYŽe&1ΠCξ dv_α¬–Μ©fuΑF•L‘jυ™of’…ΐ+ων3’p92:½ {D-+žL’’ o­ΤϋςΗEmŽή* I[-κμήύξ}½?Ν[[ΨΊΆTO9©jκ΅„L<Ujg˜$yͺ‰γ§FυϊΛΠDγΗ+γzm©VcŒ’>°`ŒB1χΤΒc IaDPΕkyΒ’~/.`δ’ε™Šnί™ ι£]gQ«eΒqtŠ]S¨v䉓̸ lD£šH‘_SαŸ#€φ³ΖΞ9η†…[oΝFσ¬ R8 δΙIh­A@…«γbΑ₯ Ψ.ˆΙΧƒΈCοd¨Γ€ΓG¬2 @γk/‘DΫμΞΠ“7 χϊŸ˜qΩrΤm―Ξ…·ψ‘AΐyΊΰk6AQ> š½`&‘Ϊ,$δ~ύX˜(˜P:ύ F‘e½‰„³Λn„φ΄ΓŸkAa›61I1―uF&ΐj$ΰ-Ψά•λPGΏψΕMΗΏύνK»έ«M’Έϋ!$ΐΔ!ι‡XΈs“:€1Ψ*ηρ|TW|ޞ™™7ΐŸΔ°=| >φ^Ν΅(Eά@ΐΑGτyqδ΄,ΡG¦Κqπ6ŽU&Šͺ C±= ¬μ‘3ϊΙΝQΕ†ά•ec‰%!@–eS{"αXhXV2%QH κ9 Ζ#ΨuεYYŽHZςhήqΧ\ΰΪϊsžσΓ^«υύΈV{EάhΌ,?ΥΤj¨REG"y:Mρ=%‰b4ζeΗ^xΡmS_»ζ 0HΤKg²NwN€N¬©ο€fm$‰ϊmhY”φ(λtΣ¬Χkeέξ|–¦3Y·»²t6λτΆΡξ^§}?ΜτοΞ€Ϊ‹·έ6SIΆ{ †Ε;oλΜvνk_{E21±Š+#kc!ςb,`‰ž ^)D ρΔ‚eήfE’U?ΧΦάπΧ{`  HB‰Z;v\·η§?½kέ³φΓ›ΫΠ†~{ά\0vφΩg#β8s&Ιs£$9=ͺ՞ΧΗaΕ¦R‰MΤ_}™—άΕ< UOс9EFτˆΰA1 t‚Πt‹ΣΐzΧΒΩ”3θ ₯{\ΑUq-+Ρ™@ʐƒœH9κ[ΐZς¬σwiCΝ 'εNG#a ζΑ¬ΎΗΖnf΄ τ!kŸΝY\ΘgΔ…°s—F„Ϊ„‘΄J·₯&dxPE՞krm#&»Ž€A*ΛμάRQ–m bΫs™"ώΆA€I‚I@ۍΩψƒ,Ϋvž¬$qV ^ωZ―nhΠΜέ¨,sΩ ­Ϋβ‘Ζψ:BfHξκHTT/(0nδT Ι+H„`Ψ^·œLύˆΏ™΅;ΕΙορ½Ρ2Y η"Ϋ[URM!½1}HΥ ,τϊ:06όu―{έ-;Ύυ­Ÿμύ…mΝ0ΔκΟOΖ %βΐΉ“D’Ψ£ρΝP/,(•γΐζ:Ζ¬FΕ溜Ĝk­Q rσ ƒίκ.ΐνUˆλ‚jWk"‚΄»τvuQδΓΰZΘ¨DΕΑ|uk<gΞϊ/]ΛQIϋ(έ±» πυθΨcΏS?ωδ³’……_ŽͺΥD΅Ϊ³βFc}T­TLRAŒ#@μ3•ˆ%₯œχc’Θβι}g‰ƒ7ζnΉ%›|ιK·gέξN’τx €,Λϊi~――M΅Ϋ½,νu³Nw₯½ω¬Ϋ{œt>K{e½ήvH³­Y―·ταέΧ_Ώϊ:έ’Ο H{¬xΡ‹66Ž=φmΕΐΫ’ˆdε kq¦ΒφjtΥpΞκβν;kpt‹p(§ΫwόCJΛΏτ{­];ήw0ξηΒm·έΖώσƞχά—G΅Ϊ‹1NN1•ΚQ’oͺΥ5¦Z­F•ŠAcMΔ\ύHwΚ8Οή7™:Θ Ό,dΜNdJ’Εhύ+,€Ύψ3ΖP$ZΦpΨΑQ¬9sŠ”6‘l2ΊβΌ½Φ!kΨ{'―@)—’y¦‰b³$ ˆω±–p- 1qgΡλ±0tIώ* VΦ-+Ξ@ΐ ’,ˆ;υ’Q‹Ώ‘ ‰³Aš;²YζJ‚‹α!JG7χΚq$€MDψΟ#DςŒ+Π‡)β-₯ ύ΅‚‘Δ<ρ€Λ΄—cΩ%ƒδfR…@¦mω„³¦ΪI²Rs?^τnΛC‹ύ±«jžψŽwύyΪlώZΤ¨ŸjMR€'ΑX#’€ΗάVbδlΧζΟ©5¨“ξώčΖκx|όœπΆ·]Όν‹_άsΰ' ΗlK‘TΡ…4.Ιχv±>ΐ©α¨>γΤίψ0>τ—A@TwΕύ"PΟ΅n‰΅!tbAvη‡fŽ£Ά¦-ŠQns ‚¬Σϋωޞ—Κ –@‘‚Pˆ'ίݐ±s53 Έθ|y^–£’žΤH§¦ZσSS7ΐΝγgŸ½!j4^l*•s’jυyρX㌨R]U*“$βL+Ž$‚1ΖυΪsM₯rΠγ“΄έή•6›7΄vΝ<)λφڝi$κ€νφ&θdέ¦;¨Χ»7νtΆ/<ππ}ΩιΕΓω™ΥŽ;ξΛQ£‘ηΥJ‘.5πFj΅»#Σ{ΰt0Tρ€‘Pžλ7ψςCNό2–² Z»v]3σ­›Υ=^ψχψ!ό*υ3Ο<ΥT«§™8~Ά©$gEΥΪ3γz}£I’ZT«ΕEΉ-—΄3ζΰοHί-LΛEΩ Ό;† Τ.*ςu7tA†„$2λ}χ&Ν>ΈΜ*1wiσ»ύ)hρψˆ|QHτc…Q`R0Όf_9βα@…X{βΪ|‘X)šΞ Ύ¬ηέh±π9s› HŸ uŸΏξG/0œΊH­ZTΚΫr¬}$ΡΔ(Ψ[ύ·œΐ[^°ζ„#&pkI%΄ήύΚλjΤ@1κw ¦wK §”γaΉKͺ}rδXŠRk₯ΝMδœΑoK' Ξΰ»mδ3J0 ρΨuΟ»Vžώ̏˜^ς5Œ#•ΐ§έHm=Υ“s= )ξ“λ–.ΥU«^΅°eΛ›ΰ³~β ;δT―tθˆρŠ}|S—LrΔpcQθ• φΟXyώω΅ΩοΏ½r·pγ‘Ώό :ρο9 мȏdHI+ζΉ€dŽΠ υ㰎g Ο= ΗNbΜfοΌc/dMtΡ3€`9μϋ©Ub.j0–|Φ–δΊr”@~Ωόm·= _€―ŽsφΙq½ώBS©>7ͺTžmβx½©VOˆλυΥ¦R©"˜8ξηύ{(4ζoΉeΞΌδ%vΊ₯;{ΝΦMέζόξφ]χNihςΌsΡ$΅_©―]{nž°kρ½€ !›FP“mΘ„N#ɞ)…Ί3ZΠΗ"&D,“ΒŒ ³°Πš»Αχ/“Ϋήiήyη}p|j3Ξ8έ4/7Iό΄ΈV?ΧT«Η›Z}e\«&f*‘1LUήΞ*βξoΑϋΘPWuŽΚ³€%λ° b ‡½°΅ΆΪ Œoδ±2p ΑωΨ‡Φ ‡(*—r1ŽN€ ‡Iq΅ ’˜‹f3@ΟoΗP‰ω] \» yvS"ωKk 3`»!ά7-”GQ0##aŒ€D„h#Kέ#"θ(αPϋ9N>7°“tDFnj:SPς6U₯Ž…jίAτ“]=+xK•¬ε6ODͺ>ΒΎέ½¨XΛΗγŸhŒ€π₯εΤφΈpλΏ§«Οzήwz­ΦUΙψψe{s,xtN’=,€™| ΐ:ζ€(έ] '¦’DΥ΅kΨ7Ύρζ©―ύφƒBbΥTq€αά Ξ…–n¬4ΔhΕ)i’EΨίcόψγdβοxeΦM7·gžψ―;Ώσ­Gφνβ)ό>ςן"S‹ίΧkgEf:dOKœΘΔ4K₯ςœ¦k­uNϊ/ν-ήw‹σHΒζ•^[«2ζrϊ –E½°‰ζΏr”£’φλΫ&θΫ«γψσΟ9žκQ%yzT©½ΨT*§™€rbT«‡€ˆI2ήk5μP|UΪσ³Ÿέ wιΟdχ―§ —\ςΧQ­0ΈΧ8"π \xUžόS.ž>P…Ρ Ρ¨¨Ψς,Ϊkͺ"!¨Y–Q{ηΞΟv7=8»LCkρ»n€Ϋ*O{ϊΖx|쌨Ρψ₯Έ^?7ΧO6I2a*•Δ$ ’1}'8Ή‡--©!±κ“ΧʜέΒU=²™=€€8z°oH$ Zξuς>¨Γ`H³b0Έ„ΆvU9%=‡Αs­3Ό'7ͺ$υI<ΪΏf…πά†4’£ƒ£3GΝώbx`Ζ_ˆξ2θχ²Ω¦…>dΐ¦Qκe€`ΐhu­„H·h0ψ M@dΣx$lr& †&Ω%φnΞ= x)μ8 .Η‘ΖΉ3)·’ΰO] £ͺβVΐv ΅ˆ£bm#Σ["τ>Š…λ±Wψ£R'©%tˆΐ₯[ςv€οαΨϊˆ¦³ςΞ˜QXuBmνڏb;VJΪ»§½ΓΠT`ƒŽRε:Oΰ{š5wmJΌ]JG)J[‰„$ ³²N'kνΨωΏΧg”ΝΝ휿ε–+ͺΟxΖ‰Q£ώ Ηg'±ΧDυϊΣ£zmUT­WqΰGh ΘθUθ!ω…ΐ€IƒœB0j[5”V!FrΑΧVzίΆ’ΞŸΤτUω6 Bhˆ *Wlτ+ϊύΘΚκήFQΌδV₯1 Ύ›§ ²~Hφ~Ί{€ ΞƒΑBβŠδ°λϋ` eŽ₯δDˆΐ 7ΑCƒe† PD0H1b•ΎQ'°%Πa€iΈžΰ…k7™`²Δ”;‡”Ž)ΨwyXͺ{RLΛNα„D‘ΗPŽe1GH&{(h‰Ši0Ψ‰Τ\b›qž>~ΝW;§ό?=ΠƒoKΖ/*žΏaν?~d…ώ p(3‰B†ό5$7|ˆλυF2>ώ‘γίς–λ6}ζ3;Π¬aηΎήQΖ[#ΊΞsΑΉ: ύ±„τšυλ|\ΉοΚDγΟΠ`Δ cLCmνΪ)MOμΞΟΏκψK/}e鏻νΦv|ν ϋΕΖχΌηUq£ρa“$‰Ύγ:+ˆ”›^Χ»©SΠΒ9d:κ–Θ,MηχξΚ0i΄d¬‡ϊΐ“‘ςk…Ό}W²ί5f ΏΚQŽH:„AΒtZή…3V½βI<>φ?“ΙΙΊW aρτyΈζͺ$-ƒ²–‘πΣbΩύ`„i% †&₯Πέ=ϋΘμπηGΒ3kίwίΨΧEΗώ«ΚͺυO‰'V^ύR\―Υj“¦V‹MΣ³.;^ŒO¬ͺ’kΒ `Ε)ΰμFX!ω&D#%,ˆ'*θ΄(P₯"₯ΫΕE!}3³")δ_Η€Ÿ™Ψ1{"‚ήόόZ˜ΞΛΌ‚<μE€-!h”e; ΤυΠΕή b琹Ψ@ Ω§WoΏ΅rίM’Ξ.v}sύ—­ΓίAZk\ψ–  J„°Ό- τ²GR ‡ DGδώμεω(§«ύΕνJΘc―].$Ψ CΐιrϊΉBD6x‹:ΤΙνρΪώΣΡ ΫDΘ8A§Œh΅\ΖύψΡlΓΕΏ9ͺT6IbΒh)[)Κ‘L&’R'ΙΟYέήK\[&°λyΐ•ΝΘ*““g5·o]ψέΊΉπ―ΓΖXp| €Deξ²Ϊ/4ρRG¨e6θ »΄ρρ Α2`έg 1P™œœ¨¬Xq~Φλžožxββ _όSΈ5k΅Ώ:ύOΧΞ‰ϋΔ‰οzΧωρΨΨίGΥκ:·δΔ / ζ„F€„Ύ ΈΧΐo†DXφ #’¨ΫέΊwΐh0qQ1NȍŽ£laΫbIΈΙ΅φ‘]e₯%TŽ£f˜8ώ…ΪΊu—κM„•/“LΤL0·`Μ!±ιJͺNΏκ IDATΆ‹LbHlΔ~ξHlς―ƒάŽ^ zνvwqjϋo/K§fφ4§fξ€;ΰŽ=η¬W˜FνόΈ1φšxl씨Z­GΥͺc  άa†œ> i°Η»·Τ@Τ8–ΟJ€€VŒlwZ1δτš€3*ˆδηJhΥδmšE λ‹ˆΔσV?νqΤK!λφΞ^ρ—nΫσӟφ†aΒE6Π»/! Ρ‚λςUq½oΒγ«(Šͺ9tkА{κΖ₯¨ θ΄­ε‘&bυ'ώ#—W"φO—ŽΘWŒΔ~h`;Ήωˆί€ν%@j|’ZŸ[RΈ©"«$%λτUŽe6ζΛ` ’Σΐσς+žψqQό\+F°Ew»c°ξ‘Žu&Ϋz‹ “¬˜όb˜“ίͺ I…¬…½žOušNАφλ(Ζ¨ΡΈδΨ /όΒΤΧΎvΧώίW ˜ΉDΎόξ¨\]‹9‹θX Ό.…+ΈίπήφΆ“L’TCΰˆ#‹υj’ ΤΧσ,JΣg₯νφ»:»gί±αβ‹@?‡^φ©―}uζpί'Nzχ»Χgέξ%•+>nͺΥIΖ%†"ΕDζΣΛΊϊϊ@ƒί ZΗH]CD(fšk°ΚΞ DΘ”z­Φ•{Ήϊ―ΰλzvVˆΛΩήΔt94{;θ–­lε(€r-cυ«^΅2™˜ψDάh ΞSf/ο+•ψ•‹Β=“@ΧΒσCν»}6 9[vιΨΙΨ%²#Ρή„  4ƒΞΜ·ξΉα†^Α8}ν΅Gς.ί^Έγφ€MN>ω“EkΗσί«““η›$™ŒΗΖΐ(* bυ†Ά›ΩΏσͺ9±A-—εtΞx€_Ώu ±μ-ˆ%™OαΖ…°’‰ œΒw ε{ :0†RΚR@„ηD•κ·`ˆυ0ΐi6 ά7b»θλv€ψΑ<”xΩ@΄ΐ "2€Η‰…δΠOΏν-ί,‚J9=VΉŸ/+―ŠƒΘ› MPbž˜ SςΚ!'μ‹+™)ΡMΌpν G6X€!:@ Ράϊp³ €R:IqAaΠ€Θ–•r,/°1@ΊΝ—οΎh.²>a,Άά$d{‹¬κKΫr‹—YYώΡΟ}>€n|Ο{~5Վ ³n Ζ„°#ϊdsΓ€P˜υ>νs¨N<±Υνώ)Όφ@LέnΉ‘ήΙΔΫZυοδ@JχkΘ°8Qϊ@°­™ϋeΟL’Ο ’AζP™;  f1i…(‚ΈΡ¨ΔΖK³4}iΪj΅:³»o>φ’‹CcΆd½ήOΆύλ‡UϋΫ oyΛTͺΏ@ˆ©­_ Œc S‡#-‹ΪΞH'YhΆΟQU6‚:jπ8¬½νΚ+?»τιLΨΕ‘†ώ`Β!―>£b΄{EEΕlΕ:’’Ÿς#»PŽr”@R9ŽΐaL’Ό±ΊvνΩΆ‚€Ψ@.·χ­€d‚Α”Ενβ\ΘΡ’Γ•Ύ’Kjλ‚ΙLθ“<λ賑šΝω=?πV€#Dw£»iΣ4LwzθνsΥ±Όΰ7γzύ—’±±gΗcγ«L51h’AS:χ ”­†aβΠ€«^ͺ6ΒBZEͺZ€‰IΔΓτατw§σAV䑇РαΦ.ΔD#§@•ˆ2›bdέn?85ζtDzNτ΅ΓˆU΄8°' ’ν=wνX²ώ+ )ž*:ωΘj@!` ”Ο³ώ]Ν ’1’ΤL(ΏBΞmΛΝ°±C|‘ώ_š2"‚l Ι=žϋί 2H@θΡψ[c‚|δ±C EΞ/γh²2yΜ/?£jƒ*A€ε7OόL‰;!†μκΓΎcHΚɍ1› χ%°l΅\VN“υoΌΐτζφΌ5J’bdΌωξL?ŠbΆo2ΌΦ‡y₯3"‘Ύwo­i’zύ₯Η\tαΕΫ―ωΪΥϋυf J†σολΡ\°Έ΄γZeΙ2(i ³"?‘Šζp?‹m'γηCήfgυšPλ€ΫωX"k˜’τZQoq i›ͺpo„Α(PFΝνέˆjΎj£θRΨ0…Kα“Κ}HItΨWrtL$ωD•ΚӎΉΰ Ώ QΌJη/82s½†‚WΟζΊe˜EΫTΕ]λHi‹:Φ‰β©ι``y ΄¨Œκυιώ珖@R9ΚqΥ―~υι•ΙΙOšju“2Ή-W*J¬™›$˜9#)ί¨‘ˆθΕZIž[VΎ‘Ϋ£φΜΜwΫχώ|ΣQώΈΫ 7ίόΐ'jΟ~φ‹’zύ΅Υ5«ί™4kM₯Z1IŒ€‘8\fy@Ί–Β<`Μn;xeˆ”  Θ,TxΙ H\žUyI–,J€₯"Ή|};|ΑSπWQ)±lηIQ3ΨλΠϊ7V=€¨¨£N$―ΐ&‹Ί½Mόc/CΐqϊλΧfπ£οxΗMρΨψ ­A‚– tΏž8ΖH°\”ψτN/jL ΐΌv‘'γY§σ ΐ5]€ύ§Jύ#t j „°Œ2»”Ί—aΙ%’ =BXPg/Η ο|η©&Ž#T­ΎˆαπΖΔtrΠ0΅Ί©o¨=·NΗ<—z)τZ­fwnξφυoxΓΧMΟ’1³°‹²l[w~~σΜwΏΫ>˜sϋΔwΎ³@§₯ξΙi«΅ωουc}&ή©AΨ"π (Τ2μΈ Ωpο:[œ%›;lωYY§³io'΄e1“f©H₯^œ― J q7‚ΟρΜύV΄‘M}ύΊυυλώΤ~až"οαtAwι@’bΤkέ¨!@’n‘·{#’x 2TpFRδW@’υΞ2˜(€r”γpγηœS‹k΅Λͺ«W―αA! έΘ9ό¨j#†ε.xτBΕ’°9’₯%^Θ3˜γθ;A―Ωά±λ_ώεMε·#kέ}χυpγΐΧΟxΞΥ΅«;ΧOΙ¨V‹Π­ω !G<‘\€ͺX b•ͺ’hέΒ§]£ΧŽŽ>,bΩΜΔ‰΅sQΑA,+mžG!Ν‘\άάΔ1@?•²¬:2ωc(©6M'Y*ο˜~de©=Zα‚+˜…ŠΩBž ΉΥ'"0‰2 2’ψ= ΐΤ€aΠP†”“‰‘(ΛΥ“ς+6}π)SΙ ‚Ι―K †„@ΩV4ΡΝD/Z{Q²|%Γ(ς½ϋzlΦΚΜOXΠ‡¨2Ιr,+ ” .(}’`S$„#OσΆ%―ΕVPάχ—1ΪΈυΚ+_tβ―z3Š“ZΘαξΚΦφ/ξΨB΅ΨΉΚ}œ”†•6¦™δ >'™˜8cέ›/ωδc_ώΗίΪ―³Ζ#ά’ΏπΆXΐ?hοξ6–zΚ\”˜ž΄ Iο?fέξΥ€€ΔXŽ9PεY¨d{0‘fj¨}*?€IιvS(3Q%. šΣX‘|§-€΄έξ΅vξότ©εPη4΄šwέquΰ«΅g=λ…ΙΔŠΛ’ρ±σ“+NŠj΅ΔΔ‰ΠρΦQη"κπεͺ φ„ͺάx’ ƒ;ovΚcK ώ8αG,kίA<|ρmua΅šΑœc0I€8Φέ³g#μ„EKη6ΰv"–ΝνEε’θhέB¬π8ΏKθ„vQχτ‘jmSB–γ}“ͺ`Nβ’,gΏY¬ŠΫΡνΡ棆c“y Κσ5ΩΞh]έλΒLNRš$ϋt ΰΣ„˜Κ±\&‰8lϝΟ+ΙδΡsΓdVρnogΕΖPZξέΉωΛΝͺ•΄ΧύΟ D>@§PzDΝ@BCΖ υ‚L΄¬²­ Θ&‘¦R1&©όΪΪ_όΕ?ήωέοξ'αη0mƒ*@ šrpΫ7Ω‘Ξ½yΒ Bΰ>^UXέϋσΒΠΗauΆZΐ e«ώn\‹ Mh"0•J5?­Άnέiωί@–υR@]Θ¨™₯ι<υz3Y/έ•u:3ΤK§hΗ±^Έ›²¬•u{ ”b’¬4I2™΅;« ΛΦ›Jυδ¨Zέ`’h₯©$“Ε"‹ $τx]ΜF AΡΉ;.ƒΨ\θcεNΊΌ« έΎΓo-^I(MηΆ]yε§φr:‹ΗD€”σˆ†Αβ&Ω¦±9Ί…‹Η›}Δw­γΡc^όφb3%‰-‡λΘκ°ܜ¦ΰβ"₯€Π@φ¬Τrλ@<=R¬C Dνͺς‰N½t φΡNq.€rVcεyηΧλN&'#βΰd#Ɓΰ:$’GΜ i ¦Ί:.³Š’BkmA.€'<€,Λ ·ΈΈiζ{ίϋΝς‰ΠΪlgΆΕ&ώ­‚(žΦ―μ*σ ΈΌζνNL¬œΝΐΑIΆy ?ΨΦ) "e€έξƒϋTkaϋb6qHΖ|ΊΨΙMcΘS―dε;βΞ¨N;ΞM)DkχBk “ƒžD…‚δ!Əg₯HΏϋ{ΕD‚pK–K#ΆHH ύ`HΫ2Ghd ³˜œGwaΚ”aL9‡Q?㌀qζ™Ο?YσZξ?Β68Ώ(IkW~ΏάS=gKy–ύΌAFC,@υ R*ͺ1Z©»C5›ΝΦΤΤ.ŸϊήJ‡~ΈuΟ=ΧξωΙO^·λƟ3Ώe˕͝;¦{ si–φΤβΜω E„­θ’7γl ›"ψιΰξBT! }ΫrΛθAςΪΕ‘Έ6 ΰ5ξHηRΙτnFD·Ζ™¦R©„£ŸŒ΅O€o¦’ƒ½$S ^†C2Ξβs…φƒ΄ΡPŸ%”A_¦ƒ(wm£β%ΛZ€OφOΓh02Υ»α?–’ #Ϊ„Τ»θθ‘C#2qxή…lΡl0ߐΩkΆSˆυ„ϋ’a–γΐΞ pνg‚Ήi™i!Ε<‚ΓάΝƒŠBωώ9|ZV£5;{₯iW«< ²1Ζ@4šD2Δ΄σΈ„\K8 Ξ4BmŽC–0…ξ4€1‘yώ1^xΞώ!Eβ€(=j„>χXwΊn§΅€Ÿ/JԜΌ³|_F21ž˜$ωvޚΩgi’γβ±Ψ=8‰νͺ½ΰ{䃯ލ&Φπ9`*Ωθh°pΝ-Ώγ(gWΉV@’ΪŠCpΰ)!RT°šE!1[³€ΑZlCkHƒΩDκVΪjw» σέχέP½EΏc1U(F~ρ£_©?νb\ IL£58ŒάϋJbؐݔƒHœ!&9~!>kI{ΐΝΟΠ²δΩ΄p CB5τhνθX€:e,ΙΝ%TŽΓcDυϊ)γΔΨρ'Ό+ͺ՝M¦ΗΛ5m,xγϋx–οLTR3aΰΓ<ͺR]ižσ Λ‘‰ΨΦD¨tΛ€ΫƒΞόόέ³?ωΙί”O}ŸcΛfΊ}η=σ7άπΎΉϋοωόζΝmnέv[wvw‡z=+„μb&TΥΧfeht Œfο'Ι@%YΡ’pLΔ±GΠ• Wk%}€ςΧΘ–Ρ*ής‚aώΉ8²1Ο5I’>’E„ΐ+ͺ’žouΛ€,•άΉΠb %8„!ύ2bΏώi@”α K”ϊ-myS[5?ˆβΪQ@•eMuψΉ†1 "ξ6ˆ7F;ΞΥIΑ©£‘‚œ @aϋ„~B‚φ ²z+κ00πa%†΄ϊψo”O}ΏŒNwσζϋn½ν“Ν;/›ί²ωύσ<ςφ]σi§ ”edxŠΆw…jWΪHο¦Σ"ψ‘Ή‰ACCΝntAzq£ξΪ|΄Ν s ΖqΈ :cί%™Q§ Ρ³νΖΌν Β’ „Ž9ΐΑ5<½νMΔ6μώPΐ€C" ΰΡyL¬ΪŒ83ύBƒnΆmU΅iY³u')#…¬–‘WΖΠχ!@ΰΉqx”8ΕΜ!Θ‹έΙζΟ²Œz­ξΝϋxBB‘t‹% €1 x ΒθγV~Ύ*<’λπγ¬#Ξ "Ϋφεγ*@πPw„ Zm¦‚ΰ3ͺ΅aΫ•Iν=ĘNΚΌΕ7΄Dgv£ 2V)‰H*Ηa1jO}φΣ+cγ—VV¬[”šΤ¦,Rφ‘ΰ%Έ\$˜ςjCΧ½½Λξ±θ™r„œBΒΘΏ«CΛ΄έ¦ΞμξŸΞίrӍεSί―#mί{Ο}έ]O|‘9½ύύσlzγό#›lNOoοΝΝ₯ΤKmO:ONϋ炦€.K~ΌΆ6Ηu!œ@˜φλ%Υƒώn+ˆaΚp($Ι©½$θήB{ΪӏρΒ‘dγUQG…y9ώ1ή»’'3ή&½EDύŠΰ3%zιΐδ~#‡e#±" Κ)’Œ‰Gκ›ΩΧ;ΒVfψY8χΏ”±άΒόΪ² •Χ4m˜zΟ‹kvP ίως5GŽ„L―‚ΦA 2BΥΆ?F_+B<%R!ΔTŽC %‘+Μ83 t‚ϋŠBbΫ€Τή§ eUύxMO–ZrGwχξ―R–₯ϊεa” ιŠj<nΊs/o+QΪfzΛΔ|£bΕ²č±j\oόΏϋœΦˆl‘ήΜ-0‚*€Έ$9˜ |֎E!ΰmί’ΛΚͺUυ¨^ϋŠΒ€œ©ΐ ³y3‚Œ[ef-ΫΟ…'ζNtΨCΌϋλώ‡ΜΪ˜Ζ‘_QHΣΙj&κΘ™ΧΌQΌ' |g^…“ƒ‡Ι»HΛ|ύδϋ’ΫΆN΄ž!dΝΕιΝϋ™ Ÿ Ύ^<‡>a‚HAΞΚ”$ bνeω#&{ΆΫ—]ιt₯Dαά½ Ζf$u™΄#›·ηΪN] U€°\ptΐ!'%ўBή‡š•™Γ^„¬ ‹R„³ƒ’’Κq΄F˜ΥU+/HV8“ˆ₯šκΐ΄’E\υŽΐw)lό<Π[6§e’{K²!6'Ό xmLzσC.“eΠ[ln½ρ†ΛΚG~`RΩξ–M½ξƒmΗ8ώ·ξξέΏΧ|μ±KόςζτφG;»g»Y/hB χsΜNόΪjΐ‚Ε0ΘΑ©…tξ|bΦͺdzΫg¦Άχ³pπα ƒψ± 1Šγ—Ψͺ<δ? ΗFυŽ?L]‚…Κ.Qͺ(šΛ ˆxM――EeΠ €AΗ0ΫτX,Λlϊ  – •οFfΠ"‡} +€Ηθζλ›/ƒ£Cv[ ϋ1!A%WΏVΙ‰m‡bh?±¬υg²›εX.3ΐo»!©m(χU_iχ‰ω㐀M΄ωŠ’Αα5ϋς—ίD»ŠvN»³YΦ')&­tστ<1YFŒ™Φ«ct₯Υ‡ρjS«=υΈ·ΌωφόXΠK₯@Eπ7}β C-,ŽaH’}^€ν½―s'ͺΥ1nŒ­χ€€™++_+š½ήSΙΊ*λxeΦ>όμ•λ’”Υ Z€ T›HLHml ‘5Lp㞌 ςχA‰PrPιD†Y?>ˆΠΎΚ₯)¬~UY―K½NηϋOj.‹2[:Ι₯/•»B£ΠΞ ζό˜£ΔφΞΦδπP;©ωhΑ*|Ίφe%\D$Α,Y³ψΟ‘,“¨ΟΊ$[t°±_‘°‘•W ―΅Π†DΊ2–(]ΫΚ±¬G㬳Έυ‹“ρ‰Ψχ’t‡%',`Α>a[ΣlΕcΠ;NΎT$zy+v˜κ¨ΙΈ’Žά*}π½ζbΪ™}β[½™™Ως©ΨѼ㎠Άΐφκ3žqOΪn3š›ϋ₯dqρ‚€Ρ8ƌ'&Š’ δύ›n‘:κ83-§Σ’OλXΕ“Ÿa2ͺ`Α¦ †ΙΗΑUž™ζ‘>~ΓΉ™‹ήL_i §%HНSLώ[ιΒDΰk‰{Œ!0)όΩ.ΐ\]jP=³ άΨ™βƒ‘ƒy+–‚ FBΥ'OϊdD`ίŽu‚Γ0€q4†4Μ* ₯ΥΈΫΘΥB Υ! H]k9nέjAΌ YV—ːEhΏ΅‚;¦" οŠΚ‚z@•,Έ½[ϋgaCΝ*ν°μMΫΌ‘u»Ώcώ£¨*/έ~lEcωύ’πΧL!&zΛWήhδΔƒ|ΕσjΥ(λT |lΏΞΫ{y˜„χ(5OΒψ”•¬@χ~°>ξ-—Δν]».¨­[gΟI’€S©vωeg¬ΫK₯Γoέ΄μ¦όgΌ%T―•’ή{βOΜ­Υσ +ΌΌΓ‰Η βΙ^FfŠ‹€½<šH}_¦΅˜3ΣD±h~qZΙζ&sτZ­­[>ϋ·—>@€ΐ€ρcJrm$6ζHF,ΖW·Ο‡x@ɊxΉη,q–"wXΥΫ-*•bψ‘ΔYL’­όg$ˆ€ΪBΆboςαTλ8G<Βτ§΄fΧI΄Ž'(Ύ+x»ΫΡ;JFR9–σ¨Χ9ζΧ+«VŸa’ΨΡ4ΉR"ωme…Τβ9ΪLΘ„‹“Wς9%~IΛ§R%”i²X+cŽcgiz‹‹wοϊΧο~ |δw΄ο»oͺ·{χwZΫ·tρ±Η.Y˜šϊ›v#ό IDATΞΝӽΉΉtp ±` C–ΆY!(Ÿyπ†δœ.ΤΤtΒ„ΚgΑΛA%Y―:ŠΰžΥ²0„n;Γ¨( ‘u«Ω%·5@Φ’Ϊ—‘¬\=ύUψΒȁzJKžBχƒ³$ˆƒ%δAϊ›«Φ"Α°gbH ™kή‘Ϊ£D!”―αEYχX|±%nφ‰lΰXœΓ~xά†|z:Χ΅a0¨ˆsBKu:YΦnc’ΪU‘ 9P Υ9Ι[½΄T7‘jωf.·ž«2©‘πBlΈCσŠXpˆΰΙ―«–IΏeΡͺΫΊ5Ιυɏ?4>JA JΉΔbX΅ žτΚ/€r”γ€Ž/zΡ/$γΏšŒ™~Β6θΧ%~L2ΪP€Κϊ$G •qd+“·Ήƒ.n«pΨΖΩήLleο{―Ωjvž˜½ͺ|β‡ftz(ν>ςΘlλž{ooίώGσ›7_°πΨcŸnnŸήήΫ³§ΧεΦmΏ–| ‰9D9ψΗuC‡‚†€Ε€$Ξ9PΫ@rx@ΓΑΣ¬Χ}ξΨ _P ­-§M£λˆƒͺ*Qΐ!Δ΅TιΰρΏd―§αQ*6 ϋΝΰn"$΅QhPfψΩ…:ΆuMΤŒ₯b₯ƒkr%1=L@wΡQtΨ64δBΆ!Ϋpί?%o7{ΉZt–‰€΅1I†΅€7ςr, ‰‹lې “5 •4cp6q οΨΆ&ΉC'JIΆΓldέξλ²^oΦκδρΚ>‘N‚^|ƒυ„Π=1α3·ƒΨΒώw0I‚&Š_δκ!ώπ(5_ÁB" p“dΐ°–„'οΦ–Œgq½Ύ2Ώ&ίρ]&βΪ}”vN΅hη8Q„!ί!,dΘ5­G3Y ‹Έ6ψ‰HŽ‰Ζ€4ΈΠ‚7ِI.°v0{ΩKωΥ£[„h»':€%NFΊ ΰ»a²θτ€€% W#@©9 τ%πͺž3·@mΖa—;ο·/jμ+€r”cΉρκκ5ο­¬œ<Ύo„¬Χ•ˆCΠΈ-Ιι/8½%ΉΙ+δ4Β$f ͺΌŸž€αT/4@½.€σσ7Μ|ϋ^>ςC?Ί›6ΝΆοΏΖφΞΈ°uλέφ‰…mΫξνξޝRš:[Q` :΄δάPΒΡ>&%]Εb²l@#’˜—’ψŒ2p&AΓ₯ώυα3“j½Zx;iVfρwΚ-vΉ¨5±φ₯ΰΧ€@`{ϊΙcdIKoF―—T%u‘‘ΤΧ@bΈXŠ€<…RφΉ}aο[bcξ xΙΊ“Žšυ$β^DλˆΈυΈΙγ±yA‘«h£ιE!x9J€ΛbΞπvΈ£άc1œ2ϋΒ9ΗM*ψ5αέόΈυŠ+ξΚ~.ΘePƒΘ΄ΙY—‡m@ΙjGk™"B6θE²Fγ1[υΡψΨκ.»μ_Ÿάl)F'έWѐ ERœ·Κ Aύ±lxΛ₯΅ΦΤφY>οΦεξ¦$ Qκ¬%νeά‹}λ½ΦٍεΎ¨‚ŒΉ‘PnˆNΈ˜ΗFĝυx»Ϋ(")Ώ!tˆλ11@Ή֐§©Ωgy››ί‘ΆΪ―‹—ΨƁ`rR–OΝΝ†žk»<Φϋ ΗΙ]ζd—Ÿ›Cj½{ηl`qœ˜xϋ’ή‰baΑ:ΎšcγDžv™Cζ4ςš{‹Cμ|ο @l_†n”IεX–cΕK_ϊΚh|μό¨Vs‚vΐΪ\Ω²@‰>θτaPΩD’ΧΛοϊaΉ,O2©Π’Ε¦-X+ΕΡυ» ‹;ΪO<ρΏΛ'ΎΜ₯‡ž€ΰτΣξLΫνNχμyM<>vAuΥκ3M£™(vΥ>M8xBδ«’Ά>->Js9.ψP ²"ρΉƒωŽd’B™βŸθΟwvz? ’€R˜ψ1½/δ²€y ˆRR’¬SNA•1¬SI––p¨ ή uϊ#€ΐXΘ‰±˜7ΈΟ~'αΊηαH,ŒE °Eτ(άΪM{θ(cΒ \1)¬'‚Γ’Q₯Eαήνοω֊–K&‰–c9HφΉ’ΆmγGΈs€X‘ŸΗLΔ—ΝTm4‡3s0λu_ €χ›$9Nέ$¦=‹πJηېk”K_ ‚Έ“·:όl‘οΚƒϋn ˜8~Ɂ›Mƒ+8EΞ¬>ς"μΰ™]81υ=}ΉϋP―Ϊͺ“@π{ή>‡,–$Η„²]ЍSσ0_•t42}c ͺ=β°… Q σSΆΐB)h1o DjέCΡeE§pF©XDμωL'ΘΕω|.c@‹½–ΛŒgνvšΆZ—oΉβŠMϋeƐQ2=ΔBΕ<¦#v)’­›; ‹°N?;@Ζ“9cΒ©ŸΦ$Ÿ * Mχθω“ŽιϊΌH…c*MΫΞ©BnP‘xp:sςCΩζF:₯²•­h”Œ€r,ΗQ­¬ZυώκΚ•«ye (4,@U™CYMΰRq‚Ξ|n‘ΐΟ%IώDΘͺ;Η€ΘΪmθΞΝύΫμ~τνς‘/ΟΡΎη Ν;οΌqqjκOΪOΜ~`qjκsΝ©©ϋ»σs)υΊ°M?,κ‘Πβ‡ΠΡmP‘g"ςκˆc:ϋ\.R‰V†‚Šτ~ΥZ~ŒΜ‰QΕCOx’‘ψ6 ΅όn}ΖΌ θ8’ηκTd»X@€Lω€ρ|Q²σέUhS r7FάqΗtB ›mCL€wΐ]’—hB¬Έ«SV bΟB’ƒΑ·eIsY"F+ Ή*•Αಜ"lsbTϋiΘDoˁi.˜3/ Š΅zίχΨf(u˜|ˆ½%TŽe;VΏς•οͺLŒΏάTNͺžZrjΓ“`p3ΛwBΦ ΨdΞ`ι yšχ0šΒΫ Kv2θΞΟ?Ψ~bζcε_ώ#{τΡNσΞ;olοΪυν]3hmŸώrkΗΞGΣVKWΔUPτ4! bε ρ*=,j$a–~RΙ(d])D)S τRΆ1λ! I6U‚Œ. [Ω•bΧΘ„3Ρ±’YQΈNDΘ{Y;pΚ9Lύ–4†”±db ¨δ,aΘsΰŒqlƒwΞ΄@’W?€ψ1>Nπ’£LdΫπ(\2ƒŒ!ΥΊ$[BjJNΑ΅Α‰†β ­}@UŽe0e˜Ά Ι]Uκb ŸˆŽΦ~“Ν§dχΚ[KΰπΟ:{φ|0KΣMje댬v‰4QέQ’šΜ.Ι«>8Ώ‚:DL’€Ζ0•Κ a]½±χΫ ²zέ₯ηŸ‰ μΦΠ%½φ/n’;½μCβ7{‡?wι₯+šΣΫ/Όx‡¬¬Q‹ΌDRον}θ$«τ MεIΔΉa4 „}±ΚX±o1υμε"ke³$η 1aT§>YΰΣ•Œ°€…JΚΤ†ΘΆ PFΠ›ŸΏLτΊύzTΊhDaxd ‚¨XVΒΩΞΖ-$Σ¦\:ΐλ&C― 6”½„Δψ=Ψ– R#1=,Ήfdu‡$~τq'vc­‘u}%Φ²ˆD(ψΟΘσ…­έ§ζI5”@R9ƒ­Z΅1Y±βύ•“u„!‡@ΈRBΌΪ·τέΒm=§Χ|.yn \¬N›¬Ϋj"d­uηζέσ³λο-Ÿϊa ?πΐζwό ΅sΧo·vξόΘΒ£~»΅sΗlΦνϊs‘œ)|ΝαrAΐzΐ•`6²ͺr‘Q!gEρ^r‘S¬·£€ίG«L%1E ͺˆƒ―.‹#u”A'ˆ6VΉ±±ΞύˆŒΕ„jl!6ψΗ©° „3rΪ7 ώάι§H^"y@$pέq΄#Β‰94Ο€l% j €πpΛ[u  άΞ)ά ]ŽCˆ5z§4©Κ8Ι4Ω\Boρ+w*/)σ?½LόπN¦Ύς•₯½OQ―·(ˆ{L •“c(kƒ‘/!€Φ0“Μ(FυςΦg0Υjuγk/ώξ>Ν /g©›‡Θ΅UHψ₯xΤUΏN©ά°σq_ΌΫ*γ«£±±w©ŒYIςΜ›@W„—lL “Π²F5?šŸ2Ž‘0(θ<¬eY;aΡrζ"Ϋ$XΥyasMH*ˆέ…>²΅ξbbο~g‚ηΖEΘάΞ87Y%DН›{8M»―ίτιOνή_kVšq—3QYc‚Ψ²RηLˆ{3/άh$…ΐ΅ρs—Άs†β<ΡRG"Ζτœς,‰jQιŠg ­˜3šsθ‡λΞ3€ΟιΡJ /αΔο5ŸΚ¨Ώ3I‡<΅ U€/€r”γPΙΌΰέɊΟΒ8’UM—Υ…tg¬½@ιΌa8π¦=KΚ#‰€ΌH>Ξ΅#PšBgϞ›¦§?Q>ρΓPϊϟo_όψ‡φcΏsqjκ·ΆmϋYϋ‰'{ν6eςŒ$΄°yˆδœ\› …p)1FδΓΪ’Θ6΄σYŽ.£ϊ<ΕΕ,NU₯Ζ†ΦP@ΦbJ‚U€ P΅$εFα„4ΌMδΚ Q6 0 ˆ ΧK₯‡d«zv0 $e  ί;~ΰΌυ'₯«…¬œ…言PE‹°CbΙ›ΥHROUΌ?• ²œ!|§ΰY Iš%If±ά/ ι’@Š@Θπ>qdԝ·| ΚœQ›˜…» 0zΏC›x †ο#Q"‹:ϋGƒL­φ‚Ισ_ΉaŸfNQkΠΎaσΒRNr …ΘRŽ­EŽύ˜$,ΪΙ²)φfφDυjW’ͺ½τοΏ$&»φ&d;7αΗΉf$hΠK# FθT°v$…Fέv ΄>ΐUΤ,.ΰ{6PΖ‚ΈeQηΙ|ƒύ ΕO±;?xΪlΎmΛg/δΐ λ¬ε”a$ t¨ο΄{Άδ\ϊμ\ ,lb DR1Sߐf₯WIπˆa,3/©ƒΒYΜθά­;r‰5m’d¬r£H †‹9‘a–¬HX8ωi ]Iε(Η²λ*cc—Ζccq‘'g0udtOή’‚¬†e˜!yAO>[v…6Ζ&NεUΈ°` ]XθtΎΠόχŸ.ωa(=ςπŽζάώw­ν½΅ωψΤ7§§οξμ™λfݎ’MλΘ½ι† 4,νX*A #―£bh t ͺʘPΉmδ?Ž"  zhˆΠ“H€²JR4q²‰D™I:Ιf‚‘η·_f-ۈ9 D6ΌΡŽavΘRκΏ4g±š~ΐm[αΤr‘`š’ρ€0ψ.(NϋHθ Ϊk„…±mOβ.œTœ —c βf44z#C:”VV‘F–χDGޘB–ύ?Y·;c›‹‰ιΛ „ώ‰#r£”τŠP”H*ΙOΘςŠ.œ‹&©$ΗŸπ΅½›2δι$…ώΖ˜(ΫaηKΫ…(―”Σ9φrœπφ·―ιΜξώ€hΛ-ιA&ό.”@6E™Π5τhF$ΊΔbe~šΩ5μλ‘4kΚ‡_/ί Δp!A‰)±ΆA”Δ σC†ώSΝο#y}™Ϊ…7·ήΒβLoaαΏmωόηo8ϋOhhΞ£ΦJv ¦S(ŸQXώƒ<:pm€ZW1€>ζ-)/>±ξδš_L.tξ •[πan³ †l-₯nR $•c5ΏόΛKVNžŠQδo ΓΤ/…‚& Ρ@Kadt]Ύ©KPΒaHώΚξΪ6Cε$γW:‰1˜²^:σσ:σΣ›(Ÿψ(ύηC[n»υOΪ;w~¨΅cΗ?΄¦wlξ-Μ§”¦κT“daΤ4r”ŒoΧ† mJ=!(’ΡUNA52±B‰*‰¬=m·OΓ5k’Βΰ'L1ЊΔϋά­έτ€Q"œS8dˆ-Bž”@vΌ3_βj}΄@y™fJ œ xI”ΐχpΐ/¬ΚQ p±cζp ‘I¬P€j.“‚ψΫ“4:πR‚ΉmYΞΧά" .BΙ*} ±ώu?ν¨ξxDL‘Ν—ξ”e·‹ϋμU( ΄¬χuήώγώιΔl΅θ/ψΰΝ›34q£ρό5Ώψš§ννurο°₯Jκz |1€φ‘l›D¦{ˆˆςμ^ˆuΙΨΨ›œΨ3ϊ†|~ψz3 ‚b#šΓΥί#ˆg;fω›‹Ά7Α5 ,βZ{ηBGν“Ί₯ŠΊ‰†/ϊAk£dΠk“σ— ;?Ώ΅;·ϋ][―ΌςΪΉ"(Τ²δί?f…ObgYv_εΊ³ήU©x)ή‡₯n[ΰo=ν>‰C :«ΕΕ΅`"a`c± –Fz(€r”』‰η?•+.Žjuά…‚WΜ74ίψ°85υφΝ3i«E”eΉΦE˜“=ψX―7£εz‘t00!%AƒΙ‡ΨΟΉ“ͺγSšž5~ΪiuύΑΒU= lΘ£#΄m¨d[νΰμ–yθ‰HRDΑSΝΗ@b•ˆQVr8‰”–,dΜΓyπk!¦”i~“2€>iύ,ƒt s―Τl εΒYτ Y[ΛF Ε&A*ˆ©8’-Η2ƒ•€ΜFY‚ΰ²ι=•χrP8Œ 5εHμƒGR”oώ0λt·y€ ͺ"ΰξ Θ™Dn[]“˜²Œ£ςSΐ$IΤ8ξΨΏ‡₯–ψh\ό² lύ«OΖ!InωΔFm­¨W1…Φ¦ŠΟE{)`YΐNjaΛ€Ώ¨¨lm+G9}ΆQ;ζ˜?ͺL¬XeŒδk$‰§‹VgΕΪ͈σFω@™ΦŒξ³¦(·:W³NΊsσ_ŸΉξΊo–όȝΨΩΌλΞo/άqΟ{·oHkzϋέΉ=¬ΣqŠΈ‚ΔΝ ͺDΚA§ ΊrΨΐ“¦ ϊκΩύനR©…Ύ/Š—R ΒΦε΄LΌφˆύ@hm¨@Kί#ζβ₯ατŽ ΰeΦχζš:xr"Υΰ•Qͺ ό€6υZ-²°Ξ’‚Π{8ΌεqI•qΎP½ω†K Λqθ§ΚόSΈažf{Νϋ:Jιo€|ΐ#©Άε³Ÿ½žή-@"ΪP”„_PˈA+TΎ"xlα‡z€ξhœ½φ΅―=k‰³f(!€tΤg…‹Yτη΅biC—šŠs”$3coRΙ o}ΛΊ¬Ϋω K㉀&vζJχͺ₯’ΕsAu”ψŠ:id’Ν’z2χώYYΠ!Ό3’ŸκΒΦΧΓΑ!ίUΗ$4­γt–BwnΟMέ…ω ·^yε tƒPωninBρ–„Φ0žΰ^Μβ@Λ’{¨ςΆ ₯*šŠ–Κa1p`ζbΈ }}‘5%\•I~6j—ε%η uψJ ©ε8„cεΛ^φ‹•+^n* "]RπpoΕ’ΐΠα)³i/Ύύ­©νמΩ5έk6‰ ‚vΏ: N$θ'ΗΞΨcDβŒΑΜ΅Ώ.;Ϊ•° š§`œHT$§€cε@ž˜“J βDΔΥ·0E#Ξρβ€K6•ς“ΑτA&αw‡ύ–ρ£k΄~ <ς{­ιι4·oqwχl+k΅²Lφθ[aAτςi{\ 83Β£`ΎχΉΞ€pC ~ |_cŽ7IœCIb8vˆ 73ΑzνΔξι‘aΡ’'Xz½.¨–6¨°ν…°π~ςšͺΠάͺ-ψ1<€`€”(χQ hςυƒY(ͺ_ŽΆD_:Ή‡Λ<=/ͺ‘`hšβežΕ8ΪW¬’713ηδχ½7_~ω]”₯WIΥλΊF ΪEF‡_$«ρ#œ˜\²‰ΚŽŸ} )ήκWΏκ™K ©@47„πIF£³τ". $“ /qΞl»κ »·~ώσŸμ5[—υ―ξ-.ξ‚«w‰ψƒR1p=…Βυl‘j υ’}Φ€AΖγΕ‘'zπNs­υ6ΘθώΌ“€ ΰBπFeΔDέΉΉ»{ζ>Ύυg7\΄ν W=ώ΄ίωƒ‚Pΐ0 ΙΓ„Ό7`|:†θ½ΈΠΣΡΥgΟ€kΩ ΣFyθ]ιυ₯tεš,Ψ°‰\ ‹»G‚IZͺτ(qΊ”γPŽΥ―<½•ΙΙ³L>ύι_ΰ Α?+φ€PΟ›˜ξUρJ“φ§κy#Tƒ’.πƒŸ1ΡiΆ°ΨΆ9xΝAQΦ>κ|HωΟΫ–ϋ˜Σ―F{σ€œ»ŸbΛ­£d$•㐍կyΝG“‰‰γ0ŠG l³SσͺF+άZ_…WD„ΣΟΎUKm$Μ~…ν"֞5·Ζ₯>‘=;ϋ@ΊΨϊ§ς‰—c0zΝ;οόn΄zυMΥ“NzsΪξόΧΚδŠgΔυF“Р׊)DYg«oDώrΜ‡œ[-h‚ά€7$XhΜfDμΏ-$Jϊ6ɜšnW.[Γξ‹£ΔŠ  f9€ZηΤvΓΘL.8ŠŠ§B@‚Aττv2ΐA ¦Oj"’ΎDω<}ge­63ΫjΑΛΦ?₯œι|ζA. τ½š2CΠ'’ˆgȜYힷΌaYF\f(R¨ͺμ‰ΌΥFΛΙηIaRNB€;i”"οa96φo  3Ώπ₯ΚψΨ/GQτΊΠ.‹(Χ¦'ά«\ˆ%γ9Γuθώ“ˆοΒr q|γΖ³ΰθƒSd>uRΔωΐΩ+AΥ95kngΫΊκ[π­ —\rfuŊ?"cžU«ΟΒ(ŠΠ24Θ•,Γ %Άi₯KŸ».΄ζq"—·ζ¦C_*ΨΆIΙAθB)ΰ–$ΫΔ2›ϋ;rP0οΑ= €Ÿ~šφ,ΘRθ6›› Χ»}η]wΎyώϊΪ‡~/,Μϊ MKΣχ@Pvkc ΩφH™Ί ˜Εnω„ϊD)°O‡=Ri2Ÿ”œ|7Ν ”Ε5ŽH„ΚΗ {χh( ΓM!Ί’ΚQŽ?"€‰ΪκΥΏU«fδVΙmB»ρΙ„‘‚²n(ΐ#EΕΦΒF;‘¬  ή`Pˆΐdτ?ώΔp_ωΤΛΑG:3³{qfζ3πε±sΞωύκδδ›“ΙΙ£Fέ 5ŸΉλ•km4eu6΅D°ΘN`ξγLh«"€oΐξΰ2a­ydΫMέΊνƒIθQ¨‘°/Θ²E ρ‚y€£υ}³οBٍ0Κ¦ƒXϊhrάΩLΚΈχ’¨³aΞο2ψΈc¦Ωƒ%γ%4 G+-]‰ννςaϊ|;—€jz½Lx8Tˆ:Λ*Η!HΪcΑζž2 C¨<ΞUŸ°„ΕW@―έλȚ#'Ύχ½Έες˟8ι½ούυzηa’Lδ‰Un1ΐ[§½FnVΛŎ}Θ‡΅―’{―<†CM©ν?₯κͺUΏi@Ύ/=©9»Νe'"Ξr1ͺu:m–=.œ€:Ά^Mαa ϋ­χ3ΰί 3δFʏιωΨ₯°ψϊγΘμ]Ν²=Ή‹ύv›L·οkΪC·«-ψƒ6YO·”ΔniBgi™sa$:vLEM·‘#G¬1KίgUΖφ›?όa€ϋ?όασόpψ1Ά:n’nβ€NDr~>ΕfΙΩΞ Ιš΄d·εͺ‘βwN§c\3–ιǎ R­>ζy7€ΡZa/jŒδ%W“Π X·1JP7=%xΞΠω³Ηxΰάsίsί>πΚΕmΫ~r8;ϋΩraα–jqq»••ΑgΥ…#ο'3‹­ζ¦0§γa眹%Ϊu΄‹εΊf9:–~£Ιʟ,+'κŠ έΟ\ύσ—9IœΞ¦c³±Ι ’ό`0(ηηξ.gηΎΌπψΦίϋΎaί‘ςs-ΦΏQ1ΉΑlΜ‡ΫΊyE©•Kiγ„έn5F'eΦΖΚ¨­q_._ίΠξ€ΦΊžΎγ-+7ΆΩq|EHη!Νά΄μσΛ&‹‘νσΩЍΣκsiŽYbŽ΅JΩˆΤ—UMsΧ‹Ι9’Δ^gν«Ξό‘Ιυλ–ύζ°`ΫΐrχQνb³΅¦Τ-}Yε˜p `Λsξ4ͺξΛ5¬ϋΆΊΓ6>5.TόΥmΏϊͺ{5κβΙ(οΉχ¦₯Ιιί¬ζηί6yΘϊߘX»φ79~QcΧ™ΛζΥϊγί^x/ύ@έήΊq~΄Kεj yσΛή£ ?„’Έφ±/q8rBWŸΐ€Ά¬PZψYΦ]%―έ·(έ0Ο5βθŸYΧ vΔ.Ι΅hΗΓ†s~6ΛΠΊΐ<θ\έsŽŽω)Šυjι·LW;¦\ψ ͺ )^Aΰ– •Ϊ΄9/cφΉ­ΓΫ[}έ™iΉΨoγt7(·m‘·μ—svZ·Od•’΄ο¨6ς‹uς±<¦ωPuÝΩtχΙ+5¬ΪoiέZk΅„ρg±Τθά—ͺ²όEΧλ­›Ζ!΅ΐΫiX0zήd%u~΅³τΙ ΞB˜™Ύ7’RηDύp]Φ~Xωδ'―Gt)ωΦ·žέ›Zρ{œμŸHW¬wE±6•Ώ%)0}Ώ[¦ΤζΖ"˝~­βψXΆjΠ₯£±³ΜytΗ-•{£s yΛ­‘ ˜Υ@΅JιmΤ‡ ˜―Ό/Λψκ‘r~α‚Νηžϋίχ—/Ξ–Ϋ–cDΤ<Ϋ¬U>Ϊώg€Γ:·n§Š1…uΩΒ‰°ε§b>ςΪδNιc{άFCΌ-Ÿ¨6κukŸ¦σŠNYd-j’-mccsLr}Ή}Z‹‘V‡Υ_4JHβieκ°Γ?ݟžž )š/λ\,ͺΥvf9'–ŸP²-ύ"gφ£Ε˜N–ΥΫ˜Γf·°όπΏ˜<δ+WŽqnΤΏΈ5ίb–P³γθ«_΄Ρσˆ“ύ°νvηπρ‡?υ©ΉΡz@XΪΑ₯ι„–l—•Φ_ΒΘΊ©e"-;γXwqʜ ™ΆΫύ—€aY垫Z±rI†Λ{ΐ†k>ΟDbSΜδ$:½ΑΰI1ϋ‰ζ=μ36k ^ψρϊ$·Βμ5'eIψlwέcΆ¦k— wZ)#%nDSΊ}κ–i!•}o˜ž}αΒ‚Ξγς@˜w˜ͺσP²γ ›Εe~žΐ\Y$GάΛσ]ίνώDζg'›?όα/ύŽwό9Šήϋ‹ OΪ1Κ:1F–―‹9nAšύ"-Šc«|;nξVsΜ+ϊύίΩρυ―/dϋι5‘YΝ—E'u ΛφίΜΏ σ’ZλΈ’‹–9DšRςgΞΣΊ:ϊם›Ο=χJWΐΑ?υ“Ο]yθ‘₯˜˜z# ·†Ξ­aΡ[ΎΜ8RζΤ]ΧΧGΔρ‰ΝϋΈ’ήk‡ŽfaΈΨŽ4θ80-Λ²–ΚΣΈnΞ—YSΪβ F‹$Gτr\+ά\EJbσ9hτΕ¬l―Ϋ¨eΜ1m{άt‹Ε27FΝ䦆¬8²Υ€€ 2’„Ψ{¬{έkrrέΑ'³Χk0˜΅6ΟΒτΨ cV Ϋύ1jδ$4ϋ’Οt©ΓΧ`»Ξ”Ω•š>VojN_|Ya87χgΫ.½TB’ψA)wήy΅›˜ψ_UοX»φ΅kΧb―—i5–ύJ—ΎΣΪΏΤΤ'η툯±.Ίnœt΄ΫΜψΈϋΘ'ΟίΎΜg«Κ‡Μϋu0xcΓp—;½΄‘εIψςžμυŠ©‰’C­[ρί#ω m ₯Ο=ϊˆω0#]-J³I_ΛK3υ §l„§"vmΰβ?+χI™‡Ρ… φUίσ —Λeτ³ οοΆα°of[­ύ²%_|g=ZC<Ύ~y™"3KΈΟ^λdΧΉ>Ϋ¬αΩ”F<βΛς‡ ω΄KWΊΏκpΟΎ‚ξ‘ΣΞω²άς,Wxo¨K7‘|Ύ™UM qΜ AΫΝΥϊ -•)wEώZΏε“k έπήnk„μμprέΑošΰΑΥj!ΙΜξρƒΑρŸλœ‡%Q‘Νƒuy-[ΑΛ‹aΛ5gV:3°(ϊ οxZEΐsΟm½Œm_Όπmΐ»υ#ήφΦ—“XLNžΖ^oΑi8N³( ‚Αf;26¦΄©#ΰcŒ"πδάDΟς˜ΊνγƒΗrνδG€―π‚ΰ+ο«j`ήWΥΦjaαs›?ς‘²ŸMΙ{ͺΑΰx:N΅μn#ŠΆšUΨH\{n…8ζΛΡv±Aη0lφδ [­-rGΊ5ƒmYS"ΆRνη΄ρ/£)• Tν―ΛΜ[άu6.Ɲ_dŸΉVs›”“ϊͺΰίαBμ=ΉŸΫ4}θaΗ±ηš†±'‡V—½ υ7&Ψ±{θΛ}kΚΰΜΨρ1qLΠZη„5δZuΓΦ9±²φ —όαάά9ΫΎς•Ϋ5κb7ŽΟΣΣ§Ÿφ[SλΧ~νAzSS™›§[’aέοΏ¬ά³}ΒΨηΙd„=`fηπχΞcΛCŸ>δ«χ°Ÿy>φ™Ομ1SοGξ½1ύΣ6ψe§πv82όΤΚ¦_†‹‚ GG:3οΠΡΕΚ6¦ΖΝ.ˆF€…kN«Ι`‘$8„Λ.(Yρwέπ{Ž)n°wώΐα r#νsLqIΟΟύ΅_γύθ>ϋώσΫΏΓϋ?ψgέηγyΏυΫΌοŸ>Έίώ»žχ;ΏΓϋ>°οŽΛαoy‹{τsŸΫ'ʜםυκ5+9φ½nrςgЉ‰ΓQΣ&œs 8W+9άΕ*1Ή–/.ωΐO₯kUσCV·ΩM&ό‘ΛYζŽ™YU•„ ½Ω’•Υ~0ψ:†Γ?»£½cό<χξwσžΏϋ;}νΗl|ο{yχ_ύΥ9†’Δ^cκΤSYyΔQŸYΉaΓkŠΣΞRΞKέ|WA‡£5ΉυwQν6j”γΖιΜN+HϋΦΉΕrœe:Ϋ.Ω€½a°m룃;ή:{ύW,έy§2SΔξŸΨxόK§Ÿ³α/'>ψ%ύ•+ϋμY·ΒŽ ›2²`Dζ]Ω0ϊ#lφλ±°ΐ_™ΩχΘ'>±Έ7ώρΟώ^ϋ+?€€=€·Da„ΉΪ™Dη’€‹ΣŠ(!– 9» ώx’Ž0cPŠœkb8 GρΠΰ±©Ž’ΐΉ(I;’Qhr$? ΰ―,&!I!Δ>Οo{Ϋ/Ί^ονΕδδΙEΏΏE1 ° Y(±1ηΪΩ4Φ5ΙXvŽΎœ‚4ζr§Ή>Ϋφρ ή{3Zc³ζ—|UmχΓα]~0ψλ>ϊΡK4ŠBHHΜRΕA―xΝo―<∿θ―[·’EΡary¨άΧφ±UΊ“gk4ΑmΦώε£# εlw Άρ k6Š4 ΝψΕ[xόρ4³χnύ—5πbα&O<ρΤbzϊ¦;μ]λ:¨(ϊΡ#“‡ ·Nɚ¬±Ό£0Ά]²C›|ΐ?<|ήyƒ½υϋΘ½7¦‹Ηψ’Η¨s.s‘v$YΜ)r„y—άD gΏ΅#[[·1V€(2£Ώ‹Ά₯Ζ}TοΣcΡμΗΡ[Μμ7wμΈο‘΅kΑŸ³QŸR!„ϋ5Ηόζoύί ηž_n=œ[ΊI’=ΖΤγ―vϋvΞ•ΫΆ#ζΡχyE»y<Μ—fΆˆΚΟyο· ͺτeωεΝηž{.ςrW!Δ>‡„$±WXυ—?}Ψs>=uψα§χ¦&™Χ±3 Tm΅·Ρ2vι%ͺλpY·³mξλ,°Η-£["’΅ZΖ4]ͺ­Υμ OϊπU…₯mΫ)ηζ~lλEέ•Lˆ=Όž˜zΑ ήά_½ϊSλ=»X1=α&ϊΘΫd4Ξ;΄.ηn€F‘?ΰ,‰«Μπώμ’Ξ;o―ΌΡήτXΰ§Όδ0Ό8sŒ%kα_gΉδ€¨,E7RΨ]0(9 GO†ΰά(‘.j!Ι`±δΜŸ/μΨ1l»Ζm$œπ10xγsδDB!„ Ϋ{e­Ψ›^ωK­=Ή˜šΘϊ“l΅af»j?‰LΌΙΊώγΒ ›Ώo–λξΡό¨’gΌ²kœŠB“•₯U‹ Ÿό&HD{˜u―=ŸΈμ²₯Εο}ο‚κΈγρΓαοφV¬xcoΥΚη““Σnrͺpύ~K΅ά4ΗΊ•³ΑΌ¬`ώ 7v! ώΘyηνυβΪτνtρ§ §Φ ‡ ƒα«έΐvγaͺx»KSΣy€>tιΧΠΌ%d–Ο₯l΅ΉXϊf080?R™t p€ΐΖž‚»oΊER!„Bq@ !I<ν¬<γŒγϊkΧόjoΕΚIΤ A«‹­σς²¦Έε½Z]žκ!έξ•i‘œ·dέeg‚΄SζšΞpΝ |μ›Πβ°ΐμœyΐεΏZιU&φ8O\vYr UΓ{ξΉόΙΔΖγ?Ϊ[Ήϊ,79ωr79q²›θo(ϊ‘θυUι*30άΛ^οWΈλιά&’χάcŸχ±ro›>tοwΒ?v˜Ο&p(€ͺ940T!» τ€ΰΪύ]š‘ήΜ\όΗ§žujώΘ”Ζff €k§θ{‚E<πΈxhς΅Θejΰί,ΐ…;_"’B!„8 $žnŠώκΥΏή_»φXφŠΤͺNλK’M½’³vώQ\%fξ4}BS Ϊ–¨du!vξ(JΑΓc%‘΄ŸΊϋDμέΞGJΟ•&QΞΞΛΩΉr’­—\¨šn±7Xά½ιΦp—[Ώώόώ‘λ7˜a…-,Έb͚“Љ‰£xs eQ<Π[1ύΨĚ΅Oτ¦&ζa>|ήǟρΆ₯%zαiN28ΟPξŒSπtp€ή,––Ρ,”±™kςŒ(:©Ε):’Β%i¨=Y°"z‘’˜Tg=€Gζc©›‹ϋu€U άM€]pΩ/ο_ώ&όΟίC}…B!Δ…„$ρ΄²ϊ―8ub͚·φ¦¦'Θ¬ΗTVΎ–υ’z ‘]Φ“š’ΈXπ–w’J ά–?Cڍ΅)f·#―B˜1³Nιι“3Κ—†ssχψΨ`vv Q{™‘ί²eϋ–-ΫλχΡG§"Γρ½(Œ½~ΉzγFΏυKξ3%—ɍΤCyŒ/8ΝXΦ–Ί+†)κIΈΌ΄,φb€ Ζ"ZΚGΚχΟ¬ŸbγbΤR=Ie&Λ€h&{S#,ΥήF’ΞφI‹―?.ώΤgυιB!„’ΔΣΖτ)§¬θ­Xρ›τά’>jyιZ½Β‹BMΛ™„vΜQΣjͺI=±ΤvΤZ[† "iέ§‹Ϋt:ŁM@1Ÿ*&¨dξ§vΰwΈT.. «……v½ή#3W^©l$ρŒSnή\h•ͺm½σŽ}ζυύΣ½7¦Y΄ ΐ+Λ θΔ€PB€8ι]sgͺˆe“V…₯zF3Νg懑p‡pςv8F™Ž΄”ž;ΔΕ‡ƒΐ•πl !„Bˆ§·@ν…ΣύΥ«ί=±zυ‘pEXžeE΄|ΑW/ςšΞhlbŽΪέΩb&RTwb”n½qκΛλu7U*Φy’ΖΦ`uΩZΨ-‰CS3 gg—Κ…ΕΊ^ρΠ– Ώ,7’OΒοω6ͺj fΆΑ‘/#pˆ>HΉ.Ή€Π”­₯ΠpηiήYέU ŒωFΘεc3eP,•cΚΫn„i΄"ϋ“8ύˆ†dD"Aσ—υ&WίΎ΄π~όΘ“4B!„β€EŽ$ρtΐήτŠS'Φ¬9§˜šrΜ Jb-`Ωβm΄΄-]ˆ9ΩΘ¬HΡ…ŸΜ·S(“kL»TvRQL§θeτΆέRι΅Tƒ ffΎA³ Ά\ψel ρ$|πžo0x_NΡΉWx~3Σ|wφΗΚzΊΞαΒbΘ­”k£™Ν¬ε!i;kβτ[‡ˆΆll€Tξͺ.ΝΐΉ«^§ΑB!„,r$‰=ΊΣ_tPΥͺχφW―9Δ9‡\ΦΙΓM²¦jΘ‚MZ«Ί¦Γ[vw νFŠΘ΅,cɘ•Β₯EaVVG#±[ΥRo²άΛI’0σ(η«₯₯ΏάvρΕ[4κB<5H‡’(Npa“ͺΰ,b’šΣB1a; ¬IIKw…ΩYKEΈ: ?zY{³ΒΧ4­[26-IQ Bα€}pΫΰΗ6œ AB!„4r$‰=Κτ OλχW―:grέΊ7ΣΣ^²±#ιpΌg(Ο( ΉΜ-IYέY\(FEͺιχ6’ͺάΙΤ\Ž[yHˆΛ^KžΤ8¬,­œ›½ή_QβΙ n$ΐ›­‡ω—v˜·ϊ`!HW ?l{ΡτbΛοNSΧ,Ώ±%…τ}#λϋ;]Ωj­)o)ΛΏ ΨEJDB!„’Δ¦˜š\Χ[΅κwϊkΦ¬vνφi:³Y“qT»„Ϊ)DΩ 0>˜“)ιA­Ξk»(b³,;4E7“0dωtLI›YέŽf(wΞmχKK³νK4κBμšάsCόΒq=―6ΰE}sνΌ"±Φ-ΞKσf>䬙ωva‘Ή”κ6lu)mŒ:ΚΣΒρĚ΄6r$†ίΜ›αϋ€}ΰ’FQ!„BΔσz!φ«Ξ8c’˜š:gbυκ—Έ~Ώ½$Μ.0U«Ρ4Tb+ ;¬ζ‚¨q%‘•˜lΟρ/¨QˆΪλΕVM]*yΛΆΡxšeMΖK~0»σ2σΥeu!žƒ‘Bu2€SLΦκrXΫ‹,mΓΜ΅£ω- jGRξ2ζvEδΖΓΊώm™ƒςΉO«Ug–€]ξ­ΌθγMž―B!„’ΔΔΌ­ξ­XρύU«VΒΉΦR­^¦eN"Λs­λυœχ1w u—‚˜ξ–(O;²ΡΧ£Ι-e'm|Kρ5xC97·ΕΈν―ΞkΤ…Ψ5Έη; <ΰΛ¬ΰΫ–BϊψuΤ”›ΥNΕΪOδ‚—ΘΨ‘‡[σΫB@v^ΧΪΘΪΧ3—d]G•·Ξ]ΰΨC¨pB!„B’Δbςδϋ½Υ«~ibνΪ—»ΙIΆςˆκBk₯ε½”Z±Χ™K‰±” φ$εkωΪpŒ8Œ^ KY'ΈΡξlq‘ΙπͺΑ°ΞΞ}i83s₯F]ˆ]σ1Ι‘ΧsΐY]Ϊ >s`.Eρ“)Ύ(θo[Š· Ό™΅κbm\#6€ΒΦ€b[6ΕλLΆόΘρ8aŸΌm1ή΄a£S!„Bˆˆ„$±G˜8θΰτΧ¬ων‰Υk¦šR4ΆͺΖΜΨ΄έnEœ4‘&i™Δ£–|΄K-)ŠMΦΩ v·ΊΆ΅} Λμ-»³”ΜPΞΞ>R-.ώΣΞλ“EAˆ§€ΑΰQžJΰTS*’.ήW»‡š#CwVϊ֜΄f[χylΝ&o6•Ω™σέ\6€#lΰΪXs₯ργ ΨB!„’…„$±Ϋ¬:γŒ½Ιɟ™X³ϊ$ΧοC“n³ΌΧY€¬ Τ IDAT\²ΕάκΊΤ-_׍E|J―]'Rή"Ξ¬½n$: ΞeφΙ¦OΈ™‘ZZgg?;ΈσΦλ4κBμšΨτ­4ιψrΦΰΣ”4σhBˆvyZš²ςΛ€(΅§¨#]–€f†£Nf€ ’–…8%‡Ό₯d˜γ$PξNη―ΑΞΦqK!„B$vC49qPoŊŸθ­X9VCνΈ5λ5faΦ#ξ‘ϊή\ρΩΥB.Wž’:•ΕXέρ-ΌK Φ.φdΛάfU…rvφAΏΈτΡΉΏ_iΤ…XžΈηΫ_•fφ:ΐŽ7 |‰υμ²ξl³¨±SΘΦLMkΝ`’1L» Ži.‡ mΆ*cƒpΔ‡αˆ2|αa@|Žπ ‰EΧ˜Ή(%" !„B±œ ·@μΣ§œ2ιϊύ7τW―^ΗV°vγ@²N›5^„V#΅vφ.©σw“ŠQύ1δQέOmgν₯,κΧ_U‘œ›Ώc°Έψ76Z^#„ΘψϋM7ΐ{bbκX/%9°ΚλΘΒRIšΟfl#,±nΦο7s€3ΐƒΡZhfΎέ™1=¬žϊln0‹±ωι]γxdμw7`Ÿ-±ͺΤW£B!„Λ£³e±[τW―>΄˜œ<Ηυzqα–ˆ₯°lΛϊ³e=”Ζι;γb’8²Eη2;T֎­ΑΒ'Χfη΅xΐ/.Ξ•³³ηΝ\qΕcq!–ηο7ݐ.N8 fΊ΄Αš™κ,f1$σωάΆΊŸš₯#C[ήΫΡ:G&ΚΪKa:η₯nΩ!ˆ°μhEΰ!ΐ>π~ψ©#NΤ` !„B± ’Δξ}€ϊύΓ99Ή14a²δ%β™i<©ΉwGόα“ΗiΫHw₯τxΆo‹Ž†:b·΅dχSžΙέ8™ YižΎΪpvφ{ΥβΒωm!–'‘€%yιzXΞ˜JBπ΅³,-Ώ‰Αn‹KAώ‘Ε¬#vΆ!s΄ΊϊΖTCηΡHΪuV›#8ς33~Ν{Ÿ>βG4˜B!„BμJΠ[ vΕZΧλ­€sYΨu”d²8"6ucω£SbΙ“= –]ˆZžsdνnmν6ΰΑ‘„¬Œ­υ c|OζQ-.Ξζη?±ύk_Ϋ¬Ρb<―όε·€ * ΰE^jfΉΓ0O1J~ 3cθδΦ‹S$wž·ζ~Ψ€$ω:7) ͺΩθnς­fŽΩ™ΑίeζΏΈzυaž€S!„Bˆ'AB’Ψ]¦HφΘ€ίX]Κ–/Χ,Λ4j:Ίeeg•‹F€δ*²Τ}­Ω_§Β¬²…ν~O]Εͺ΅M³π_ΌέK+ηζn(ggUΓ,ΔςΌύO‡ώ‘^EΨ ’>dYW¨υ³ψ’0 ύθ#ΜΔΈQεΝ<£+ Hž&ίq.Βjαˆy˜w|€£™#°ΙƒŸL]Ϊ~ζH•΄ !„BρdHH»GQ,¦:cζΘ›¦uΚΫΪ΄KΞ:χŒiΗ†Ϊ°΄© ώδBSύ”Ά\x·΅z9εi,Iο2οQ.Μ?^ΞΞ~`η7Ύ±U-ΔxήΏιϊ8{Š`―'yθB’6ιBš‘·φŒ@Zκ₯€ž\φ s:bh:«ΉΥ_οh; »‚t“‘”Τ'3œ‘[ v±ƒΏή I!„Bˆ§JOoΨlX>bΓrΑΜV€ΥΦ€Ά:ΔΡU$ςΒ•΄4l£YΣγ©V•ς•θFj"ŒŒβR½%ς²7λ·<ΑδΓj87χω»οώΌFYˆQ¦Χ―ΓαΗ€¨°bŏΕ Μ¬4νΣRf‘5σέ5Σά‚ρ :λ²D&³”}η³kφεγώHΈ4©k j±©{π±E3»w…™1 !„Bρƒ !Iμ…z«ΆΒμ°`>°L"²Μνƒfν˜ύ5δΉΩΦΞDbξRΆΠ΄(Ymf¨ΛΩRycNJrC1=G¨ͺ{8YλΥΐ*C΅°ΈΕΟϟ·xύKd!Ϊ¬8τΜ?ΎΏν% ˜z9€WζHΜΓH2 =q6/ΜΡ—.;>xΐ’Λ¨c2Š‚”ζšL$ο@₯γŠ„ƒ™χŽ@->…‘pΨψ=Π}Α›ίFo–I!„Bˆ§ŒJΫΔnaƒΑcΆΈt₯•eŒ-",l8raΜ>Ίυpωϊ19’2Gσψήhu"Ωͺ…λΙdΰ±Ή΅χΙfΩY•KUΉ0wυΒχΎP#,Δ(σoΕϋ7}+^«žπlΔ&ΜΜ@GGζ]šu ΉHiNt\ΛH8΅™ ¬3’ΌΜ< „sΩ1τˆ΄―Σ9Λ3Η‘pf@aΐ=Žξΐξ$" !„Bρƒ"!IμεΒp*«―—ƒAΥtQkβp­iτφ–geΞ₯ZΚ·Σ]±λ›e₯tczΓ΅/yΓπv‰[U‘š]Έg8³σ/ΎwΫΌFXˆQ λΌΐΑί8ΜG Λf]dD#h0OƲƍشwτQ 3΅©AcΨΖ τΝ8ψpπρx‘΅SΰΆ#° π9›ΈήΜπ‰HB!„BόΐHH»Εά ΧVπε΅εΝχ›Oέ•Z°¬ΙΊWΨΨ“FέGδH_§ts³FΝvŸ€Lpbsέβ"Φ ¨–ΓΉ_Ϊ~ΕΧht…εΎπ±πεQπΗQ43‹0Rλ눴zφy˜yoΎ.YcždΖφ‘!ΈŽΐΒ₯pνlϊ»ό`’rΣ—[*€‹χ ξ‚W—XT0’B!„?$’Δnc•€Z\ΌΘΚω’γYgΈά}––žυ•VιšuΠyl»Τ1%•‰F˜ΕšΆδo(K” χV‹‹ŸΦ¨ 1Κϋ7} GŸ\<ΎͺΞpZ˜Ztω$΄¦UšCΘ5jΌIα6Gΐη_J2BmIL@ϊAZεΓ<&˜ηhR "SF›CθηΔ'7iΐ­‘Άΐ5]ͺB!„β‡@B’ΨmžΈβŠΩjXώγŽ›―βΒnΨ-<«“‹²mΠ¨@ρo ΛNKΝƁԖ”,{šzΟuwϋ9XΧή †r°4ΞΟ_<»ιžλ5ͺB΄ΙΚΩ€3Ό€ =ΤόΘ¬·XRrPηufΉYœωΜΟ¬Kυφ€sΝΎγΡ‘d¨b Ώ1va³ζ¨>`wφ)›>ώβ7ή­AB!„β‡@B’Ψ#vW93σ§εάά -ΉΜ–­Ό$‹kGk–‘–7ϊ6ΐoAͺ››…dωR–]·Rp1vp Ε.Q‘’ƒUͺ…ω[‡s;?4ΌώFTˆ†Žˆt2‚ˆΤΛ&Z–Αi»#:0|ΥΤu©$šDν4§»²Ρu²ω‹tΑ›ω("₯ŠΆTOKZφόlΔ)gf˜Ω+oJ;ϊΉ£N  !„BρC"!IμΆ]zι’UΥωK[·}°ά9[su›yΆ˜œRhV―Τ=Ψ,>†moQΪ3;Β•Εγa_αk―r›u―y”ss3ƒ³2sεΧnΦh Ρπww_ΐP8ΐ1Ξ6π fη‘uXC„RΰλΆ8Ν³YœβPΉf#Αό1ŒΫΌ#…¦nΖ|£–† ωp›#€Y_μ†₯₯Y GŒ_8κd ͺB!„»„$±η>L½β «ͺwqϋŸρ‹KŸ³°ύΧz*Ϋ’NSρ„h[{Ϊυ1Œtb³Zͺ ΎΖ₯jύˆ<άΫ(ηηo¨?―Q’!9‘½ύJ”•nΰYŽ„ω::›qΆ*0ˆ7p$Μ<=ˆ΄LVΟΑT|š$δ€@Ε²o΅‹ΙεwΔιMΗθ{B~Gc}Όΐgβ‚oΥ  !„B±»k½bO±ε+ΫΦ‹/~Ψυ{ΏΊπθχΧΑŽs1ν°lΛ⏲.j#aΫhΚΩb7¦ˆΤY<6—-Έ‘jα):“hΩKˆΙ-Υββ¬_ZϊβŽ+―ΌW£(Dόrθ‡Κ5’8όΔΧ"q–™ίh I¦΄8έ’‹0ωΜb'DŸuG«u$Ÿ‚τ“(Τͺ₯―#Μ’jdqίWg*‘€ ΅nυρ :³ 7Έΐ#ιΘ 7’B!„»O‘·@μiζnΏ½š>φΨοω₯αv?η\±ŽE/6h )׍›!.c’‘ΘΝCH·Υ%jΩνυγ£H”œLyαΛΨ ¦΄&l8ΔpηΞ―vμψσ₯Ν›·kτ„όν]ΧΗye_ΰE$ϋΜ'ŒAΐ1.Ω‚οgkɘυ].™ώ‹χ€u¨’c,Q£tt΅ΗΠŸΡ₯ΧβΒ-“οpή"όύψr~ρΉ/Π  !„B±$ž6έ3\ρόnΆ²Ό€\˜Ÿχe΅Ρυz«θκ₯%Z+R$)ˆ-QX‚ŽΣƒˆP—–¦/'οR\Δ6aΫυ2ΆΙkΞξ|t03ϋηΫ/Ώμ*šΏ½ϋϊ˜CίοΑμΥΞ4³IF=ΘΜ’Hδh0Η¦΄,‰A¬η_œ•ΉX„Z/JB™?ΦΜ@:t€Gˆ,ξ5K‘ͺΝk‰š0AςAŸAqγͺΈS‰HB!„Bμ9$$‰§ω»ο.Μ-<>}ψ‘WΫ°όz΅°0aey”+ŠiE§k²uhΫ’dl£0+¦I›Ι@c};Μκ•+‘ΦΔ₯«_\°ΑΜΞΟ fvΎπΐζ˜ΐίmΊ!Μ5«z0 ƒ `EhŒŠΚ€h7-–΄Y.9Ζφ‹0—₯l*:DI7ΙMm] Y„™Z›œ’ˆΤr/e·ΡΠsδV‚_ pΝ$¬€_’ˆ$„B!Δ₯§·@<Ψ#ΩΦGZpυ!眳iP–Ÿ/ήΦ[Ής ½+Vύ>:f4yJpΤΚGjιOρž:P;‰I)‰q¬wFVUΞ/|ΏZZϊχ_Ϊ6”Α‰df(Š~―ͺp`g’\ifžIΕBQͺK] ZraT(N"κ‚ΆϊqVη,…}Φ™GQG6K%tΩvqw–φ½ΐeΎα―GSR²1ͺ΅ƒΘΰX»Œ2gQ”bvRλ¦°w6F# ν> ΚQžΕ7tΏi°/υ ·Νϋ•φΞSΟΤΐ !„B±‡‘$φ*Kχoφσwί΅0Η›§Ž9ζZ_–χTƒΑ:3;Œt=γT¨Wš¨»|3Ωf΅CΌ™ΒΉ³*ΉΌ™Έω Ý3·”sσ²γŠΛ7iTāN‘Μ*ηŠ‰x5 μ>ΝΌkE›±1Ή,υΜεQIωnη$YTšRjZ-εϋOρό‚6Υ*o ׍pίψy€ _>ϊ ¬B!„O’Δ3ΖΒ¦MOτΧ―ΏδΥ~8|Π‡‡ΒΫ!ΞΉ‚Ξ‘Ϋ*udš² €΄±±Y΅Z{ kU…rvηL97ϋ?Ά^tΡ…@+Ω[ˆŽ$"p€;δ«iˆΪ€74‚²²5Β!ΞΔn1[”]―νJ™ΎK!ΛGJ(ΐΑ5%ΆD₯Τ q+aŸ1ΰήτdΏ"I!„Bˆ§ IβeιΑΛ…M›wλωNΡλ]αƒΗύ`ψΣ9G—š}‡eͺ²ξkJQή\<­Z£S©vIΠή£œ›[ΞΜ|€š›πΒ}χνΤˆ™\Dp€W8&^OͺNτfΖμφδ LdyW6tF©Ϋšcž||ŽΊϊ-Ϋ>3Fρ(%rΧep ΐ>{IžOΈ;$" !„B±w$φ >XΞί}χcnνΊΨsίφΓα¬/ΛΓ¬‘s€sh¨Ή)z‘’)·?4«J”ssσΓ™™UƒΑ_o»μ²‡τ‹™Ώ‰"R868ΐ±ρ{Α#+& d@ΘAj²’ΒυTΪVΟQ6³ͺΈΊyb’~Ω¨BdΫ₯4R Χ4yθΐ€Gyž7'cα[>U+„B!Δӌ„$±O1xψ‘ΑΒ¦Mχχ;όZ3“–σ(«υt\KηΘΒ5ύ‘€‰„<š›ΝJ6f%ωΑÝ3w”s³³\\ϊϋ'.½T"’8 ωλ»,9ηzΐρ^ΰ8})Ν$Λ£‘²λx•@[Dͺ'bL>2€u~RΫQ8:Œ‘2!©N;Kχ9ψΙo₯'”ˆ$„B!ΔήAB’Ψ'YΪΌyqaΣ¦»zλώ¦Αn³²tζύzš­°΄’$³8^4KNζ+Ψpˆrn~G5»σ²αΝΎσϋ?>{Νu;τξŠ™ΏΌλZΐβ°rS…;–ΐ+IͺΈ™Ct[R‚ίˆH@œuŒfγ“,ε!΅³οYχΕ8s“ƒ© νv±Β ]ΈΓάJΰσΎž ZίvΜ 5°B!„Bμ%¨·@μ¬}ΝYGΣΣg“―u“§Ί~£λυVΉΒυᜠεm4«ΚWΥ‚•Γϋύ°ΌΥ/.^>œ_όκμύ[ο-ο»©;)dώβΞkA n€•6yΌ#ΟtΐFΆE€T0κxΖΌ€δCJ½γ–,ΫQ™H2vQth‡m@ΑθY ”…TmΈΨ Ž`lžΨ‘πΐ'|‰ΐΕπφc^ˆ/}ξΎώ;\!„B!φ’Δ~Εͺ—ΌtmoΥκcz“§Έ^oϊ½CHo%€%σΥCUY>β—n©ζfξ[Ϋτ ό?QD ι οΐ3ΉΡΜD$D ΘB“c“H83ψδL%nuιa.׏’˜έNYv(Wkβ˜‚ρ©‘ωI°j;ι.π”o?ζ…xαΞΒM_½Jƒ+„B!Δ^DB’x6PΔΟr©·BˆQώτŽo€ w“xŽ Ÿοˆ3xœ#&X-σEP IŽ‘c›ζ˜‹b7·ΰSrhΚΪ`fd(…s {ˆί9qV[’\ Ξg½Γ°‡ΏDGΘωΛ ηΎlfψS^‰ΑΞ9 B!„{™žήρ,@%kB,Γuϋ7ΰ ˜/·γ°‰u'|€ηθθˆHLQHαΝΜ€‘φͺΤ¬ΩΈ‘“ΆmŒqΫςCDRΙ}MάHt©ˆŽ°fφ5_ρfψOΚDB!„βΓι-Bˆg'ηνί„ΈgΒΚώš;uw6σ–₯ –b³£ˆD:ΈjožfΝ#ˆΤEu§5ƒ™ΜΧί1QR²τlL:RΈμƒΤdυk@#<Νψ5Β.°hf‘„B!„x†QΧ6!„xς'·] ΨξΧβˆΙΑ)$Ο"ψ\’…c6bVGΦ!ε$1–©ΥmΩςΆj¬Ε£Τ]-τW3€ŽυmY‡·¦’b.’#ςͺΆPΦFr†ΰUώ+Ξΐ―=ο4 ¬B!„Ο0r$ !Δ³Œ?Ύνλ0―8ρLTμ8δλwΙ"¨:ΛΘG )lΫ[pΨ©`‹1άο >άf„™™Gxlma2Ό™y²‘σ)0 IX’ΫΰJκbHDB!„bBIBρ,αψsΞΖsV―‡0Ι ίΌύλg8ς΅fΆ.8\T{’Šc.^ςcvWwnKξ₯¬ ₯BGRΈ7D¨¨πΘ”ˆδ`1Έ;ψ›œkϊΆv…C«†rΡ`x‡D$!„B!φ$$ !Δ³€?ώzΌω/;&]ICλIςl«IΆ„"‹ΒŽύrΎ€$EK’ B…‡ΧϋρΜ*Χ²--ΡΥ5pMRŠΠΆZ5ŠŽ€"ڝvΈ ΐeε€ ~]"’B!„ϋ*mBˆύœUΗ‰Ÿ}ߟΰθ©)μΞ»Eΰ žσk 6Ξmσ‘–-osΈTΧδ#oFGΚΪ ±ιZˆ=ʞ–Ρ³”Δ©:~»·„mΎβ.0 οxž‚΅…B!„Ψא#I!φs?ωΔ}‹‹+Vυ¦^kf/5ZΟ€ΚΥrQ25ςQsΛIͺlhˆH €fσnpα°ͺξ,PxƒαΦ«Ύ)I!„Bˆύ9’„b HΰαPž ΰeŽ8AXΥδ΅*ΛΒcΜΰX‡gΓGΒΗΆkΰe>ωΛqΌΑ\€”ν;=·5―!†m»€­»Ξ‘7«ς±~Ύf$ !„Bρμ@B’B<ΓΌνΖ‹‘’† œ*P½ΐ‹ <8§+ΐAΟ₯g!ΉVΩZς+9’±kΙ)s7:n’>e#!gΗνΣ³Χ!LΝcΐ>€Ν2π6σ=‰HB!„B<λPi›B<ƒΌυΖK`Η Ψ‘„ύ¨―xxPqP₯μ"f Ά―ˆ cnR’£€Φ@SώΖφεξχ€«£²ώlmMͺΎ €J€wό²Gyω*–ΔIDB!„βΩ…IBρLpΥΐ"½Η„ΓΔ‘Έεd x1`G0i°²)#cνκΘGq «·LχX ΫF'd;w!-Wβ–n·μ/c—·θJJ»(ΞΌ ΰ΅pθΓΰπΘ·γOβ—5ΞB!„B<ː$„{›«.ότϊCPrpgμt‡DΙ¨ ‚O«t miυε¬έGΉχ(οΤ–„$ζ‚hΠ6bΐvζJΚΝMΐοΌΰMfΓίuόιc!„B!ž₯PoBμ%ΊΨ±X³@1”―}έτʍGNNμΝΦ$ί‘¬‰CRŸ@Ηπk€cCAΒ1φR‹— ϋ‹xŸ‹JαΖ 8Ζl$|θό²‘HΰΐC^ƒ#nΩ‰οΧόέγUΚ&„B!Δ³e$ !ΔήΰΚO‡ΠκΥ«³ƒΑκν ί½θ«ΧυBPυ ΣOΝ™―¨E“–dLelέΓΉΉΪJ”=.t_‹9IΛ”ΰψ¨ΉΈ!Ifœπ]’ψw·Μΰ!kzq€IDATJ|ϋ+—JDB!„β@₯mBρtrωωΐ‘?lΉ { ψfΐ~ fΌΩ yŠΠ·- BΦ*Gs `Žy.R(;#Ϋ‚QάΔη©Ja‹΄}KDj•Ύ5·Ρ7{543{,όM€ΝΧ|ξ‹ψΧίc³B!„’„βιβςO†Ώ[ξΜΦ€|5€s@œ`€!Πd₯€ά(”όHŒ#2ˆHΎ=EqϋϊšY(g#‘Ί²ω¨-…ύYP’’PδΗ“Μ;’0’f~žtξJΐξ ―Λγ='½~0Τ8 !„Bq!!I!φ4—$d=ΐΊ7φ€‡%>ϊ‚¬ρ₯Hm_wiK€pμΚ€›ŽmΙYdA[ς€tΘΛίrˆΤ}­‰δ—η (`f ·ΈΩΑΎεmπž_?,5ΞB!„B€HHBˆ=Ι埊 Ά°ΣΎΐI0›1\@³`j{‘Xw^딬™ι’¨δ8x !Ϊ$[₯pΩ}{O¬Λη8ΪΥ-ZZ2ΰϋ0•nMΰο:ώ%c!„B!`$$ !ΔξB_ώ`r"έ0ψCΌΐKnvUΈ?ˆκ|£ χdΉD­§0€R΅£ΔΪ³G–n›Α’•Ζ½VKOΫd+ur’Έ•δm΄κ:nρqŸοΩψR΅B!„8’„β‡eν* _όWDαΗ˜ό‹Ύ°Γ@* …%'T΅™΅ε#Φ HIX20ΈŠj±(Γe}ήXΈΠ:³9΄|HlGo[σxFqx„°«*[‹Π΅ ο–€$„B!„ˆHHBˆ†νΐϜ―L8ΐΛ;1*>>΅ŠΛ,Έ‡βεˆ όEΞrŽ2qΙ'Q‰vk­|#«|νHJΕoFΈ΄ylΧ–:ΐ‘Ή[Ι*Ϋ άμλlŸθ9xοUΚ&„B!„h!!I!~ήύkΐ[~Ό{πj/ΉH΄(Ξ0«Ζ’rΓΜF”,fΑ7ΑΧi³¨EΥPŠέv±ΔΝΰΰ’ ):š,”Α96{KEpΙσΔθtBp!mp€{“δυŸ}±ΖZ!„B1‚„$!„x*Όι,ΰ’«€·Ό)θ/V8ΐ&œΰ ŽhTž¬ά@Ξω<”¨ΎέΜ¬–xRςQΨMΈ­@’ ,vmsπShΥf!·›.$(‘ Ό3£7ΦjUέ₯-JTΐcΎE؍Ξ§§,$!„B!ΔrHHBˆ'γχ~ψ©7πŸWVτ;ΐΛœ³ ΜE’\/’‚Q΄ HYI€‘ΙΊ27R…<<;/L ₯jM63ƒEGRξE2>Έ•ζΩ­$―φ°-.ϊ–$ !„B!ž IB±gœάpK‘ࣃwλAΌπ§‚Jؘ2ˆRiZnHρGοaή‚ΰTW΄Ρ,+tCSxa¨½‰ ;σpQNς`–<£¨D8oŽ Ϊ=fΈrͺη7-TπŽ”€$„B!„xΚHHBˆεxίeWά ―μΧ6 ΨRτQ9 ™HMšv Ή˜Ϊ©±ΥΈν§ ΕjΙGδRv\³‘GzͺτʚΌ£Σm€§‘JεR›·‡Νp=ιnς°ωα`€ί;ρ58μčg!„B!ΔSFB’BtΉμόόΪ€8ΐ‘€‹MΤ2±’\ΞEτ(ύΑ ΚCB!„BόπHHBΈ|υΌπΧ θυz(Λ`|1ˆη6εhζT!LF1C›Ι„xΩ#”«ϋΜΖhρρY \ŠLj ά"­@$f·…²7Έ^Ψ!άξaΧΐ}0[4Β/Νχΰαρρwύ!ξΈδJΉB!„b·$„8πΈτΌμŠυnDYΎΰ {yFPEQΘΥ’ΐΠΑΝ₯@’¨];–ψ,lΫ7χ5n€dgςW6K[xA4Be@―Ύ»*Wxΰv~ΐ&›-@o τπΐ­·αC?χΉB!„b !IqΰπΥO4—Λj=wΐΨ€‰+Ε¬1|ΌΓ΄γ‹w€yΧ‘rQ)n˜Ί»ο4Β¬ͺou6_—Ά…yIυmE|mwΈΩw0  L1Ϋ‹³³ψ/{“Ζ\!„B±G‘$„xφsiΜ@2h+wzΕ)Φ˜ŽΗΒ °`ύ©υ£ZPΚT VFR°)Ή‹\—Ψlΐ<31‰qχ)©©;α’£‘Eœa܎€+Bν۝n°ΙΓΝ8ψ2<ρΨ¦ϋπџώs!„B!ΔΣ‚„$!Δ³“/|X9.ΣV¬μGžσλ@Dpφx„Ϊ1 ΄E«e«QάA-&± ΠNΩšμΖ‰Tw^ΫUΧΆΊώ͚ΗωP3XpB9’κn ΰ­ξ° ΐ €2…r탂λώφΓw!„B!Δӊ„$!Δ³‹KSωš!TŒU‡ƒ<πΗƒ\`eΠi’‹¨ξΦΈŽ,f5ΨΞ8²Ž#‰-…(s.5]ΩΪUr$Βγsρ)=«1₯{Žΰξ άζΩ²ΪΎ¦²•*#ήκΩs!„B!Δ^CB’bηΏΌν-ρŠμ6<ΐqpξ(˜­0³=X‡1+c ©Ψ)<»v&ΥΞ’,φˆΝ«£ά΅Τ΄(‹Bσ}Xμζ–žΞ‚ϊe·όέο4ΰϋžΨf0LΔΠ tΪλ5ξB!„Bˆ½Ž„$!ΔώΙ»~ xGKZΨV<V=t‡ΆΖI0&UhœFΦέ£CΚΐfξ:J‰ΪΜJΫκΏA"˜kD*CہT‹FΝν4Δ m ”ΨMX𠁻n2πq†68Jx|μτ7jμ…B!„Ο’„ϋ—|ΌΉόζs‚.γ±ΐQ€{€ƒ@Œΰμρ ΛΈuVB6N聇™kΆJΡ’8Ε£Ψ…-ΈŒσi7YP7Σv™bU?oΈμμ˜%p―χΈ‡(5τω+ψW HB!„Bˆ} IBˆ}Ÿ–λ(αWάΓΐްΐΠωP.fBψ΅kάE Ι-ςΦIΑŽεi΅ ¨C΄ι2‘B†R”‚Π ΙN₯mفΫεl(@L†ϋyΟΐμͺ W\4τφΨvΜ=Ά+`1όΌΣΟΡΨ !„B!φ)¨·@±Οrqζ>"τPΨsw4hΓp0`=U(3KρG>ιA 3mΘuŽ~MW΅Pzζΐ±ΙΨyΰ6ΫΗP«u¦Έ]Ό`δ*k@NX°Δ&‚7›/ΏsΊΥΫ›˜Dw|ήι?ͺ±B!„Bμ“HHBμ[Τξ£θτιυαπ`λΫπλ­‚‘jΒ³S·5"–›Ή 9€ΖXζFS³  sυӎ’s€.kuγ6Ζ»\Ψ:ΑqO+Vζ@< Έϋ»½©M¨q$ϊΦExϋλώƒς„B!„ϋ<’„Ο<¬}H²pύ5€­ΈΐQpPά°ŠΫ:0‰7)Γ¨U’–ίΔϊφ:pM 6Ω<&άΦ=>faΫyχ5C(e3(’jε,ΨrΐC οΓδκ[±4γΓΞώ%ΏB!„bΏAB’β™!/[›–­ N# ΰΉ!Ο­jr’σQΏ1’νl#’εTj"―˜²κ4εm-Α({LλΈΩzͺδGBμC·eΈά%β6δή±opγ=Ώ’$I’^I’^œσπΥgΗ2  [‡Ό±\!σ[ΤπhΡ†TΧΑA5δ‘V1­κ+Œ†υjC4\Ν" …E}0T«†8QΕΤ?=gT+‘Zλ[D’@ϋ;ΐ½Zy”?ΥχbΜ₯V+xηΏ ’$I’^JI’žί­α­7ŸφΘδ:Δ:Q^Όyb‹ΪΖ}3Ώ"•ρ²”ύ\£Bδ΄ϋl>»hk΄bz]’ι&·VέDΝ«2Κ±ΝlcθT‡ΐδ>”mΘ{ΐˆίO|ξ›VI’$I: ’$ύsί~ρτΛG‰Bζ:u(v­:"Ά ―εR«κΘlUGQNΆŽεΙKR­&*“Ά8ζΫ֎W#΅–ΆωΠ혜]ϊ±ΪγϋF¬κͺ8VΤmk‰Ψ†ψ™ [χΩϋ΅Ο[α‘$I’€SΙ IΣΝB£ιε’«±F›ΤYGύν:QΊΪ±Vrώ 3¦AOΧLeςp “Ζ–³œ}˜~|9 Œrl…ΞθΟ/™+" :‚}ΰΙ/wΩΨΈΟrΩ ­j±¨ηέxΧο„$I’€SΟ I|ώ1\ή|ΖΥ!Φ¨AO‘Vm—©ΑѐηkϋX@OΔ‰™C™­5-ηνkύΘ£ Μή˜k4 Şog«AQ©4ܟql[[ڝD«:*χm’μΤ§ΐΠpΣ k’$I’tܚ)σ͗퇑δ§ύάъ‚j`”‚5ΰ"δ&Δ&° qr1-ZqΤΜL΄Ξy0Σv³IQQνh] sΚΠͺ6†LL†eχ/μΪλ»φxxמ²GζπxόΒbυέόcυΗπΆ²%I’$ιο$I§W™ίF Ψ^£nT{8ΗPΩΣ‡AΉšΜ(*cΛZNΫΞj2U«ƒΪ°λŽqΦQ/jΕΠΠςƘ7 B‘Τ9FΓ‹κΌ₯θˆΕ’μv v!C<’ΔC²ϋσΔoΌjω—[Υ$I’$ιΉ$I§ΣcPτ*5@ΊœahM6¨Β΄νl“¨ƒͺΫ†΄θK†¦ƒ­ΫΛ²«ΥEuΘφδόΦSm-[v]{^ίΪvμ‘«?ˆ²$»%Q–»DμςγΩžόνϊ<λ»ΰ£OόkK’$I b$JΉ v!Οη ΞΦ[ΞB9Cpr.΅lΉ sA”,jϋZΖdžRNzΨVd·’niλZΣa{Ξ!ΔΘCjP΅_Ν>Ι°O,–Π<σΧΈώn} _οŸT’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’$I’τŸρΈv|γΛΨIENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-success-128x128.ico000066400000000000000000002040761524161175200273430ustar00rootroot00000000000000€€ ((€ !8KVdpwwpdVK8! Gƒ³δνπσυχψϊϋϋϊψχυσπν䳃G  J™γγ™J =qΈόόΈq=,‰ΔξξΔ‰,DΒυύ  # ( - 0 1 0 - ) #   ύυΒD BΡ. %I0`7o=yA‚"FŠ$I&K•'Nš(Oœ)Oœ)Oœ)Nš(L–'I&FŠ$B‚"=y8o0`%I. ΡB RΏ+ 0`D‡#J’&L–'Mš(Oœ)O*P*P*P*P*P*P*P*P*P*P*P*P*P*P)O*P)O)M™(L–'J’&C†"0`+ ΏRNΎ' 3eM˜(Oœ)O)O)O*P*P*P*Pž*Pž+Qž*Pž+Pž+Qž*Qž+Qž+Qž+Qž+Qž+Qž+Pž*Qž*Pž+P*Pž*P*P*P*O*Oœ*Oœ)Oœ)L˜(4g)  ΎNΆϋ & 'N?} G$L•'O*P*Pž*Pž*Pž+Qž+Qž*QŸ+QŸ+QŸ+QŸ+RŸ+QŸ+RŸ+QŸ,RŸ+RŸ,RŸ+RŸ+QŸ+QŸ+RŸ+QŸ+Qž+Qž+Qž+Qž+Qž+Pž*Pž*P*P*O)Oœ)Oœ)F‹#-Y,  ϋΆ]ω !B5iE‡#GŒ%H%IŽ%K’'QŸ+QŸ+QŸ,RŸ,R ,R ,R ,R ,S -R -R -S‘-S‘-S‘-S -S‘-S‘-S‘-S‘-S‘-S‘-S‘-S -S -S ,R ,R ,RŸ,RŸ,RŸ+QŸ+QŸ+Qž+Pž*P*P*O*L—'=z &Mω]*₯ 'M@} D†"F‰$GŒ$H%IŽ&K'K’(P›*RŸ,S ,R ,S -S‘-S‘-S‘-S‘-T‘-T‘.S’-T’-T’.T’.T’.T’-T’.T‘.T’-T‘.T’-S’-T‘-S‘-S‘-S‘-S ,R‘-R -R ,RŸ,QŸ,QŸ+Qž+Qž+Qž*O*O)Nš(I%-Z ₯*eέ :@ C…"E‡#F‰$GŠ%HŒ%IŽ%J'K’'K“(O—)S -S‘-S‘-T‘-S‘.T’.T’.T’.T’.T£.T’.U£/U£.T£.U£.U£/U£.U£.U£.U’.T’.U’.T’.T’.T’.T‘-T‘.S‘-S‘-S -R ,S ,R ,RŸ,QŸ,Qž+Qž+P*P*P)O)L•'"Eάe•ψ0 8lCƒ"D„"D…#Fˆ#GŠ$G‹%I&I&K(L“(L”)M–)S‘-T’.T’.T’.U£.U£.U£/U£/U£/V€/U£/U€/U€/U€/U€/V€/V€/U€/V€/V£/V£/V£/U£/U£/U£.T’.T’.T’.T’.T’.S‘-S‘-S‘-S -RŸ,RŸ,RŸ+Qž+Qž+Pž*P*O*Oœ)C‡#: χ•­0^B€!C‚"D„"E†#F‡$GŠ%H‹%IŒ'J'K'L’(M”)N•*O˜+Rž-U’/V€/V€/V€0V€0V₯0V₯0V₯0W₯0W₯1W₯0W₯1W₯1W₯1W₯1W₯1W₯1W₯1W₯1W₯1W₯0V₯0V₯0V€0V€0V€0V€/U£/U£/U£/U’.T’.T’.S‘-S‘-S -S ,R ,RŸ,Qž+Qž+Pž*P*N›(;v) ­.Ν$F>{A!C"Cƒ"E…#E‡$Gˆ$H‹%HŒ&JŽ'K(L‘(M”)N•*O˜+P™+R-U£/V₯0W₯0W₯1W₯1W¦1X¦1X¦1X¦1X¦1X¦1X¦1X¦1X¦2X§2X§2X§1X¦1X¦1X¦1X¦1W¦1W¦1W₯1W₯0W₯0W₯0W€0V€/V€/V€/U£/T£.T’.T’.T’-S‘-S‘-S -R ,QŸ+QŸ+Qž+Pž*Oœ)L—'/^Μ.Hγ 4g@| A B€!C‚"Dƒ#E…$Fˆ%G‰%IŒ&J'KŽ'L‘)L’)M”*O–+P˜,Pš,S.V€/W₯1W¦1X¦1X¦2X§1X§2X§2Y§2Y§2Y§2Y§2Y§2Y§2Y§2Y§2Y§2Y§2X§2Y§2X¦2X§2X¦2X¦2X¦1W¦1W¦1W₯1W₯0V€0V€0V€0U£/U£/U£/U’.T’.T‘-S‘-S‘-R ,RŸ,RŸ+Qž+P*P*O)?~! αHGρ, :q?{ @} B!C"Dƒ#D„#F‡%Hˆ&HŠ&JŒ'JŽ(K(L’)N”)N•,P—+Q™-Qš-Sœ.V£1[§5\¨7Y§2Y§2Y¨2Y¨2Y¨3Y¨3Z¨3Y¨3Z¨3Z¨3Z¨3Z¨3Z¨3[¨3Y¨3Z¨3Z¨3Y©2Y§2Y§2Y§2Y§2X§2X§2X¦1X¦1W₯1W₯1W₯0V₯0V€/V€/V€/U£/T’.T’.S‘-S’-S ,R ,RŸ,QŸ+Pž+P*P*H%8ξG8ϋ$G=v?{A|!B~"B€"D‚"F„$F†%Gˆ%HŠ&J‹'KŽ(K)M’)N“+O•+P—,Q˜,S›.Sœ.pR’Δ{œΚ„Λ†˜Ι€}»_[©4[©4[©4[©4[ͺ4[ͺ4[ͺ5[ͺ4[ͺ4[ͺ5\ͺ4[ͺ4[ͺ5[ͺ4[ͺ4[ͺ4[©4[©4Z©3Z©3Z©3Z¨3Y¨3Y§2X§2Y§2X¦1X¦1W¦1W₯1V€0V€0V€0U€/U’.T’.T’.S‘-S -R ,RŸ,QŸ+Qž*P*O›)0^ϊ8Fϋ0\=x?z@{ A~!B#C#E„$F…$F‡%H‰&I‹'J(L(M*N“*O”,O–,Q˜-Rš-T›0™Δƒ­Ο»Ž˜s¬‚­Οœ²Φ ¨“sΆR\«5\«5\«5\«6\«5\«5\«5\«6\«5\«5\«5\«5\«5\«5\ͺ5[ͺ5[ͺ5[ͺ4[©4Z©4Z©4Z¨3Y¨3Y¨2Y§3Y§2X§1X¦1X¦1W₯0V₯0V€0V€0U£/U’.T’.T’.S‘-R -R ,RŸ,Qž+Pž*Oœ):t ϊFEρ1_=w?x@z@}!B!C€#D‚$F„$F†&Hˆ&IŠ'KŒ(KŽ)L)M‘*N”+P•,Q—-Q™-S›.šΕ…’ΐ“BN<"uj¨Κ˜΅Ω£–Ι}g°B]¬6]¬6]¬6]¬6]¬6]¬6]¬6]¬6]¬6]¬6]¬6\«5\«6\«5\«6[ͺ5[ͺ5[ͺ5[ͺ5[©4Z©4Z©4Z¨3Y¨3Y¨2X§2X§2W¦1W₯1W₯0W₯0V€0V€/U£/U£.T’.S‘-S -R ,QŸ+Qž+Pž*P*>| νE,θ 1`w?y A{ A}"C"D#Eƒ%F…%G‡&H‰'J‹(K)L)M*N’+O•,P–-R˜-Sš/w°ZϞES?09+|”p΅Ϊ€―֜…Αhf°A]­7^­7^­7^­7^­7^­7^­7^­7^­7^¬7]¬6]¬6]¬6]¬6\«6\«5\«5\«6[ͺ4[©4Z©4Z©4Z¨3Y¨3Y¨3Y§2X§1X¦1W¦1W₯0V€0V€/U£/U£.T’.T‘-S‘-R ,R +Qž+Pž+P*D†#ί,Υ 4e=u=v>y @{!B}"B~"D#D‚$F…%G†&Iˆ'IŠ(JŒ(KŽ*M+O’+P”,P–-R˜.Rš/\‘9ŸΘŠŠ£}8C3€Ε•ΆΪ₯€ΠŽqΈO_―8_―9`―9_8_―8_8_8_8_8_8_8_8^­7^­7]­7]¬6]¬6]¬6\«6\«5[ͺ5[ͺ4[ͺ4Z©4Z¨3Y¨3Y¨3X§2X¦1X¦1W₯1V€0V€/U£/U£/T’.S‘-S‘-RŸ,QŸ+Qž+P*G‹%( Ε΅ 6hw@z!A{!B}"C~#D‚$F„%F…%Hˆ'I‰'J‹)L)L*N’+O“+P•-Q—.S˜/T›/|³a²Σ’"‹¦~·Ϊ₯’Ќg²B`°9`°9`―9`―9`°9`―9`―9`―9`―9_―8_―8_8_8_7^­7^­7^¬7]¬6]¬6\«6\«5\«5\©4[©4Z¨4Z¨3Z¨3Y§2X§2X¦1W¦1W₯0V€0U€/U’/T’.T‘-S‘-RŸ,QŸ+Qž+Pž*G‹%¦•1_w @y A{!B}"D€$D$Fƒ%G…&H‡'J‰(J‹)K)M+N‘+O“-Q•-Q—/S™/T›1Œ½t˜΅‹ fy\Έά¦™Ν€d²>b²;b²;b±;b±;b²;a±;a±:a±:a±:a±:a°:`°9`°9`―9`―9_8_8_­8^­7]¬6]¬6]¬6\«5[ͺ5[ͺ5[ͺ4Z©4Z¨3Y¨2Y§2X§2W¦1W₯0V€0V€/U€.T’.T‘-S‘-R ,QŸ+Qž+Pž*?{  φZ#ν'M:p;q=t>v ?x @y!A|"C~#D€$E‚%F„&H†'Iˆ'KŠ(KŒ*MŽ*N,O“,Q•.R—.S˜0Tš0k©K³Σ’DQ>VfMΈά§’Ρ‹g΄@c³v?w @z!B|"C~#D$E‚%Gƒ'H†'Iˆ(KŠ)KŒ*MŽ+O,P’-Q”.R–/S™0Uš1~³c¬Μ›e΅>e΅>e΅>e΅>e΅>e΅=e΄=d΄=d΄=d΄=c³t>v ?y!Az"C|"C~$E$F‚&G„'I‡'J‰)L‹*L*N,O‘,P“.Q–.R—/T™0_‘>£Κ•s~–qΊή¨Ήέ§‚ΓbfΆ>fΆ>fΆ?fΆ?fΆ>fΆ>fΆ>e΅>e΅>e΅>e΅>e΅=d΄=d΄=d΄=c³t?w!@y!B{#C}#D%F‚%Fƒ&H†(I‡(KŠ)MŒ+NŽ+O-P“-R”.R–/T˜1V›1ƒΆh³Σ£8C2Ίή¨Ίή¨₯ՎkΉEgΈ@gΈ@gΈ@gΈ@gΈ@gΈ@g·@g·@g·?g·?fΆ?fΆ?fΆ>fΆ>e΅>e΅>e΄=d΄=c³;c³±ΡŸTbL£Γ”Ίί©Έή¦‚ΔahΉAhΉAhΉAhΉAhΉAhΈAhΉAhΈ@hΈ@hΈ@gΈ@gΈ@g·?g·?f·?fΆ>fΆ>e΅>e΅>d΅=c΄e΅>e΄=d΄=d³=c³u @w!Ay"B{$D}$E%F‚&G„'I†(K‰)LŠ*MŒ+O,O-Q’/R•/S˜0U™2i¦J―͟9C3 ’Α’»ί©Ήή¦rΏMj»Cj»Cj»Bj»Bj»Bj»BjΊBj»BjΊBjΊBiΊBiΊBiΉAiΉAhΉAhΈ@hΈ@gΈ@g·?f·?fΆ>fΆ>e΅=d΅=d΄=c³s ?v @w"Az#C|$E~$P‡1c•HlRpŸVp‘Ul R`˜AO,P‘.R”/S–0U˜1Uš2†Ήm΄Σ£„›wdwZΌΰͺΌΰͺΛolΌDkΌDl½Dl½Dl½Dk½DlΌDkΌDkΌDkΌDkΌCkΌCk»Ci»Cj»BjΊBiΊBiΉAiΉAhΈAhΈ@g·?f·?fΆ?fΆ?e΅>d΄=d΄=c³t @v!Ay#Bz#mšU˜Έ‡£ΐ”₯ΐ–¦Β–¦Γ—¨Δ™¨Ζš©Η™ŸΒbžCS•0T—1U™1Wœ3i¨I΄Τ£΅Υ€΅Υ₯ZjR»ΰ©Όαͺ«Ω•pΏIlΎDl½Em½DlΎElΎEm½El½Dl½El½El½Dl½Dk½DkΌDkΌCkΌCj»Cj»CjΊBiΊBiΊBiΉAhΈ@hΈ@g·@g·?fΆ?e΅>d΅=d΄=d³e΅>e΅>e΄=c³sQ5Ьw₯Ύ—]kU&,# ™³‹°Ν “Ό~W˜5Uš2c£Cp«Sf§D[£7e¨A‘Βy°Τž·Ω¦ΈΪ§™u4>/CP=½α«½α«¦ΧqΐInΏFnΏFnΏFnΏFnΐFnΐGnΐFnΏFnΏFnΏFnΏFnΏFmΎEmΎEmΎEm½El½Dl½DkΌDlΌCj»Cj»CjΊBiΊBiΉAhΈ@gΈ@g·?fΆ?fΆ>e΅>d΅=c³d΅=d³=c³;b²;a±:a±:`―9_8_8^­7]¬6\«5[ͺ5[©4Z¨3X§2X¦1X¦1V€0U£/T’.T’-S -RŸ,Qž+&K₯90\6h9j:l;oe΅=d³=c³;b²;b±;a°:`―9_8^­8^­7\«6\ͺ5[ͺ4[©4Y¨2Y§2X¦1W₯1V€0U£/T’.S’-R ,RŸ+H%τΝ# 6g7h9k:me΅>e΅=c³e΅>d΄=c³e΅>d΄=d΄=c²e΅>d΄=d³e΅=d΄=c²;b²;a±:`°:_8^8^­7]¬6\ͺ5[ͺ4Z©3Z¨3X¦2W₯1V₯0V€/T’.T’-S‘-R ,@~"¬e3a7g8i9k;nS€:§Ώš6>2s…h―Λ °Μ‘v©[S”1s¨V³Ρ£*1&d΅=d³=b²;b±;a±:`°9_8^­7]­7\«6[ͺ5[©4Z¨3Y§2X¦1W₯1V€0U£/T’.S‘-S -QŸ+ κͺ2 6f8h8j:k/¨€ΐεΎδ«±ί™žΩ‚‡ΟdwΙNwΙNwΚOxΚOxΚOxΚOxΚOxΚOxΚOxΚOwΚOxΚOwΚNwΙNvΙNvΙNvΘMuΗMuΗLuΖLtΕKsΕKsΔKrΔIqΓIqΒHpΑHpΑHnΐGnΏFmΎEm½El½DkΌCj»CiΊBhΉAhΈ@g·?fΆ?e΅>d΄=c³d΄=d΄d΄=c³d΅=d΄=c³e΄=c³=b²;a±;a°:`―9_7^­7]¬6\ͺ4[©4Z¨3Y¨3X§2W₯1V€0U£/T’.S‘-R ,;u ϊ/Z7f7h9k;l;m=p v›b¦½™29.¬ΖΙ œΎŠW‘5U“4Β‰¨ƒ***šΉŒ½β«ŠΛjl½EkΌDj»BiΊBiΉAhΈ@f·?fΆ>e΅>d΄=c²e΄=c³e΅>d΄r ?t"~£j©Β›HRB —―‹­ΘŸ¬Θf›JPŽ/’Ή}œ΅Ž$ΪΪΪN^GΎγ¬Ύγ¬ˆΛhnΏFmΎEkΌDkΌCj»BiΊAhΈ@g·?f·?e΅>d΄=c³d΄=c³d΄=c³d΅=c³r ?t"Av"Cx$D{$F}&G(I‚(I„)K‡,M‰-¬Ιvˆl ¦¦¦ψψψΪΪΪΌΌΌžžžyyyΠΠΠεεε žΌΐεΐε}ΙWsΕJqΓIqΒIpΑHoΐGmΏFmΎElΌDkΌCjΊBiΉAhΈ@g·@f΅>e΅=d΄=c³e΅=d΄=c³d΄=c³d΄=c³ψ 3a7h9j;l;n=p ?s @u"Bx$Cz$D|&F'G€(I„)K†+Mˆ,N‹-P/Q0T’1U•2]›=Ίu­Ξ›΅Τ₯΅Υ¦|’r19,ΚΚΚβββ !!!111AAAΈΈΈώώώ888’ΐ’Γθ°ΐζŽkvΙNuΘMtΗLtΖLrΔJrΓIqΒHpΑHnΐFmΎEژ”±†+3' #|”pΊή¨΄Ϋ‘nΊJd³r ?t!@v"Bx$D{$E|&F(H‚)I„)K†+MŠ,NŒ-P/Q0T“2U–3X˜4X›5[ž7] 9_£:_₯<Ώr³Υ ΊΪ©ΊΫ©uŠjCCCύύύƒƒƒ///iiibtXΓι±Γ谘ΨyyΜPΞZ³α΄Φ’›ΈŒœΊΆΩ€Ύγ­€Ψ‹pΑHoΐGŽΝp½α«QaJCP<Ήέ§―Ψ›rΊOa±:`°9`―8^­7]¬6\«5[ͺ5Z¨3Y¨3X¦2W¦1V€0U£/T’.@|" ώμ.U8i9k;nr ?t!Av"Bx$D{%E}&G(H‚)I„)K†+M‰,NŒ-Q/Q0T“2V–3X˜4Y›6[ž7] 9_£:`₯/?L9ΈΪ¦Ύγ¬—znΐGxΓS½α«°Ÿ&.#ˆ’{Ήά§‘Ιvb±;`°9`―9^­7]¬6\«6[ͺ5Z¨3Y§3X§2W¦1V€0U£/T’.;t ϊζ%F8i9k;n=o >q ?t!@v"Bx#D{%E}&G€(I‚(J…)K‡+N‰,OŒ-P/R0T”2V–3W˜4Y›6[ž7]‘9^£:`¦c«?f@ƒΎe΄Ω‘Όή«­Ν>I8βββΏΏΏJJJρρρωωω___¨€Δι±Αθ’ΦqyΝQ}ΝUΈγ£lb=I7Ύγ¬Ία¦€Θ^nΏFšΌαͺ₯Ε–7B2³Υ’΅Ϋ£j΅E`°9_―8^­7]¬6\«5[ͺ5Z©3Y¨3X¦2W¦1V€0U£/T’.6hυΎ58i:l;n

q ?s!Av#By#D{%E}&G€'H‚(J„)L‡+MŠ-OŒ.P/R0T”2V–3W˜4Y›5[ž7]‘9_€:`¦;b©>d«?eAg±BtΉQ²Ψ½ί¬Ύΰ¬BN<¨¨¨ήήή ͺͺͺΖΖΖ>J8Δκ²Δι±―α–{ΝSyΜQ~ΝVΏζ¬ZlRs‰hΎγ¬₯׌oΐHrΐK©Ψ’»ΰ©vk Œ§Έά¦Ηs`―9_―9^­7]¬6\«5[ͺ5Z¨3Y§2X§2W¦1V€/U£/S -,Vؘ" 8i:l;n

q @t!Av#By#D|%E}&G€(Hƒ)J„*L‡+MŠ-O.PŽ/R‘0U”2U–3W™4Y›6[Ÿ8]‘9^£:`¦;c©>e«?e@g±Bj΄Ck·EͺΦ”Ύΰ¬Ώβ­O]Glllόόό---@@@ϋϋϋύύύIII ¬ΞΔκ±ΐ謎ΥkzΝRzΜQ}ΜVΌε¨‘Α’(0$―Ÿ½β«|ΕYm½E}ΕZ΅έ’΄Χ£%,!ZkQ΅Ω€΅Ϊ£`―9_―9^­7]¬6\«5[ͺ5Z¨3Y§2X¦2W₯1V£0U£/Rž, ?¬f 8i:l;m

r @t!Aw#By#D|%E}&G€'H‚)J…*L‡+MŠ-OŒ.QŽ/R‘0T”2V—3W™5Y›5[ž8\’8^€:`¦;c©>d«?e@g±Bi΄CkΆEmΉG‘Σ‡Ύβ­Ώγ­UeM---IIIΏΏΏ¬¬¬cvZΒθ°Δ鱩ΰ{ΞSzΝQyΜQ{ΛR§έŽΑζ―s‰hšu½β«₯׍lΎElΌD˜Π|Ήή¨qˆg"ϝ·Ϋ¦~Ώ__8^­7]¬6\«5[ͺ4Z¨3Y§2X¦2W₯1V€/U£.P›,% f26d9l;m

q @t!Aw#Cy#D|%E~&G€'Hƒ)J„*L‡+MŠ-O.Q/R‘0T”2V–3W™4Yœ6[ž8\‘8^£:`¦;b©=d«?f­@g±Bi΄CkΆEmΉFq½JžΣ„ΏγΊέ©GT@qqqOOOϋϋϋ%­ΝœΔκ²Ώθ«€ΠX{ΞRzΝQxΛPxΚO‰ΠeΌδ¨Ήά§7B2L[EΈά¦½α«pΏIkΌDrΎM·έ€Ό ™ΆŠ·Ϋ¦žΞ‡_8^­7\¬6\«5[ͺ4Z¨3Y§2X¦2W₯1V€/U£.N–) 2)M:lr!?t!Aw#Cy#C{%E~&G€'H‚)J…)K‡*MŠ-O.P.R’0T”2V–3W™4Y›6[Ÿ7\‘8^€:`¦;b¨=d«?f@g°Bi΄Ck·DmΉFn»HqΏJ€Χ‹ΐδΐδ)0%ΞΞΞηηηuuul€aΔκ²Δ鱓Χr|ΞS{ΝRyΜQxΛPxΚOwΙNΧ€Ώδ¬—r$²Υ‘½α«‡ΙgkΌCjΊBŸ†Ίί¨9D4ey[·Ϋ¦―ל_8]­6\«6\«5[ͺ4Y¨3X§2X¦1W₯1V£/U’.E†%Ρ/ 9kr!?t!Aw#Cy#D{%E~&G€'H‚)J…*K‡*MŠ-O-P/R’0T”1V–3W™4X›5[Ÿ7] 9]€:_₯;b©=d¬?e@g±Aj΄DkΆDlΉFn»HqΏIrΒK©Ϋ‘Αε―»ίͺšššΦΦΦ!'Δι±Δκ²²γ™~ΟV{ΞRzΝRyΜPxΛPwΚOvΙNyΘSΊγ§»­Οœ½α«™Ρ}kΌCiΊB}Β[Ίή¨asW7B2·Ϋ₯²Ψ oΆL]¬6\«6\ͺ5[©4Y¨3X§2X¦1W₯0U£/U’./Zά‹ 9k2^;n=q=r!?t!Aw#By#D|$E~&G'H‚(I„*K‡+MŠ,NŒ.P/R‘0S•1U–3W™4Y›5Zž7\‘9^£9`¦;b©e@g°Ai΄Ck΅DlΈEoΌGqΏIrΑJsΓLΚZ΅α Βθ°|”p444ΣΣΣ N]GΔκ²Δ鱫α’}ΟT{ΝRzΝQyΜQxΛPwΙOvΙNuΘMtΗL˜Τ{²Τ‘,5(›ΉŒΌαͺ₯֌j»CiΊBhΉ@§ΥΌŽ ·Ϊ₯΄Ω’„Αg]¬6\«5[ͺ4Z©4Y§3X¦2W₯1V₯0U£/Q,Fχ"A;n=p>s ?t!Ax"By#D{$F}&G€'Hƒ(J„*K‡*MŠ,NŒ.P.Q‘/S”1U–3W™4X›5Z6\‘8^£:_₯;b©=c«>d@f°Ah³BjΆDlΈEn»FpΎIrΑJsΓKuΖM‰Οeΐη­ΑζFS?!!!κκκύύύWWW  ²Τ‘Δι±ΑθˆΣc{ΞRzΝQyΜQyΛPxΚOwΙNvΘNuΗMtΖK™Τ|­Ξœ'/#’Β“Όΰͺ₯Ռj»BhΉAhΈ@—Ν{¬Ν›  ±ΣŸ΅Ω£ŠΓm]«6\«5[ͺ4Z©3Y§2X¦2W₯1V€0U£.8lϋ Ή$ ;o=p>s >t!Ax#By#D{$E~&G€'Hƒ(I…*J‡*MŠ,NŒ.O.Q‘/T”2U–2W™4Y›5[ž7\‘8]£:_₯:a¨=c«>d­?f°Ah³BjΆDlΈEmΊFpΎHqΐJsΓKuΕLwΙO›Ω|Βθ°±Σ ΘΘΘΒΒΒmƒcΒη―Γι±₯ވ{ΟSzΝQzΜQyΜPxΛPwΙNvΘNvΘMuΗMsΕK©ΫœΊ¬ΞœΌΰͺ‘ԈjΊBhΉAgΈ@ŽΙq³Φ‘  ͺ̚΅Ω£‹Δo\«6\ͺ5[©4Z©3X§2W¦1W₯1V€0U£./ Κ s 3`=p>s >t!Aw"By$C{$E~%F€&Iƒ(I…*K‡*LŠ+OŒ-O.Q‘0S“0U—2V™4X›4Zž6\‘8]£9_₯:a¨υ(Kr ?t!@w"Bz$C{$E~%G€&H‚(J…)K‡*M‰+NŒ-PŽ.R’0S“1U–2V™3X›4Zž6\ 7]’9^₯:`¨e―@h³Bh΅Cj·DmΊFnΌGpΏIrΒKtΕLvΘNwΚN~ΝX·γ’Βθ°Q`I***HHHvvv₯₯₯‚‚‚vkΓι±Γ鱐ΥmzΝRzΜQyΛQxΚOxΙOwΙNvΘNuΗLtΖLtΕKžΧ‚Ίή¨1;-RaJΈΫ¦»ΰͺ‚ΕaiΉAhΈ@f·?•Μy Ώ ΆΪ₯΅Ω£ŠΓm]«5[ͺ4Z©4Z¨3X¦2W₯1V€0U€/3bϋ>Ν* :l>r?u!@w"By#C{$E}%G&H‚(I…)J‡*MŠ+NŒ-OŽ.Q‘/S“0U•2V˜3X›5Zž6[ 8]£8_₯:`¨;bͺ=c­>e―?g²Bh΅Bj·DlΉFn½GpΏHqΑJsΕLuΘMwΙNwΚOΤpΑη³Υ’!!!SSS'.#Γι±Γ豫ߑ|ΝUzΝQyΜPxΛOxΚOwΙNvΙNvΗMtΖLtΖK’ΡsΎγ«tŠi…ŸyΌΰͺ»ΰ©iΊBiΉAg·?fΆ?€Τ‰£{  ΆΪ₯΅Ω£†Αi[ͺ5[©4Z¨3Y§3X¦1W₯1V€/T‘.2Ψ‘7f=s?u!Aw"By#C|$D}%G€'H‚(I…)K‡*LŠ,NŒ,OŽ.Q‘/R“0T•2V˜3X›4Yž6[ 7]’8^₯:`¨;aͺe―?g±Ai΅Bk·ClΉEmΌFoΏHqΑIsΓKuΗLvΙNwΙNxΚQ«ή’Αη―q‡fžΌΓθ±Ώη«‹ΣgyΜQyΜPyΚPyΚQxΙPwΘOwΘNvΗNxΗQ›ΥΏδ­† y,5(­Π»ΰͺœΡ‚iΉAhΉAg·?fΆ>΅Ϋ’g{]+4'ΆΪ€³Ψ‘}Ό^[ͺ5Z©4Y¨3Y§2W¦1W₯0V€/E„& ‘:ψ!@=r?u!@w"Ay#C|$D~%F€&H‚'I„)K‡*L‰*NŒ,P-P.R“0T–1U—3Wš4Z6ZŸ7\’7^₯9`§;aͺe―?f²@h΄Bj·ClΉDm»EpΎHqΑIrΓKtΖKvΗMvΙNwΙN†ΠcΎζ«° &-"WgNΓθ°Γ谧݌yΜQyΜP‡ΠcšΧ}€Ϋ‰’Ϊ‡›Χ}™Υ{₯Ϊ‹»γ¨­Ξœ_qU|•pΌΰͺ»ΰ©wΐThΉAhΈ@g·?u½RΉέ§AM:TeMΆΪ€±Χžo΅N[ͺ4Z©4Y¨3X§2W₯1V₯0U’./[ϊ:Ξ=s?t @w"Ax#C{#D}%F€&Hƒ'I„)J‡*L‰+MŒ,O-P.R“0T•1U˜3Wš3Y6ZŸ6[’7]€9_§;a©;b¬>d>f±@g΄Bj΅BkΉDmΌFn½GpΐHrΓJsΕKuΗLuΗMvΘNwΙN‘Ω„Αη―i}_±Σ Βθ°Ύζͺ‚Ξ]xΛP™Χ{Ίγ¦Πž«‚§Θ˜΅Ψ€¬ΝœŒ§~SdK#KZDΌΰͺΌΰͺƒiΉChΈ@g·?fΆ?™Ξ£Γ“!'‹§~΅Ω€°Φ`­:[©4Y¨3Y§2X¦2W₯1V€/Q›,ΞY$B>t @w!By#B{#D~%E&G‚'I„(J†)L‰*MŒ,O-P-R“0T”1U—2V™3Yœ5ZŸ6\‘7\£8_¦:a¨;b«f°@g³Ah΅BkΈCl»EnΌFpΏHqΓIsΕKtΖLuΖLuΘLvΘMwΙOΐζ­ΌΰͺujΑη―Βη°›Ψ}xΚP…Οa»γ§‚œv 1;,ΌΰͺΌΰͺ΄έ {ΑXhΈ@gΈ@g·?k·E²ΩŸ{”o ©Κ˜΅Ω€’ό[ͺ5Z©4Y¨3Y§2X¦1W₯0U£/9oYλ =r @w!Ay#Bz$D}$E€&G‚&I…(J‡)L‰*M‹,OŽ-P.R’/S•1U˜2V™3Xœ5ZŸ5\‘7\€8_¦:`©;b«e°>g³Ah΅Bj·ClΊEm½FoΏGqΑIsΔJsΕKtΖKtΖLuΗMvΘM–ΥxΑζ―fz\,5(²Σ‘Βη°Έγ£wΚOwΙN Ω…ΆΩ₯"(8C2ΆΩ₯ΌΰͺΊί¨‘ΛthΉ@gΈ@g·?fΆ?‘Κu°ΤŸ7B2GU@°ΣŸ΅Ω€‚Ύe[ͺ4Z¨3Y§3X¦2X¦1V€0U£/ μ^"B@v Ay"Bz#D}$E&G&I„(J†(K‰*M‹,NŽ-P.R’/S•0U˜2V™3Wœ4Zž5[‘7]£8^¦9`¨;b«f±@g΅Bi·BkΉDmΌFnΏGpΑHrΓJsΔJsΕKsΕLtΖLuΗMyΙQΊγ₯½β¬ ˜sΒη―Αη―‰ΠgwΙNvΙN¨άŽ³Υ’  \nS΄Ψ£Όΰͺ»ΰͺœΡiΉAhΈ@gΈ@fΆ?tΌQ―؜›u˜r΅Ω€΅Ω€a­<[©4Y¨3Y§2X¦1W₯0V£/4d_Ϋ 8kAx"Bz#C}$E€&F&H„'J†)K‰*M‹+N,O.R’.R”0T—1V™2W›4Yž5Z 6\£8^₯9_§:`ͺ;c¬=d―>f²?g΄Ah·BjΉCl»EmΎEoΐGqΓIqΓIrΔJsΕKsΕKtΕKtΖL™Υ|ΐεcwZ4>/Αζ―Αζ―₯Ϊ‹wΘNuΗMuΗL¦ΫŒΊή©CO<M\E—΅Š»ΰ©Όΰͺ»ΰͺ’ΝujΉChΉAg·@fΆ?g΅?£‹Π(0$ͺΜ™΅Ω€•Θ|[ͺ4Z©3Y¨2X¦2W¦1V₯0P™, ίj%E@x!Bz"B|$E%F&Gƒ'I†(Kˆ)LŠ+N,O-Q‘.Q”0S–1U™2X›3X5Z 6[£7]₯8^§:`ͺ:a¬e±?g΄@gΆBjΉCkΊDm½FnΐFpΒHqΒIqΒIrΔJrΔJsΕKsΕK‚Λ_Ήβ₯О«ΜšΑζΊγ₯…ΝauΗLtΗLtΖL“tΎδ«­ΟO^G 2<-q‡f­ΟœΊή¨ΌαͺΌΰͺΈή₯„ΖdjΉBhΉAhΈ@fΆ?fΆ>œΟƒΉέ§+3'n„cΆΪ€΅Ω£i±G[©4Z©3X§2X¦1W₯1V€0*PjΧ :nB{#B|#D%F&Gƒ'H…(Kˆ)LŠ+MŒ+N,Q‘.R”/T–1U˜1V›3Yž4YŸ6[’7]€7_§9`ͺ;a«;c=e±>f³?h΅Ai·Bl»Dl½EnΏFoΑHpΑHqΒIqΓIrΓJrΔJsΔJsΕK¨ΫΊή¨FS?`rWΐεΐε­‘Ψ…tΗLtΖLtΖKsΖKsΔK²ίΏδ­Ώγ­Ύγ¬Ύγ¬Ίή¨Ύβ¬Ύβ¬½β«½β«½α«Όΰ©Ήί₯‘ԈqΎLiΊAhΉAhΈ@g·?fΆ>Ο„·Ϋ₯N^G&."ΆΪ€΅Ω€–Θ}]«7Z©4Y¨3X¦1W₯1V₯0H‰' Ψuώ0 Bz"B|#D~$F%Gƒ'H…(J‡(LŠ+MŒ+O,P‘.R“/S–0T˜1Vš3X4ZŸ6Z‘6\€8^§9`©:a«;b=d―?f³?g΅Ai·AkΊCl½EmΎEnΐGoΑGpΑHpΒHqΒIrΓIrΓJrΔJΠo½γ«€˜t$΅Ψ€ΐεΈβ€|ΘUtΕKsΕKsΕKrΔJrΔJzΖT€ΨŠΆΰ‘½γ«Ύγ¬Ύγ¬Ύβ¬½β«Όαͺ΅ή‘«Ω”™Ρ~yΑUj»BiΊAhΉAhΈ@g·@r»N ˆΆΪ₯VhN šΉ‹΅Ω€°Φp΅NZ©4Z¨3Y§2W¦1W₯1U’/%FώuΧ-UB}#C~%E€%G‚&H…'Jˆ(LŠ*LŒ*N,O‘-Q’/S–/T˜0Vš2Wœ3Xž4Z‘5\€7]₯8^¨9a«;b­d±>g΄@h·AjΈBk»CmΎEnΏEnΏFnΐGoΑGoΑHpΒHqΒHqΒIsΔLΉα₯‘ΐ’!z‘nΏε­Ώδ­‘ΠqrΔJrΔKrΓIrΓIqΓIqΓIpΒHpΑH~ΗZ†Κd‹Μk‹ΜkŠΜk‰ΚhƒΘb}ΓZqΎKj»CjΊBiΉAhΉAhΈ@gΈ?ŠΗlΨ™°Ÿ:F5~—q΅Ω€΅Ω€’Ζy[©4Z¨3Y§2X§2W₯1V€0A|#Χ4 e²>gΆ@hΈAjΊBkΌDl½DmΎEmΎEmΏFnΏFoΐGoΐGoΐGpΑG„ΙaΎγ¬cwZƒwΎγ¬Ύγ¬~ΘZqΒIpΒHpΒHpΒHpΒHpΑHoΐGoΐGoΐFnΏFqΐJŽΜo›€˜Ρ}ŠΚk~Δ[uΐQxΑSΔ`‘Μu§ΦΊή¨―ў™Ά‰=I7HWB²Υ ΅Ω€΅Ω€o΄L[ͺ4Y¨3Y§2X¦2W¦1V€05‘ Ψ)NE€$E‚&Gƒ&I‡'J‰)LŠ*L*N,P’-Q”.R–0T™0V›2Wž3YŸ5Z’5[€7]§8_©9`«:bf΄?h·@iΉAk»CkΌCl½Dl½DmΎDmΎEnΏFnΏFnΐFoΐFoΐGΌα©©Κ™CQ=Ύγ¬Ύγ¬Υ‚qΑIpΑHpΑHpΑHoΑGoΏGnΐGnΏFnΏFnΏFƒΘb΅ή ¬Ξœ—΄ˆ₯Δ•²Φ’»ΰͺ»ί©Ήέ§ΡžΌŽ…ŸxcwZ3=.QaI΄Ψ£΅Ω€³Ψ‘|»][ͺ4Z¨3Y§3X§2X¦1V₯02_Ψ Kχ7iE‚%Fƒ&I†'J‰(KŠ)M*N,O‘,P“.R–/T˜1Tš1V2XŸ4Z‘4[£6\¦7]¨8_ͺ:a­:bfΆ@hΈ@iΊBj»Bj»Ck»ClΌCl½Dl½DlΎEmΎEmΎEnΏF–Πy½β«›ΊŒ:F4  ZlR²Τ Ύβ¬΅ή‘|ΕXnΐFnΐGnΏFnΏFnΏFmΏFmΎEnΎEl½EqΏJ΄έ † z%$'/#-6).7*+4'$+   wŽl΄Ψ’΅Ω€΄Ψ‘€½b[©4Z¨3Y¨3Y§2W₯1W₯0C€%χK• C}#F„%H…'Iˆ(JŠ(LŒ*M+O‘,P“.Q•.S—0Uš1Vœ2Wž3Y‘5[£5\₯7]¨7_ͺ9`¬:b;c±e΅?g·@hΊAiΊBjΊBj»Cj»CkΌCkΌDl½Dl½Dl½EmΎErΐL²έ½β«Ήέ§ΆΪ€·Ϊ₯Ίί¨½β«½β«–ΡznΏFnΏFnΏFmΏFmΎEmΎEmΎEl½El½DlΌDΛoΈΫ¦'/#šΉŒ΅Ω£΅Ω€΅Ω£}Ό^\ͺ4Z©4Z¨3Y§2W¦1W₯0Qœ-•Υ E$G…'Iˆ'KŠ(KŒ*MŽ+N‘,P“-Q•.S˜0T™0Vœ2Vž2X‘5Z£5\₯7\§7^ͺ9`¬9a;c±g·?hΈ@iΉAiΉAiΊBjΊBj»Ck»Ck»CkΌCl½Dl½Dl½DxΒT¬Ϊ•Ίΰ¨ΌαͺΌαͺΌα©Ήΰ¦—Ρ{mΎEmΎEmΎEmΎEmΎEl½Dl½El½Dl½DkΌCkΌC›Ρ€ŸΏ‘ fz\ΆΪ₯ΆΪ₯΅Ϊ€―֜sΆR\«5Z©4Z¨3Y§2X§2W₯1U£/- Υ"μ0D%I‡'JŠ(K‹)MŽ*N‘,O’-Q•.R–/S™0U›1V2W 3Y‘4[₯6\¦6]©8_«9`­:b°fΆ>g·@gΈ@hΈAiΉAiΉAiΊBjΊBj»Cj»CkΌCkΌDkΌCkΌDlΌD‡Ιg˜Π|™Ρ}’ΞtvΑRl½El½Dl½Dl½Dl½Dl½DlΌDkΌCkΌDk»Cj»Cj»B˜Π}Ίή¨#)  3=.h|]¬Ξ›ΆΪ₯ΆΪ₯³Ψ‘–Ι}d―@\«4Z©4Z¨3Y¨3X§2W¦1U‘/ =μ"?χ >H‡&Iˆ(KŒ)LŽ*M+N’,P”.R—.S™0T›0Vž2WŸ2X‘4Z£4\¦6]¨7^ͺ9_¬9a―;b°;d³=d΄=fΆ?f·?g·?g·?hΈ@hΈAhΉAiΉAiΉBiΊBiΊBj»Bj»Bj»Bj»Ck»Ck»Ck»CkΌCk»Ck»Ck»Ck»Cj»Cj»Bj»BiΊBiΊBiΊBiΉAhΉA€Γ^Ίί©ΌŽCP<%ER>s‰h“°†¬Ξ›·Ϊ₯ΆΪ₯ΆΪ€Ν‡uΈT]«7[«5[©4Z¨3Y¨3Y¦2W¦1V£/&Iχ?aώ#CI‰'JŠ(K*MŽ*N‘,O“,Q–-S˜/Tš0U1Wž2X‘3Y’4[¦6]§7^ͺ8^¬8_­:b±;c²fΆ>f·?g·?gΈ@gΈ@hΈ@hΉAhΉAiΉAiΉAiΊAiΊBiΊBiΊBjΊBjΊBjΊBjΊBjΊBjΊBjΊBiΊBiΊBiΊAiΊAiΉAhΉAhΉ@hΈ@lΉE§ΦΊή¨ΆΩ€΄Χ’―Н‘Α’•²†‘ƒ—΅ˆ₯Ζ•°ΣŸ²Υ‘΄Χ£ΆΪ€·Ϊ₯ΆΪ₯ΆΪ₯‘ϊpΆO`­9\«6[ͺ5[©4Z©3Y¨3Y¦2W¦1W₯1-Vώa‹ώ"BJŠ(JŒ)M*N+O“,Q–-R—.Tš/T›1Wž1X 3Y£3Z€5\§5]©7_«8_­9a°;b²;d³=d΄=e΅>e΅>eΆ>fΆ>fΆ?g·?g·@g·@hΈ@hΈAhΉAhΉAhΉAiΉAiΉAiΊAiΉAiΊAiΉAiΉAiΊAiΉAiΉAiΉAhΉ@hΉAhΈ@hΈ@g·@g·@g·?q»M¦Τ·ά€Έέ¦Ήέ§Ήέ§Ήέ§Έά§Έά§Έά¦ΈΫ¦·Ϋ₯΅Ϊ£²Ψ Φ›“Θxm΅K]¬7\¬6\«5\ͺ5[©4Z©3Z¨3Y§2W¦1V£00]ώ‹Ÿ;F†'KŽ*M+O“+O”-Q–.S™.Tœ0U0WŸ2Y’3Y£4[¦4]¨7^ͺ8^¬8`―:b±:c³f΅>fΆ?fΆ?gΆ?g·?g·@gΈ@gΈ@hΈ@hΈ@hΈAhΈ@hΈAhΉAhΈAhΈ@hΈ@hΈ@hΈ@hΈ@h·@gΈ@g·@g·?fΆ?f·?fΆ?e΅>e΅>{ΐYŒΗo—Ν}œΟƒžΟ†žΟ†žΟ†Ξ„˜Λ~Ηs‚ΐcoΆM_8^­7]¬6]¬6\«5\ͺ5[©4Z©3Y¨3Y§2X¦1S.'KŸ. H‡'L*N’+P”,P–-S˜.T›/Uœ0VŸ1X 2Y£4Z₯5[¨5]©7^¬7_­9`°9b±:b²;c²;c³eΆ>eΆ>fΆ>fΆ?fΆ?f·?fΆ?fΆ?f·?f·?f·?f·?f·?fΆ?fΆ?fΆ?fΆ?fΆ>eΆ>eΆ>e΅>e΅=d΄=d΄=d΄=c³e΅>e΅>e΅>e΅>e΅>fΆ>fΆ>fΆ>fΆ>eΆ>fΆ>e΅>e΅>e΅>e΅>e΅>e΅>d΅=d΄=d΄=d΄e΅>e΅>e΅>e΅=d΅>e΅=d΄=d΄=d΄=d΄=d΄=d΄E„&W£0W₯1Y§2Z¨4Z©4[©4[ͺ4[ͺ5\«5\«5\«6]«6]¬6]¬6]¬7^­6^­7^­7^­7^­7^­7^­7^­7_­7^­7^7^7^­7^­7^­7^­7^­7]­7^­6]¬6]¬6]¬6]«6\«5\«5\ͺ5[ͺ5[©4Z©3Z¨3Y¨3Y¨3X§2X¦1Hˆ("AΘ5cώ1G‡(X¦1Y§3Z¨3Z¨3[©3[©4[ͺ5[ͺ4\ͺ5\«5\«6\«5\¬6]¬6]¬6]¬6]¬6]¬6]¬6]¬6^¬7]­7]¬7^­6]¬6]¬6]¬7]¬6]¬6\¬6]¬6]«6\«5\«5\ͺ5\ͺ5[ͺ5[ͺ4[©4Z©4Z¨3Y¨3Y§2Y§2X¦2IŠ)5ώc±ύ* A{%Sž/W€1Y¨3Z¨3Z©3Z©4[ͺ4[ͺ5[ͺ5\ͺ5\«5\«5\«5\«5\«6\«6]«6\«6]«6]¬6]¬6]¬6]«6]¬6\¬5\«5\«5\«5\«5\«5\ͺ5[ͺ5[ͺ5[©4Z©4Z©3Z©3Y¨3Y§2W€1R/@z$* ύ±HΚό )O@x$N”-Y¨3Z¨3Z¨3Z©4Z©4[©4[ͺ4[ͺ5[ͺ5[ͺ5\ͺ5[ͺ5\ͺ5\ͺ5\«5\«5\«5\ͺ5\«5\ͺ5\ͺ5[ͺ5[ͺ5[ͺ5[ͺ4[ͺ4[©4Z©4Z©4Z¨3Z¨3Z¨3Y¨2O”,@y$)N όΚHKΎ ;4bKŒ*Y§2Y§2Y¨3Y¨3Z¨3Z¨3Z¨3Z¨3Z©3Z©3Z©4Z©4Z©3Z©4Z©4Z©4Z©4Z©3Z©3Z¨3Z¨3Z¨3Y¨3Y¨3Y§2Y§2X§2JŒ*4b; ΎK-± 62`G†(W£1Y§3Y§2Y§2Y¨3Y¨3Y¨3Y¨3Y¨2Y¨3Y¨3Y¨3Y¨2Y¨3Y¨3Y¨2Y§2Y§2W£1F†(2`7 ±-‘σώ* "B.X7h>v#E‚'IŠ)JŽ*IŠ)E‚'>v#6h.X#B* ώσ‘VΎνύύνΎVS”ωω”S 8…ΛυυΛ…8 ,n‘ΖέκςωόύόωςκέΖ‘n,€πόπΐ?ψΰΐ?όψπΐ€?ώόψπΰΐ€??ώόψψππΰΐΐ€€??ώώόόψψψππππππΰΰΰΰΰΰΐΐΐΐΐΐΐΐΐΐΐΰΰΰΰΰΰπππππππψψόόώ?ώ?€ΐΐΰππψόόώ?€ΐΰΰπψώ?€ΐπψόΐΰψΐψώ?ώ?caronc-apprise-182f859/apprise/assets/themes/default/apprise-success-128x128.png000066400000000000000000000420461524161175200273520ustar00rootroot00000000000000‰PNG  IHDR€€Γ>aΛ pHYs  šœ IDATxΪνwœ\guχΏΟ½Svfvgϋjw΅+­z—¬fInrΓBΈΔ&ΆΙ›` ͐@z1!’Ά‰ ¦lƒ+ξr—mΩ«fυ•Άh{Ÿ²3sŸχ{gζΉeTŒdœΔ>«έΉwΟ9Ο)Ώsžsΰνρφx{Ό=ήG‡ψ_ώ|‹σ•ΐ` P”A@·ζ@Y $q ΨΌ< μx›ޚί?dup)p°Ÿ‚Ο‹»€Gϋ6‹Y½ΝohΦηησ€ΐα{L{€ΝΐSΐσΐ‘·Λ©>ΰΟ,qDNτ;ϋ+K6D Φ—¨+%PΒ β+ ’•ψ>ΜHŒd†L,Ef,Iz$Ιdίɞq’GΖȌ$Oτ;K`ψoΰ‹ΐθΫ pb#`‰υ―—σ!t αΧΠ#"³ͺ(]XGιάZBΝh>-―œ…pSIy-)hu™1Ht Ϋ;@μυ~β†ΘΔRΘ΄ΜΗσ<χŸ·ΤΕδΫ P| [ϊ½ψχλ”4–QTNιΌZΚN‘djΤAhαω΄’ Zr}T*'“]cŒοκ#Ά§ŸdΗ(Ιξ1dϊ˜Μπp#πΈεqΌΝΚx§%κWXξ™7‡Τ—R±¦™²S5E Τ΄‚πΒωpΒNr―₯οA}ϋ!'#€D29'Ω5ΚψΞ^FΆtκ‰ν9³ΐVK5<ό6@πcΰ@+φ¦ΘΒZ―XLxVZΐ‡ζΣμ„ByQlρΫΑƒ $Ϋ‘s1€T˜C"%Θ¬‘J3q`ˆžίξ"Ά«οhΟmοFώ/2€Οzψ[Š}Π([4…Ζ?[Jhz%š…en\ˆν Μ σ@εΑ“ΨRˆό²υo~@žάR]ϋƐΆί&S$:G9ςλνŒoλΖHef0ώ΅΅2W`5π=`ΧI½7<«Š©±‚’†(z@+\M˜žyQο$΄“θήLc·ύ/w[Ψmι%œL‘0„ωZ"ΣρQ:n}‰ψ‘b*αyΰβ7Γ6x³`Ήε 79Oh!΅ζRχy‘.Π„@hB ™Η…Πk?§„bχ •μ'œŠbφ>n^πΠΝΉΏsΔVmGσœ" θΉo½Ώέ…‘ςTύςκtx…ˆΉD~d~-υW,$<­\ρΊ4]C8VŽ)vOΥώΎπ|JSϋ ŽΓ‘6[ί₯ϋƒΘͺj°lλ|ξ2)%‰ŽΊ~ϊγΫ{Š©„Ώ>UΔΡO1ρΏ άd<…Ιχ κ.]@γ•KTEΦκšΘλ}a_Λ‰~MωΙ½Χϊ-Dα˜ω·"E„rLΣLΙPδ'ω:^μ5ΉΧRρJ vˆyΚ’TΉΧͺδΰ–PΎz*Z8@lG―"ϊnλχ““$€ό§εήΨ|{Τ™φ7§S:·Ξ"¨eΰε˜@·ˆε³ˆ₯™„Τt‘[λκ„’Έ‚9H»κ·V«( {Xϋ6ΰxuΎ"dώ,°{ †T7@b?0ΔΎ―=…‘Θxa·Ά€€·΄(Ύi}YΫT¦”2λλ 5DMB Π4 ! ŸmU[«λ5–+(ΦυެH°€€mU‹‚ΚP~ΜcZ~»%ƒ²Ϊm8„}5;+ϊΚVYΰ…r+ΰ― SsήLΖwφ:έE¬Β Œm:™xΑΙfŸεΖό£σDΩz¦ύυιψΒ~K4η­ηW}Žΰš%κU†Π”σ1lM΄BδόίPhcΝbΨEΊͺςβ;woTU€M%¨ͺηkXU`;Œ­}TœήΔdΙ1η4΅l©',©π–S·Χ:Vž9Ί‹η‘—˜ΔGΧΠ…Ώ-ρišεξΊ†εŒAΣ0,`φΥ”Ÿ^―X€pΫώR… dQό§€ζT€P-z˜©£.XI4! ωΡmgHΙd6ΝD&ΑHjœΎψ ύΙ ²« )₯₯ Ζb6‘¦ηžτ=ΈΗλΫέΌ­ΖίČβΩξYuώLκή9ίDσtέ\=ΊβΧ[Μ€›Δ΅ά@τœ$Πy0%BnUΊ@ΥυΛ3ˆΗ㠏p»~BUύŠ[υ…9Ώa5΅%&δΌν p:pπΝfЊ™‚]pυ˘φ·kΡ}ζ*V0'νͺDΝ―j τΌ‘§ΉŒ>‘ky<@Ψ\='αXXYωώRΞ«_Eu jΪcde–£Ω2ΈΛMiΗ s†ΫΩ΅§Q9δγS—|ˆδDβι]Ώ²Κ(—όΥ•lΌφςΌάIf&yθΠ3tLτ"-&(H‰aψΖfbΫ]€Ρ.`Ωqΰ{ΐ»l7+ 2ύCλΠό7Ο$†VpρTέ­"u&—(ώΌfCψL{»Ν μΎ}Š»V’Έ΄ιlͺ‚QŒŒA&ΑΘMΰΣ}Τ‡ͺΡ„ ;9hΏŸj;(~™?ΜάΊ™<φΣϋIΌA†Ar"ΑΆ§_‘uσΛΜ_½˜HEΏ¦3Ώjc“τ'GA+8(_ΩΘθ+GȎΫ2Κjΰ‘7‹.ΎŽ’ϊ¬•ψ˜ϊΎψΛKLΓI]­š›π9=ž#.BS|}KΜη€Keδmΐ1o’…Ψ ΔΣ*ηRΪΐαέΉεsίαΏ>ώ ωρ½$'Μ\2`0HΤ‘+ΡOΒH)ΕΞ Bh$#ι ζW΄0cξ,ΪvξΗ PU_C]s=υΣ©kͺ§ͺ‘†²Κr‚‘ FΦ 3™.:‘Γ½ƒ<{ίSΤNBγœihBНJ2“€7>d~I‰πλ„[*έziO2Yb„ϋN΅ (^ΖΜΪ΅¨/¨»d>ΡMfμ^hŸ%Ύ…z.ΐ£wΊfωύ–k§ EάΤD! εUAžy\ΩAnΧπ3.e€w/ΎηcτΆοΩ8ϋŠ ψθw?gΚΠ±6žΨa ώδ`Z„= )TΛ9u+Ȍ$8OYE”²ͺ2JΒ!€Γ0HΔ&ι¦―£‡φέΩράkl{ϊ•£NμΖχ_Α{?υA„d₯Αγ/ςϊp°0‚\β‰4θt?]?Ωκ4bχXˆaμTJ€oZ ΐ‹¦PuξL[ &αη\>‘ΩΕ~Ξ P _SkΆ@N.€£)v€¦ΩU€&΅‚C΅Μ‹Nγα–-Ώ{ΖΓzu1mώ ¦ΞšFm°’Cρn’Ζ€… bsMσ¨ŸŒ₯'x}μ?ψ«BŒ’μOtΡ:ΌŸW‡χπϊψazC₯3šXΉv5+/\Η—ΟψΠ(]ϋΫ='φ@λFϊ‡Xrφ*|ΊΞμςfw3‘I $Ύ€πΜ*#€ŽŒ«·¨±θΓ§ŠV?²έ 4@Γ{–’ύω S‰Š’L ΗƒˆšϊδΤ‡°"KbΔ!`d‡bi`‰ξUUσ©τ—qίEΧχ€g3YF†YϋsπΤ+Ψk·T Ά”`ώm`0‘=ήKg’ŸΤ(™)#M2›b4γH|€=c‡xmh/ŸΑτ†fΞΎτ<ζ­\ΔφgΆ’Š»ΣΓΪvξ' 2gω|„Πh‰6°kθ #λΘs””―nbπΙƒΞ|‚5VβΝ‘“Ν>ΰ1‹Λς£ξOR5‰₯†e=Ÿ3ΰ4ržjδ)" ²ˆnJέZ‰ͺ P€Νa OΣ9³f}mGxθΆ{ˆŒ{>Tg/Ms¦Σ²h=D<›b0=Š&„ιͺv…΅«ƒεΤ+¨+©€:XNu0Je J©?D‰•Y™U’N zΓlΩΟd6ΝΌΩsΉψ]Α‘ν9ΠαϊN;Ÿ–ΕshœΡD@χƒλRs]ςq„Π¬*†7»\Γs€OΌΰDΰγΐΥͺέPvZ#k§’iΚΚΞ;ͺ¨†šb šφž’­kΝΥ^Pš-n Œ€ε½ ‘ f—63#ΐη[yτ§χυΑ:χζά+7¨-©ΰΰD7i™΅^"zˆ³j—±¬r ’Σ™QΪhώDΜί³K›˜^Ϊΐ΄H=ΥΑr&2IFΚBυ%‡hυPUZΞ†Λ6’I§Ω»υuWϊ΅M[X½αLΚ*’”Λ8ο'–ž°Iΐ_";‘v¦—U[vΐs'‹¦[Ψs­*ϊ§\Ύ=θ³VΉΓ5ΣΛ\Γ–Δ‘ΖωE.(€09·PW‰›K‘ΐhs+*η‘%άϋƒ; ،βX|Ζiψ4>M§3Υ§DAHΑegΡͺ! όωk3ιŒΙΔΉΝG©/Dm°’ΉεΣ© VΠ“0Κb‚€1ΙΎ±ͺƒεœuξ9ΔFΖ8ΠjϊdlΫΓyWmΔ'4|ΒGΫx—’3ΐΑ_a|{Ω‰IΥΈŸ†Ή…ύ¨ϋ΅γτ.ζ«+ΦMG ςA2ωx*ZˆΧ™ †yZCH©„\sI ^Ÿ½JΣΆ²ju†d•°nM œΊ`%ρ±Οήw|‰4wώϋν tχ#„ %@m ²@| ’-Dύ₯ŒτqσηΎΓ_­Ό’+›Οηκ™qε΄σω‹…—π•χ~’Η~φ£C#HΓΐ―ιΜ,Κ5-˜U:ΥTa’]?Ϊύ"c]\ϋ₯3oε"Χw:όϊAžϊΥΓ s+§S(Uπ‰6š₯rέ4ηζΗyέΔ*Κ­0oUξ€Ώ”ΚsZl†ŸΊr5ΕPύχ‚d(„ƒs–~^”kMθ./’ΰ%˜RDΕμ©Z‚yeӘžΒ?Ί‹ΦΝ―·ŽkίέΖyWnΐ―ωΠ…FG²/ΟΠ‹Λg•%όπ3ία©_ύΞ…f&Στ΅wσΚ/πΐ-w120Lυ”jΚλͺΠ…Ξ¬²©H=ρA3ΜlƒγG¨UsαeΨτ«‡I%R6ΐh|h”ΥΞ$ Τμ+Ψ BΑ‰BΣ+~Ύ#nΓY΄Kύ! pπAuu—―i"ΤRmzΜU¨ 45‰%ΗOΣάΙ*Ϊ§ζ",―ε‰i “ε˜Ζ’š&πλ~ΦΧ,G7ΰλοι£€0^αΤΩΝL›?“ͺ`”ήΤ 1#‰0#άH0©qλn":ϊ_)%·νεΩϋ71™L1gω||>?SΓ5DόaΕΊA)HŽ0§j-sgςάύ›lχfφi h˜1•šP― μ!k8·Ά ŸŽς1ΆΥfόW»-Θώ «€mρS~ΪTςΙ8ͺ’Έ²v»u oWΣ¦€=Ÿ/χŽ|ώŸ’ύƒ°ιfΤ!˜WΪLXςΫάI||β„|\#›εΑέΝΨΐΑYΥΛςΟ’Θ&Ρ}:Υ uΗ}Ώψψwχ§|ϋΓ_΅˜F0/:•UσmΡΚ‘Ι1φŒ΅³pέ2VœΏΖεͺn}β…<#/¨œaΊ„’€Z½~)₯G₯ί‰2ΐ5N·―βτi ˆ+…vΟEYIω½zR(Θ•’F‘Έˆό·œ6…u½ΐŒ“ΫόtKύhB°Ίr!ιΙ4άrΧΒΈwΏ΄ƒ}‰€Μfit6šμw‘…ύ\ύ‰k •†Ρ4 έ§η4½ψTn}βΎώΟY ©ΖβͺYΤ—T‚XBπΪΰ^πkœύ§Ίερςͺa^e jͺƒ=±^βΌΌΪ’γR? xΏŸΪΛD7t{ΊWtΆν·&liί›ΌhGθ dΎGGΣνͺC³Ωδ“?O―XΘ”’JξΏωΧΌτθs.δοxΗαέmœsΕ…”DBΤ*8οf4kJ“₯ —°αšKXqώώμcΙUt-ίw9ΛΦ―¦΄²Œ‰ΡγΓ\>ϊ:zξdελπk>4M£=֝ηύ¬ΜR,£©ͺΟ½Ζθ`aSΠd2ŊσΧPέP‹_σΣ:ΈOQφ­ώͺ0£[]Α’™.pB pπ 5ΰS~ΦtJ’Š―ζκ GdOΑοsΖZΞuS\@rz_‰π-Ÿ'€EZ>;Έ6XΞΪͺEŒŒrΗΧn.š€qϊ#τ€†Θ!άXA"”₯ΗaXSήTΝΩηΜ‹Ο#±{ΛvΪy€ΖΩΝ4Οm‘:XΞΑX‰l*OΒρτ«›—°ϋ₯t졃;ΡͺrŸΉC΄w3žŽΫΝϋœI i5·‡Œk¬DŽQW‘lθ~Θά:₯o‘RŽLœ|wMͺιΈ²rε–Šn)ˆΌ‚nΙgώ"Π…`Yt>‘σάƒΏηΰφ½pšΣ}?Ό“Άϋΐτp=uΑ*0˜cϋx/ οδΕα]lέΟφρƒ<=ΨʝOΠβκ_Λ§ώΫ½ΏΣ0 Ύύ·ŒŒ"€₯•slI,©QF†YKη’ϋμksο«―›+VhT£ξβ’`7•.¨Γ_Rίΰ·θyά6@-p–z <§- τ΄mΏœ(VR*έ:μ[Ή]»zm;rh—T"}3hφDιαzšBuŒρλoδΈ‰–ΆΰΊίCHΣ€±Si™αεΡ=<7Έ¬εΊo|ΒuΟύ―νf«»Mƒ°|Ί}›ПfΪό™ψόφ"cν―4ΈΠˆψCΆ9ulŒ£€ΉœP‹λ™6¨@ή±` ψšQeζφ)΄Β+·Ξ"΄jμ©9{Κ)‰°΅΄UππΚπΝmγ/0‚Ι,!=ȊςyhBπ‹oώ7Γ}ƒΆ©©©aαΒ…ΌσοδŸωŸωΩΟ~ΖΆmΫLWνΰAξΊλ.>φ±yNBΫΞύ&Œ, %\O}IM~Κ5Ϋ΄«ΐ 쎡σzμλ―Όˆ%g.w!|­›_!“Ξ˜%`³’£Τ6MAsH5ωΔ§ω¬U€νsσS¬ ’§5˜›k c–EΧc2€†Yΐ!_iΣW"Pq˜ϊΒ]HIδΆ^+(……-RŽ}šyKVρΥg0ο<'Ν½ ³άjAΤ„ρ•—Έˆ'νkΗΪΞ3ƒΔ΄Τσ{σPαa§‚Κ1ΕΞΑBηZΒυŒτsσgώέυ Ρh”•+W;$ͺλάzλ­ήR`Η~žΉχ CΦƒ¬ͺXPΨu€€aσΙMΌΏ75Lσάjλ\ˆcL*χ—V†;ϋ~wM;Λ†ΐkΦ:G _΄„πœ―L’c1@Θ 'š‘ JšΛ«]8Ώpό-\IGΖ±R±…½Τ‹r-άο© T±<:Ðάφεdΰˆ» Sii)3gΞ<.{ΰΜ3Οδͺ«ςŽάx;cƒ#€`nΈ‰ _™²g°ΐN΅5‘Ό¦’ςΪΚ’«Ω'|6Γ:μ 1™HynRυM›άΖ4…y΄O^εΩӝ·XŽYlσ¨ °ΘΖ%ΊFIsEΑΨΜSRΑ!;@ΊΘ/\{τEΫ±&K:―"ΟXͺθ@™/Μ™U‹Ρ…Ζζ{+𩦢ΆφΈ@Σ4>ωΟ‰D\ηb#γόό†[t‘³,:ΛTΆ|—6C"ρόψ~Χ=S‰”mηͺΆ€‚ραQe΅F 0Λε²–άώ˜Ν)ˆ.oπΚιX~,ΈΤvEY_YζHwμPzlΚ°_']0±%toέΚοΏ·΄5‘qFΥΚύe΄οnγφλ‹LΪ;Ϊ©λ5ζϟϡΧ^λyξ‘;ξcΗσ­MΑ:‚ΥΚΖO‡ c­Ζϊ’j“iΟxD °φ£¨UΣ{Έ›lΖΎ ΌͺΎ&_Ζ.–NΰΒSm6€yLσλDΈΐeΗb›ώ6—ΫΑ…θΆp„–ށ,p†΄Yφ²ζ΅I}%ρ‘#ζ‚)«˜ͺ%Jρ―ωFϊ‡ŠtέΊu'Δ>ŸψΓE₯Ζώω[H >MgqΩLt‘+–Έύw@ψ¨ –3:8ΒΨ€»ΤOΈάΤύc™x4i ™Ÿ{hΧ2i;Σ΄,œ•4NΖ±•beΞΡεΞ~η±`©Ν"œV‘θΆ’=_9χ^:ΉVζ=‘Δ„΅)W:08·vΝ‘)H)ωά‘·ύθio«V­ς<~Ϋm·ΡΣΣγν.XPT thη·ί%υ*šJjm&°*—”ΟΖ/όtνo§ΏΛΎ‘cκμiy΅Π—Κ_·¨b&8ΠΊΓQƒxΑš₯ #³ $GΌ¬g;˜—.v―fV8ίœR–7Ϊ Ϋ€Ό“?r^ŸpzυjBHΗxπΝ½GΣtΞ¨YΒ¬©€S“|χΏΞΎΧ^?¦N_Ύ|ΉηΉνΫ·σwχwE―ύΪΧΎζ)²™,ύμ~ϊ:zΦU.Ζ―ωμA`J°ŠωeΣI''Ωτkw‚ξΒ5KρΝφνρ^ρ•0-ROχ:χv]³μl“™ϋΓ.ΠΜ ΓWB »lΕΐΆdτ ψ5OύοFρ.Ώ-έΖ‰”nξNͺ+o9»ξ4”· ŸώΫ-lΊλ‘cŠσE‹ψνήΝ½χήΛ¦M›Šͺ‚οϋžη:χζΙ;Β0 ΒϚŠJdW  eΣj~~χ£l}ςE—j;mύjό?νρ22–UΝΕ'4vΏΌƒžCvΙΦ8³‰κ“!w’1Ό]\Ώ–/’νEη£J€@]QLΝH±#›9eΝ€ΰζΟ~‡ϋo9Ύ‚YλΧ―χ<žΝfιμμΰϊλ―'“ρ6 ―Έβ 6nάθyξξού”Α#}  Ήd υΑκΌΨυ Ζ’Z:φβ–Ο}Ηuν¬eσXΈΦΤ²/½RPͺdnY3ιTšGϊ€λš5Ο!’•{GΪ)ZΡK±ŽλΑΖ²’vž“lp˜Ώ:\,εΕSHιQe]q uwU) lβ"χ*κsυŒ ԇ̍Ηίώϋα‘;ξ=nƒξ¬³Ξς<ήήήΞψΈ™"ΎeΛ~σ›ίU!_ψΒƒž.ά-Ÿ.π ‹Jg€0!π MΏ~ΔeΙϋ~.ΌζbΚ*Λ9<ΡΓpzœ€ξgmυb‚z€ΪLΫϋΞpY„₯g­@Σu3gΰXCΨ=1‘ ‚ξ$‘ΕΕΐ–ψι« Ω . .‡³Fž½&ŸDJQ΄Ÿp0“p‘f•5qyΛΉDaF‡FψΦu_ζ™ϋN¬JΪgœαλ?|˜XΜ΄’γρ8·ί~;cccžο]Ήr%W_}΅ηΉڜουΑ*¦…¦δη%‘MQQ[εΎίkΉπ½3idΨ6ΊCJVUΝ§>TΝ`WίηoΉ]ΣΥ‹™΅tY™eΫΠ>ΌL*PΌιΐ 5ΏΚ΅›‹1€­ρ’Ώ<δΆψ…ΛUNIE H»δWάJ ”JΉ`κιœ7u!=Θαέωχ /όnσ Ώ¬¬Œ©S§zžλθθΘK€‡zˆgŸ}Φσ½@€λ»Žͺͺ*Οσ7φ;$'βθBcIΩ,4©“1²œ8ΒωWmdΩ9“jέ%λωθMfVЎΡτ&‡(σ…Y\>‹‰Ρ7^w½ΛςΧύ>6^{ώ`€νCϋ‰g’™N{ΛCθ₯~°†ό*2”·5Τ`ΦτC* ™·Ϋ€·±hHεΓ₯,lY]·ˆ₯us ωƒ `Σ=ς³oάΒθΐ‰—Λ=ύτΣ‹ž;|ψ0©”=Iφ3ŸωLQ}ΏfΝΊκ*O£°»­“ίόΧ/Ήζ“ο§Κeni―Η³'~˜¦κZ>ώƒ/ΣΧލΠSκ «θ IDAT¦7 88q„­Γ{AsΨtΡ~ύνs`›[Ό―»x=‹Ο\N,gΟθαž€bNΈΐs/VβG/ρ«ϋ|½¨ lC–ύ:Β‘η–―–%½AΥ4©2 †‚°ΧL‰TqΝ‚¬_D‰/@<γϋŸΎ‘οκ[oˆψ+V¬p‹ι4ννξ=‚­­­ά|σΝEοχΝo~“RSH²ιGςΩ;k*ΐΗD6Ε}=Ο²—#Τ,h€~~3γΎ$Ώλ}‘'ϋ_ΙWΡ„ΖΔXŒΆϋ]χ¨­βοΎρ €”μν0}Δ?#)ΔΪ\b@ ϊΠJ\MΜΒNP†’"¦τBτvν€r@Š„Ϋ[¨‹Tqώτ5\9οBͺ‚Qb£γ<ΠοωψΖΏaΣݏΌα|Ύœξφb€X,Ζ‘C‡<―ωδ'?ι’ Ή‰DψήχΎηyηPOύκa²YΣΰ;³z‰dlΫΟέG6ρ‹ΞΗΈΏϋΊύ6ι8<9NI$DY₯έM ”ωτm_EΧ5ϊSΓlιίa#€ΛQvΞ•΄ΏG ˜M΅c¦SΨ@uŸή΄r₯π \ΐ΄ „iBΎk§ŸΖܚΒ3ΔόΟρθ/dΗσ'§0v±`,cίΎ}y+ΏΌΌœŠŠŠόΟ¦M›Ψ°aƒη΅ο{ίϋψαΘ /Όΰ:wίΝΏfύ»70mώ šƒuL-©£#Ρƒ΅£Ώ¨Υοc$γς]Γθΐ‡^?@MC-οωΨϋhY8›Tv’GΊž'#"ψjŽD2—j†"Ν΅€ξ\–ΥN°«]? TBΙ ΜΙ“<ρtZύΦM_F‰/ˆΠ}=άρo?dΗ ­L&ON­–––’)^Ί³qγFhnn¦±±‘©S§ΠΠPΤΠSœ/ωΛ\rΙ%.μ šδŸΎ‘―ύφ?Π„Ζ’h =Ι&eΖž8αρΗz·pΙβ³ψψΎDltœP$DE]5™t<Νψdά±Ζ€‡€U*ƒ«ψ₯Π5—:ΗΪιεsΐΒπ ›[‘7ζ$ήhnбVm€Š‹ζΕ”hB #Λ=υ ξ½ωNNφhii‘’’Βσ\}}=7ήx#‘Pθ έ{ύϊυ\vΩeάsΟ=s»^hεχw?Βϊ?½ˆ†` M%uœθςΩJΉy£“1~Ρφ0 ΛgX_ΓΈΜ°{`Ϋχ’Ν―όγS‡BΨ[Ϋ@({9 šΝΙiΫU†ΜΫ{ž’ήͺž’©N‚‘ήΡδžeσΉ`ξ:„€δD‚Χ_ήΑΟoό½ݜŠq4  iΪ&>@II ϊΠ‡xςΙ'q¨·_}V]xαh„eε³ιŒχ’2 Žb₯ΉζkyΥY$Ϋ†χ³mhœϋbD—ΕI[V PzίΚη4muedΦP,~/ΑS€ΧFUήμ¬+‘R²ύωWΉω‹ίαΫψΥSF|03}uύΤ΅AΈΰ‚ ΈπΒ =ύτρλ›ξ ώ2FgΈ 3Y2vJ ‰xfχͺ#‘HΠΪΪʎ;hmmeηΝτυυ111Αψψ8±XŒT*Εγ?Ξ–-[<οΡΨΨΘΧΏώu>ς‘ΈΞ υ πΨΟξη}_όώ€ŸsjNγΑξg]œ”Ξ-Μ΅Ÿ)&iR!—tcC‚ΜO4& 䀫ΟΔ ΣΆ―[ξ BLyοrτίκκ‘[₯^­ ΉκέΦοYuΣΈrΥF³Α“€Ρ^ξήφBόεͺ?‘³υ 7~δ+§”f̘Akk+eeθmmm,[Ά,πZUN{αξ»οζςΛ//*5V­ZŎ;<Ολα[˜±dRlκ•±N€‘+υfVώ,”‹7 D€‘?Wμ)1 ΅Ό|ξϊΒkυρΆaη9΅ …ΞžWUΐ0J©Q™•n½!έ ”PŽςŽg’!ςؚ*κY0e&™l–η·²dέrΦΌγ¬SΚuuužΔxφΩg·OδQ†aάpΓ EΓΕΑ`/}ιKE―Α§oΜ§Ί-*›APψQ}5‘ŸOiο,Vμk©šΧ&ω₯«q¨#™ΖHΊžγˆW0Θ–`gΔ'•τ˜4inΞXήΌΚp”ƒ|οΣ7ΠΧΩ‹”pαά3πi:ϋϊΣ1ΪΓU½Φͺ”}jΖΡφlέΊυ„οΧΪΪΚoϋ[ΣbJ&9xπ ›7oζ—Ώό%ίϊΦ·xπΑ‹2άώΧvσΐ­ζυΪ`-α†ό<šN–΄£5ύλ:κB]Υ­τy©ατύνF{6‘v–’3Όl€ν(9c™αΊ2oάΑ*ο))ατf‚Íϋ £ƒ#ΌτΔslό‹ΛΡu σΞβΑ=Ώη•Όkή9\ϊΑ+Ήσ¦ŸX»vmΡsΕτω±μ…/}ιKάzλ­ 266ΖΘΘ£££EacuάuΣœqιyTΦU±΄b6c]$³“…¦ βHοPΔΓ3\ “³Τ}̌M:/ΟZί%vͺ/&‡K&1{ψΩ·;Ώ§ύΑŸάΝpŸΉ={NνtšΛλiμ€c΄‡5MΣμιo:lίΎύ έsΧ]<ςΘ#ΌόςΛμέ»—ΎΎΎγ">ΐψπwέdnVϊ#4†jμA5•l6o_Λ…N"…#ΒΫωs°„©ΖΣJ)1ύS‡­’’νB« Ak%eΝ θοκεΉ‡ = ’ρ$w~Χ\εšΠ8½y >‘σθΎg©˜RΙΉz‘YCΰ$Ώίοr'''σψΏjό½™γΥM/™₯a%T*l]EΙύνjL‘dMIwŒEΝͺrεHϋFΦ Υγzφ}64H/Ϊ ξ™θ™;4³ Α‹=CʁεΏόΤ ΄>ϋ ΛΞZEKΥTZͺ¦²¨ƒ'φΏΐ†«/αχΏyŒŽ}‡NΪD/]Ί”žžφνΫGOOGŽaώύŸŸŠ)ΥLf'y²ηeφw8VΊ΄‰t”žy»Ÿ―z…‘BΣIURδήη( π¬+$θ T•³&{b¬΄%%/ ™I„ρωύG%ΖC?ω η^quMυ4Fλ˜]=Γνμ:ΜιžΑΣχ=IΫΞ}'…π³šyχί9λ.YO T‚! RFšξδ /φο₯+ΩOρ5ξ6qj.£ΔήI4‘M™E­Υ)€`j¨†ͺp`΄χ ϋΗ; ]A__5κ€eWΩ BŠΤ‚Ρ^ܘŒ“ξwIηί©/τ"³°³NH‰ P-vΆŠ3Φ”U2½ͺWŸyΉ¨έ‡°nγz4MP ³·0m#œ>σ4ώ­ΟΌlΆFy#Qΐ² Χ.γΊ>ΞϋΏόa¦-œΙ( %°ud//μ¦=ΡΛxf‚£o§*τ·EΧT=¬κ^i™fJΛΧ±τ=‰Ίƒ ₯ΖXŽ]”«ΗlUσœ°΅3ν^Ί=Šƒ =ΎŸ‰έry`-ς’ Ε¬1›― 4AɌΚBΏ?«ξŸa¬Y°‚/΅}Έλ¨Dκλμ‘‘₯‰¦YӈKIΣd$γ’³/`ϋσ―2ΨΣΒΔ?ύgqν—>Μ{ώρ/¨jcwμ0[Gχ°cό νρ^&² ;ςfsU#J:πxi3ΚΤU¨2BšjΔ£pξΌαϋΞ–±**X ~q€Dω­2Β\–νΠ}ΗkdΗmuϋ―‘.†Ζlς²#=” 3’(0λKtχ’LO2gιόγJΒψΥwΜΔψ YΧΌ Ÿπqh€‹CΓ]όυW>zB„Ÿ6Ÿ½ύλόΓΏšΕk—±g¬_u>Α–‘]I δ!W5*–ŸDCΪ&QͺβΩ#ς–'ΎΪΥSζ:{*Χ¨«ΦΕΉΉS―/\c8™Μ°‡Ž₯t€½Š;))v-‘h„­ƒ»yΆ§•Ξx/Q)kk—°²j3J)ΥC„τ υ%Ռ₯γ NŽ:V‹΄η-:rν8Ό΄ηAHιZ•ͺΑ¨ςΌŸ―VΉ^*:5ΰcϋf“ΨԈΔ—)` ρ½ƒ >ΊΏΡUpnΒ±yμh 0ό J΅p™ΞšYE&mIΔP|„΅Ν¬\³ŠΦη^Ι—@+ ΫξΪΗΚσΧR^]I$b"ΰπh7_€ε‹–±ϋ• υ PQSΙ―—_w ΣηΟδΠθξ?Ό™Γ±n&Ι°Άn1η7’Ά€‚€ξ'LσμOQV%\‘)\GλΘ~W΄ΝS$Ές₯M2¨ΊWZypΎε™·J(θ{ιXՊ=’3<μ•±œR%η#ΑHg|l?‰ΓN‰ώŸN ΰX Λψ š^P‹ΠσΌ…i#Λ@lˆεs—2oιBΆΏπ*©Dς¨7ήΏmη_ωN4!ˆBξΰΰH+›Fiš3ήτ9¦ΟŸI‚IžθΨΒK}»4FjΉ¬ε\¦—5  T"Ι]ί½ƒ―»žηξ{ŠΚΊjζXˆi”ϊBžθφ¬£γ,ΆnΫs#UΫ½^J{W%znε^+ŒβγΆ(žΝέ”ΆΟ^‰—·‘MrδΆW!kγφΈEΗψ‰2@'π·Xy‚F"ChFeΎh²idΑxr‚ώρa–Μ[ΘΚ³ΦppΧ>ΖGƊ¦^ PQSɌ…³) †MΕθ‹0e͊Υ,>}±LœϋyψΠs OŽQ©fMύRΞlXFPσΚγΟρ΅χ–Χ6½”/·Ώu7g_q!‘²R*όet'ˆ₯γ6rγ¨J`ΧϋNϋJΪ (½@ηŠΝ©μbŠ*‘v―2ˆj[¨οΟΏΟ(0ΡΐC{‰»}»€;Όθp< τ0ϋš A,Epz%z؟χrαP|”C]4Omβχ\N(&•HΊ 8ηΖξWv°ζg.+EJƒC OŽΡ6IϋθZφq`¬ƒ_ g5.c]ΓR¦DͺB°υ©ωω7nεήάικΐ™N₯aΝ;ΟF (Ρƒ΄MtcHΓαϊIΪ']±wΥPs¨D•.@:ΐ#ΥΈS @Λ•΄>#²v“6Οπ@ώ¬ι±$΅ΕKΥ½+ ψ0ΐ!ΰoΘ•͚;WB3*-[ΐ\MΉyρt’=½IŒsΑΉη³ςœ5œvΦjZ¦‰–.‹­,gΚ΄F¬\ΒΤ™ΝTΤVΡ5ΦΛΑ‘.2RŒ¦&HeΣ,©ΓΕ³Ο¦‘΄Ÿζ£ϋ`'·~α&ξΏυ’=xΑ¬Η;wΕκ[‰ϊ# €F™Ο‹,!₯sαzƒ6ι`sΓμƟ*ΆιpkPƒχRΉΏα’28BΖΕh6#QJΊό©Wμ1ΰ;G³~Žgό― …ύTmœKpJi‘α³nΑΔV\‡Ο-‹X1me%³u¬ΥζΥμφRbι8νšΑΔ¨ΩΔJ7―+­β½‹/Ξ—›Ώϋ?Ζ½·όŠl:s\_ΊΉο=ύ„τ'‡yΰΘ³€eΖΚΕ4ϋš“.<¬μωzΞ$ ΦΊKEHT.ιŒΪ=‚<31ƒ# iζ_Θ‚oζΠΏ=ν•Ώ³­ΌwΠμ8ΰ—ΐ—rΐOίΥG Ά),Δ°6!ζΏ ^mίΕ«») E˜­&\Β§ωde†αdŒώΔpΎu,Œ4JΓ`ve3HΨώό«άϊΕ›μ>1¨xΰH/χ~N.Ώξj³“gi3;GΫςE( +ίΘ3+,d)Y©TΉ°YΨc..½A#d¬2–!]ŸέpΫ2“eπ‘½^ΔΏ³m\ΡqΌ›θ’–©±AM+iŠ’—•δΫΏδkΨΪΉ™§³iF’cτO Ρ71D|˜Αψ(ρtΒj§?΄ΊŒ§³TΞΰ—ίΎΫφœpœ@JIW/ΛΟ[C€Ό”ΖP-»Η‘62._ά—wδΧI%ΧJέ}+Η\ΈΎ !Ϊ=Σρο€λζ—½žΰηjVΧΙd€œAψ©ό΅ά?n©D”θωHKžθ9‚::^ˆOΎœM](κ±Ι-Μš3›ηϊύ’έm4Ο›AΣμi”ϊΒ $GLΩlaWGΞΎ3yΓ)²ρDνΌ―ž7\*ΔξxΔ¬k'‡tώΗ‹d'^ώ™xŒ;9 zΤ„#‘!›Ξš^Qη ao©’)Ÿͺl­W aηΙl]hœ6)½‡Πu ύ 1ΑΦݜ{εό?i#CG¬§(Rι”ξΏν’uˆ.`H:`e%ΐ(’L’Γϋ»οh%ΎΟΫyπϊρΞΙ-₯ρf_‘Ό*ΘτΗΡΛKΤ•ζ ™W`uϊV$€VhzŒΆΏQŒΓ\Ι«žΨ §Μ’‘Ύ­O½Θδ1rΌF*‘’¦±Ž™‹ηΚN²o΄CGΣv;g„Πρ·GTΠ•'`“4ΆKD:βΞΔ3qΔ`xΣ!ήου•oΎΝρβψΊ‡Λψk»‰G?@βΰ`^\aΣk†² rzΟΘO–a JG–Žωϋ±Ά˜»r‘«ΓφqιL–ήΓέιUEφαcίϏ‘Šn‡οΈ'._:C¬†iCΨuΎacCρO'vτΡ{§gM‚έVΒΗ mϊC6θ>‚RV`ψ±$;Gσ_\}¬‰+.o2…%ςŒ\‘σΨ‘±^ΆυναΏΊ’Šθ a‘ Κ«+ΐD&ad 7”T1Γΐ°~rŒk2©aΖ ΣP3rίΟ0‘Ίό5"ͺσzCZρˆ“©!C1ψ ΧΏ‰ƒΓtήό²³ά VšΧΏX4αΝb€ŸαθJm$3Œ<ΎŸτPΌΐρ†΄m„4/0aJΤIRˆτ\G+e U\ςΑwŸπ—- ‡8γυ %mc]dT°Uξΐ#qC9&=,;ΣγΜ1”ήΙ"9 SH&)HΏdχ8ρ†ΫθΓ‚ιώFx2Κi=ŒΩitN ²€zb„fW!όΊΚΉ‰šb#hφ¬δ]FΕV@– ™ε’s/δΕGžφlΥξΙιΊΖ§ϋ_™:{:έρ~^ιt£'δKŽa(ˆ3ώοˆ:}y Η9Þ!”LΠυύ-LφNx=Ϊ/€λNDοŸl\ŠœΨ˜˜$Υ=FhZZ‰?οήΩΐaΗ Η)΄dS›ς 3€3šž 9ZΟ’₯KΨό›ΗŽ™DΊόάΥ|ξ'_§iΞtzγClξΩΚΘδxήΘ”Rzί3'ΐ™ι‘#`sQ#yξμ!gƏψR’JΠυ£Wœ>Ήρπ^‰o&δtΠfΜLβ|Yνμψ$ΙΞQ‚SΛΠ#~Τζ°Bm½šΓ~lΐ‘£#Ή(δνdŒ †4X9w)#C~έπZ΄ξ4ώΔϋyχG‚:/τn繞m [6μ‘<P°³žBH‘·qπΨw€-ΎΰΰπmLaOυΖθόώK$ εή‹YΨ‹?6τ­Va° $Ϊ†)i@/ €" ρβ_΅’-~ …€?1ΜτŠF–―ZΙψΠ(ν{Ϊς_€afωωΏε²Ώύ3¦/œΕή‘ΓηΞ!€;‹ΨJ–!cz¨Έ†ρΆ!:Ύύ<“½ž…-†,Hώ.Ό(8ωγ"Μ ¦ΆP³V’Swε‚Me Y;Œta6•Ξ…‚sϋ 4͌hΒΜ50{ΆZο5Ο£ t‘ρξο 1RΓθΐ]Ϊ)―©€qV3Ύ ŸρLœ§:_’c’ΧφΐΕz ςΨ'P ΊΈΆ}{‡ˆ°°ΗfiHβ»ϋιόαΛΞ]=*Ξ pRφ՟ X‹Ygΐηό΄ͺ‹f›DΏf%…XΉ…š†¦iVMΒ\Φ±†°j’kfΡ¬φνΊ‰ΝjfAέLB0šLiΪΖΊΨ9tΠσiέ{Τ½bΕRι’"‘WΓIx;Ύ‘W†dψιΓτώr»Wl?GόsNΖΚ?Υ €•;π+5|œ‘ΕS¨zΗ,τ°ί"¨²β…i± ‘εW{^]θ¦DΠHMCΣ YHzPΗησ£ AΪ ©·“^™ £`§ι©Έα_UFΈŒCUδSU(\“K{ηΖ^θ,6ŸΓΐ5ΐ#'“Hϊ)d€d|“ }ΔχP2£=( Biˆ”;¦¬Γ|#iαπ4 |IΦ’š›ΤΗβuG LCzμsoρŽx{φ & yx„ŽοmρΚζ͍vΰύ'›ψ§šφOkP6˜δHγ―A”ψ6”*M°χb·β¬j"IΎφ}.!U³€CΞPdI„E˜ΥΙ.LφĈmο!›€€₯Βς„‚ :°ΜήFšO³‰!‹΄Ή·:›V·(bω»{βΐB' ¬6ΖtΔIb$³τή½“žŸl#Ω6μΠQm§Ο[ΑS^άPπΗ ΟYH–χ ω¨Ίpe§5 …|¦Λ—g‘χ΄ίd€¬μj«^P!RH—W V‘m‘/ΆϋΓΑB:šdHGΓ 3 1+gγiΖΆtΫέρτ±ζεηΐΏ»ή,Bό±K \܊£e­MD•ˆž>•ΠΌ3ΰΧςρΝ§α+ ("<6ͺ u…εξxΩnΐ^χ/;™%Υ1Jl{/#Ο΄“Nk.&Ώ²;™7“Lȍ(fžα;8ΚF-μ'Ψ%2Ώ†eυψ*JΠKόθJ’Κ/›P(’_ζδ…Ηvαaϊ;@!Ηξa€Ια±­έΔΆχ’<4r4Λ>72˜[·Ζ*ίώf·δΖe˜Ξ;κ»4脦—SqN Ρ•Ÿζbυ ΚA‚QEΎΒ ž!b;Ζ/3YΖ^νadσa’‡GΜΊόΩγŠΜ>…ΉgοΎ?ζ€Ώ• Έ³”ΩΉΗ{QdQΡՍ„ηΥ G¦[θ³μρΖ³P0Ξ2ύ2†ΩE%c‰₯ˆοdό₯#Lμμ;Ρ[oΎgα#£μ «1@ž¦xτmΛm<~Γ’²„’ζr‚S£ψ§Dπ—ΡΒτ­Δo·πkfsl₯™’4,"Of1&³ɌΩi#ž&3š$Υ;ΑdΧΙΞ1gνγΫ€aΦιx«Lτ[•Τq!πE`%PΒ‰¦±ι=δ7=‰Zΐg1€–@"ΝΖ &“Œd#‘&›H―Hχr{“’χΰρ·βδώO`€άXŒ™ύr¦εFVΎEΏη°εΖ=‹Y•cΗ[yR'1@nT[ ° 3βxα› hy¬΅Β^Ά`·Η)gή°% ή…™l•δ~S~:¬Ο|—υΒ”ψ_Θe˜.ΟNΓ¬²€„nΩΉNιjΗtI‘ά’aύd­Ÿπ*fxϋΜdΜρ-«θΒπΝΐK…D,ͺ”Bώb 3π2fYιƒ˜q‹Ž7{3ΗΜT΅‹y#`IENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-success-256x256.png000066400000000000000000001371311524161175200273560ustar00rootroot00000000000000‰PNG  IHDR\r¨f pHYs  šœ IDATxΪμ½wœ$GyžΌ3›sΈœO—twΊΣ)’€„““ ؘ―16Ζζ‡Αc‚mΒd ƒΘ’‘‘B("‘¬S–N§Λyoχ6܆ٝ٠υϋ£{Ί«ͺ{fηNρ€-½V·;ΣΣέΣUΟSΟσy>Οσΐ̘3cf̌™13fΖ̘3cf̌™13fΖ̘3cf̌™13^žCΜ<‚—έhζm@ ¨j:η§Φy-„Οδ,0Œ£ΞϘσZθφ3xF̌w4g‹€9ΐz`>,G°CΞό η5Kω½άΌK ¨ό[ϊ]GQίw›½ΐvΰΐΠΜΤΜ(€™ρμGˆ:?ΝΐYΐF`­#μ/ε±xΈΈ¦œŸάΜΤΞ(€™<ζέΞΎφŽΉ~" G< <αX ΛafΜ(€Wτ8ΗςӁَh~™ηAGμξv”Γν3KafΌRΖEΐΓΐΈcΛWθOΞy‡grΡΜς˜/§κώΚW° λΟ•Ξ3«wžα̘qN¨Ρ ¬^ Ό‘ΑF¨&B(%TΑŠG§’„’B‰V,Œˆ†°"!DH€Y”σdΆ@1“§0™£0‘#Ÿž’θό^Ÿ’0ω‚cx»€€GΧafΜ(€—μ8xp ° ;δφΌ +"ΦQK΄5IΌ»žΔ¬:’m΅„a¬x„P4„ˆ„@„%AX @BΪS/œ% Š R}X"  Μ)ds3ŠSdϊΗΙ!sp”©ώ4™ή1dπ|>Χ"π8π p pΛΜR›Q/₯ρ€ΏΗ&ΰΤ»šyMΔ»j±"!mΚ„1ƒ2`Bmš°¨2„φŠD*W‘’_^ ν7dΘδ‘Q&v1Ήο¨m- €Ι NRΜζŸKep1p9p›¨43fΐσ*ψ›€7ζ9¨BΙΙE-€–·‘˜Υ@¬½–hs ‘hΘTQεΤ Ώ1 ώ-χoη=©x’/d9™W>!₯k]θKŠΉف rύγLμ;ΚψSύ€wRŸz.ζe ψ&π ΰήE0£žρFΗΤ?›Š{ό;}ΣΓ;0ŸΞ1ϊθ!οΨΕΔa ™Ž8˜>βX3cF›Θ‹―·ΏΠ§f^u+ΪiΨΠC΄%ιΫ‰}»ΌPLn ψΑώ€o"eΩɁ/]{Βόœ§IeWσΌ#€bhHίIΫπθ(₯sX¦œ‘‡2φΔa&χ%7<ωlζςwΐGmΨIL3cFT€?>tΌ'¨YΤBγΖj—ΆŸU‡lΦΒUή¦­όU‚ΰE!―ji~²’ Q-ύS’* @ΓHU}ΊEΚΚ†AΰϋφoΕlžΜΑQƞξgψή}Lμ|VΙ†—?˜Yβ3 htc#ϊo:ŽηuλΊh{ν’σš°b;τ. d­$ΒBFCJEΉ»¬Ι.ά}WαšεήΙ€‡&JΣΌP°Eιh{Ύ”z±’6ψTŽ ήK†ƒτ©©œ§X§ LξδΘM;8zώγγΓΨ@αΕ̐Šf€2>|h?Ά§'₯"Τ―ν¦ύυK‰wΥΉBn ‘£y&WΨsΝκ²f―‹Ά‰ifΤHΑ±†ε3_5Ϋ­ΰώiς €_w‘&6ΰiI)e@Πϋg²w”ΎλŸf䑃9Ώ1ύθώέ± fΐ+tXΨxW§λ‡γ³κ©_ΫMΣs‰w€laž°[Ν_7M՝—Ξn Ύ δ΄Σζ‹Š’PηQbŒR:»²slΙM  |ͺ~π²œU€†1ho( χΫIΘI3pϋNF:HfΘρ¬ϋ€?ΖΞL,Ξ(€WΞθ>|φX?˜˜Σ@ΣΉ ¨_ΥN΄Ω|QΪαί-aωŸn€Ooz>—2ΰG€Dιχͺt‚ Κy„²{ ϊq•­<ʐ4£ Ί[ £κaΝ οI¨Ά€‘9€„©qF?Μΰm»˜Ψu\8Αgo;.Œx™·8ζίIΗς‘pcœΞ·­€~m‘DαpλΛRόtKΨ~Ώ"¬Β‡ά{Z@ΈΫ΅(o²‹ςΣ₯* )€§d€zrσ³:Χ―όήξaRθ»/>θNhB,μϊxΚAϊH‰n)¨ošΚFJ€”&sŒ…•vχ*w~i–ώ\ΉŸ*φ€”t Lχ @³H ΕlŽΎ_>Νΐ­;ɏeειdίοδΒ&―οx2p-v²N•‚£~]­,"ή^«˜ψŽP[Šo ΰX–šΐς« ΆΔΑΰ W―4›ΒJ! U!]ΌA Gβωύ0α4€λ>”Σ%AαΎ*ˆγξώ?u@zΐb€bΘ§Χ[9z~ς£Η€φoαΉLcšQ/ψ¨ή\‚Mξ©j4œ:‹ζsœίd 7%αΆά]ά²„'ΓξߎΈY lΠύ›@ς]‘Qύ{,P VΨύI LΔ½œ…ΰ £,“nT’Ϋ ςSΖ0ƒd,ω„ί:TίKοdΰ¦ έ΅ηXΦNψμβ$#3 ΰΔ'_ΕΞΡ―jDškθώ“Υ€–΅cE,oG΅,„prκ‘ˆ’Ÿ―X–γ ν…ƒ τ¬ώΰd‘šό=Xƒ dω™*8W₯«PΞ"(»Κa$:–E@*‘«d°Ή >+Aω[¨ŠΗΡwΕ|žτ3ƒμΏr3Ωή±cYG·ŸƒΜ(€d\|*γϊ"bΡxΖ\:ίΊΆμ^Xn9α˜ώΒς;!<Ÿ_X–γ8‚κλ˜Υ%_Xx4ŸK Z¦ΐWœ5ΏΈΪψζ)€wJώz9\Ο·λιΒA›Ότ%™ΎΙC8)Ԋ}o2 uBϊά ιœ«˜ΝsπκΗϊΓnŠ“U»ω}ΐŸΏQ/} οΓΨώͺFΝβfΪ^»”Τ’VG¨-OΈΐΟυρΑv„·‹—°ο}‘cA8€(³mƒbΠ……_φ'U₯ε M EΐΞλρςPδϊ ΧgΧ1ΐZ~…`†τέD’@ο@?—/Θ(e°΅ϊΗ蓇ιϋΕΖ·τΛϊϊ(vΪq~FΌτF6ΕσΟͺϊβa‹Ά7.§ρΤΩ„“Qwχvwχe―υ’9―μϊBvHΠ’^t@΅πbξΪ#B;ΰ“‹`ή7qβ˜fŸPΖΙ―δ+P!ο³€Οǐ ³Π<‡‰ψ0i:'φξξΞ/₯ί|Պȧ³ ήΎ‹Γ?ίB±ϊz‡?ΐ¦Œ˜Q/±™˜“«98>»ξχ¬&ήUο‚{.h_Bχ…°ύ’oO ι·Λ/ζο€v’Ί2ξ`† Š xΎΙτΉξ%SΏœΐ@q%LAέΌ…Ος6”ŒDJ‹Cγ˜˜’p>WΖ p-)ΨΟ}»yέΐε9ψΘBΑδ!νtF˜AύsbΟ{Ύqο±`8Ν3 ΰΕ§bWmœφΛ†uλΊι|ϋ*‡Μƒ·ƒ»ΒŽό ηu%a9Η ι’J.XΗ* yЎ―MŽ/eΠo8αΎι ΎΚΑ Ÿ―,€Fθ'Ψb²bDΑoϊ+φD9_>H1Θ ΟI $4­ d€”Θb‘}ίyα»φV›[0Œ]υωΎπβw`“{¦㴜·¦³ζ{d@gy±}!ΌjΉB(ο—ό|MΨ…ς―p³JaΏ’ Κϊϊ~^˜€οgΑ Q9ί_h₯Ί§Π*K/tιΫU€B ‹υΙ n€3+@ϊceιΐ>ΰQΕ"ζίξο.YΙ»ΫwrψΊ§Θ U]ƒΰΨ•ŠOΘ:οϋcΐw¨"ΖU³°™w―‘ne§+δ^lίσ?N¨Px¦Ώ£ ԝ½dhXϊΊΠ-‘(‘$©χ―žΟSHΈΚK;V(ρE (qΛΒΎ¦…δπ’ ήΙ)}_€Δ}] Φ<πž…Π•§ω<\%(\2•ϊ}\5©~a|Ώ}¨‘₯ zιe/‰Γ;fnΙεmdφš–N,·iμgrFΌ0γkΐ'¨άκ€ϊSΊι~χἸ Ί―΅Kγ΅τ0Ÿ₯,P7: ΰA€"|Ϊ9ΤŏΝ Ήͺ%’ja0½Σ*Χή`*²λ *šΰyη•’3š„"@Βε-xJΑ8ύZ~%¨„HƒP” ϊ=Eτ>ΚwΑSRΚ9U%¨§(‰hc‚ΖΣηŸb²rrQιdηaχt<αΒ„'’ π[μn;ΣϊϋM‡ίΪ—|}«dθI<–κΟ[n$@*ΐ p>ο2!Βς’Ώ δ L–―'ˆRL@ΐξͺΑ¨Κ―ŒψΛ‚•' Ι’ϋ ΰ~w (5Ψτεƒ@=|₯Š΅:R/D’ӈ₯βrΰ ͺΡ…ώίΈV½»“ZΘ‡π› Eϊͺ*)ƒ£¦5`ΈωΡ {.½ρ'ϋͺuUίΐKœ5x"(€»Σ¦;(1―‘Ω:ΥΈJΞ½%τ0‚P•,¨2­'ξoα(Ή%kBZJΈΠ­`Ω+Ηrͺ™1ΰ― Dρ°tΤ>Π  N½VTR}M…ι(‘4έpfTΟΗηΧMoΥτ—Ž pc0pχ6ŽUˆnaη7,υΛεSμύ潌=^Uρ {°ΙΜ(€γ˜ΦηPΏ›Ž·­D„„ΒέڎsύoKhœν˜Κ/–4Βr”¬5Π πσ}‚.*šώXεt€[AL–S:yH'σLΧY X1¨ [PY·ΦOΆ^HXn ΅°&BˆH(LTD [–¨dΌ,-L‘ΝηΘsŠE$’‚,Rt~―€\’v^ϊb …`Њ Syφ_ωΓΨS-&pζŒ8ΆΎΌkΊϚGλ…‹‘°zVCzΪ.―„¨(Εν-Γ|/…„Jζ»ΐ°,€Ά…`)Šk ,Ÿιο—Q1νoΛΏ‘?`X ’Bφ_080¨(A F „ $,ΪbM΄'šh‰7P‘!ZK<#@K”u%ω cSγ eΗ̌025ΚθΤcSi²Ε\yE XεHCΪg₯Τ€Αb‘HίυOsψgOV³žxvΕ‘PΕψπΣΤtΞ|Z_³Ψ%Žxq~―‡e™οyŠ‘$€–VΡΧRͺϋx€” –ŸVtΓ‡(„₯τ0}|# Έ,(θ;μUn*L;HΘ*B0@―ωΚJpDO²• θͺi%"ΒΣ#‘²\™0?R)λ₯s“ŒM₯̌°oό0»GP0^Ε€*š˜‡”E?FσEϊo|†ή«―fMψ§—𠽉@‹ΥWYψΟ]@닁€μΌUΦ2^Wbψ:_x *›Ξ@Δ† ,Oy€£@,/δgω”ƒ!j…••¦* +PΩj*iFc±‘²τΠ#TRŽΊŠΝYΤj!Sθ£V<Φ7ΚσΣωH,Όg΄―α΄ΆU4Ζκ ‹~£>ΓAξψ*άBE΅"ΤF’΄%š˜_ΧΝΚζΕ4Ελʌ’)NiYΌο-5/L-Σ.LRQιKZΤB1›'½khΊΨκιΨΝK_R‰^jΐ;s©Bފ ιUσhΉ`‘ζk£κ Βό;ΊΒ€³p©½.Π‡9p•„₯ƒ@©gΉœD‘ΦP+›»»Δσύ‘Ύ NAF1}όxΚ4 Ε¨Ϋόy_΅@YΖBπμ¬mZΒ)-Λ}ί·X(0>2Ξδψ“ι Ζ‡Gc|xŒτθ8Ήμ…|!αH„Dͺ†TC- mM$j“ΔkβΤ€jH€jˆ%βΰ’>₯Έ·οqzΣdA$ψ’†<χ@*δ_ͺ±”όΙ£ωνΆiΜ„γΦώtFψΗ«›¦σbNŸCλ…‹‘Cg7L|-·swΐ9…ŸξšύJΎΏίwκωYήηάςί" UX š¦Ώ’b\ΆEX9?ΰ5_³P­1§QvL³ύρϋϋr/+t,C p#2ΰO_¨€x]χΤFj΄ΉΌΖ»xόqhη>†ϋξ";‘©zΑX!‹ΪΖzZΊΫhιj§cN=‹f3gΩ|zΝρQ~;”ξηΙ‘μλc"—!¨K‘ ϊ"―IΙαŸ?ΕαkŸͺF \ά<£Ό± ›<1·A©νtΎkgŠ»^°³„fΆΊ€œ₯ςγύ.DΙPΞgιΉ;Ύo FhΠ>ΞrΙ@ŽK D"PΠNο”]h5υύ ‘–§ 6@σ‘+ΰq>α(έ㚦Ell>I…Έό“_γ_ήzL_͈Ē$λS4΅·°β΄5œύφ ι˜έΑθŸbλπn܎€¨‘~‚ƒ'”ΘE…©<½Χ<1%vΥαΧOΟ(H`χp_]ι ψœFfΕ)š―νξΒΘγΧΘ9 o_˜ žUr€ΒWτΓ zn‚jϊ+ΦA‰\2άπ„T€d ΰλΡΎ`J#4Ή^¦@dPΫ…ΦΈSa‚2θs~pN"‰Zή6ϋ\κ"IΧΪΈεΗΏζ{~…ά Γ‘™Ώr1τ—oηδs6‰F}χ],J³GΉνΐƒτM * @˜€:t  (C!cΟ₯χ0φΨ΄<Η€Mΐδ‹)|/π[LCρΟi€ϋ½k°Β!`²tpΟ ŠγλΤ\ΧeP²ΠτlΉ T]K#•\ Π- K΅ ”΄ΰRΕ`Uωxi°ώ ΑξqaIΑžŒ„ͺ8»IΊΞ½ZVˆ°&l…Y!,Λ"δ€oRν¬(/»ϊ1$zž‰ω9tSy-ŠrJσrχ>'FΣ\υΕ+8r ο[`ΓύƒάΫ?πϋϋuΝυ$λSΚE(τΌ~υϊΉχBS€ρP”• έΩ=΄s?7δΧLŽOΌΰ‹-3‘ቻζα[οcΰP? ­MΤ·6’jΌΆD#σλ{Hη&ʎjZP«) όnYιΠP<Κ’“—Ž„έϋN„bΜ«λ¦.’βPΊŸΌ,h˜†VηI5“”ΧB‰©“Ϊ9zχ^dXΙC;Ψ ly%)€ΕΐwΆJlΗ[VP³¨E Ττ[…Φ«U˜BQ ŠIͺUQιΊ–R5¦€έKΦkPU€T αw=„0κκ±sΤ 9˜UΠHE” 7]‚sΫΧ³Άq υ‘”Χš,@KνΛS‘fΥ΄“ Χpp²ίΓC* DBΑLD@ˆVSβ–c8oη§rRιš&μ ΒόΛ]|ψηJΤ­λ¦ωœωˆεέ±μ4 ΟSV1H+₯šϊZŒ_eJ%ΩGχ΅Ζ –"ά– „ύ=πΜTΊυ!Κ$#)~§…¦ΰΤv³i©&ˆΈσΊ[Έφ›?αηίΈŠί^yw^w3έΉ™‘ΎΊζφP“JΊχΡ•had…¬VWΠ·γ ΓεreήΔD„ΛKh‰7IΉB2χ€Eμxl+ϋy)ν?͎Gž¦}v'-=mξw†"Μ―οa8;ΒΡμΈπ³!uw(ΉΈ…lί8™[ z_ΞaΐS°‹'–½n¬³ŽŽw­&\s’m₯¦œx%ΉΥΈ»β£»1x(ωμΊο― *F-?X$όιΐΞη-!Α ιυKŠΕ`–ΟϊzT/ 2 ]ΊΦa ‹7tŸi{ΞλΉμ—~όψέ]Le‚σOf-žΛί}ύ_˜·|‘‹? Np݁ίWœ@YΆϋΣ}Ψχ:+ΡΞ«;7r¬$ŒpλΥΏαWίω?ΖGμzό ­tΜν¦s^͝­Τ5ΥSS—rοo*“eth„±αξΨΗΑϋ9rΰπsΎHkλxϋGŒsίu‘ΖΜσά~p3Oνςs‚Rˆ₯G&*ζ <󉛦λ= /€x!@؎έΑ'pXρ0οZMbv£»ΛYfJ™ˆ£~bŸ:ΛO©`«(݊( ±γΧΦ‡[%(dλ…Σ!ΨΞ‡‘ŒΧ<uηPbρZ±€J?εZŒsj:Ή c£ϋ†”’Λε«άό“_WξΜ 4u΄πί7]Nmc½ϋΪ}ƒOρΔΘ²`ΠZ-ΣώΟΌnΑE]§Ρ™hρ΅+ Œ €’Ά!ε)tŸ\ψ‰GRJ²Ά?Ά•oΊ›ΗξΨLίήCΟΩ‚}έ_Ό•?ώΗηSwυ>Βγۍϊ‚κο(*bνgϋ§n‘žͺtΩΐ" σBε ι| 8·’i:o©ενΊ°»HœaΆcψρ–«7…_ψύV%·_XAζ·™ϊ[Β <«Γ2Γ_nσ5aψcΊ¦π«ο£ί»št~ϋ)$Β1χ'ξ~„«Ύxω©ι[]e“d&3œ|ΞWθΪγMlΫG^u‚πWΆΙ>FΦ)–dvxjœ9Ι"VĝYΔkΔq%‚€΄G?ƒRA8‘}V''Ώj―yί›8εΥ›HΦ₯HŽ3•™",ΘFΫ~š‘ΎA–oZγF BΒ’3ΩΚ‘ΙaF¦ΖΊρ―’ξJF ΧΗl’P±¬ruπ€^N ΰ\ΰ?+ Ψο_ίMΣ«(ό~Ρχν’–₯ψϊ–ήΣˆ©£ΔπK‚f)Ι?ξnmωC‹κω<ZQQ7‘ΘX.ΐΖ#<œΑŽΤzώψkόƒNκQ[ι;ΕCNmYανNS9nΈς:Ά>ψdΥsδ@Λ6’Ή³Υ]ΰΡP„}“}šς₯|;΅ΚP}aϋ΅‰|†Γ“χΗF IDATΤER$Γq›E°°» ƒΐχΚ&"€ϊΦFVœΎ–³ήtΎ θ%βτνλ­J!=Oν`d`˜“N;ΩUa+Δ¬TϋΖ3QΘΰ‹jϊRhΈ@’»žlšΜΎ£•¬ςΕΨYƒ»_ Š]ΠsAYΏΏ»ŽΆ?Zn3ύ„Ή{’ ζfE_Ζ›β³qz­Y†eψψA žšΡg™Œ@―ܘZ?Πr‰GxƒΜ° e6±z‘R ½θ 0,Hu3'Ωι>ΗμD†ξrƏŽV=9Ω‰ ΉΜkΟέH(l/‡ΪH’ΎμωIό’F$-•I©1+•ωR%XΐD!ΛΞτϊ3G ‹ΊH”ɍ3=Jοδφ§ϋΩ3~ˆέι^Ma {”‘ά8™Β!ΛVTAς#€p4Lχ‚Ω¬9{=kΞΩ@±PdΟS;Žkοέ²“Ύ½‡Ψψ―8U$aNm';Gφ3UΘμώΎNξzK-kεθύϋ)Lδ*ΉΛΛ€ς>rΞϋŽk’ώύš―p¦5”rw¦pη‘G]¦ YίΟ€T[›=ώŒΪ>~aaS•K>sQΙΛ²X€€D%’’λϋ»ŒK6‰ZΪbMΜKu17ΥIΔ λwe`{Άμ䲏}‘ύΫφΧ3ΪψΪ³ψΫ―ώ“V©7=ΐ5;nς•7q΅Ά ”0Ύ₯uηt}?ξ„ΜOX  »YBΩQΏΎ‡Ίuݚ­Η֍5Z~ΏbNŸ΅Τ4έRεΟiwΜuŒͺΐ–gΆiIDYΪΡ­€ΜB<εcΉΚ έ"A 㑚ŒjΡ a΄ΈRbοVŒυMK=dΈεͺ_σΔݏΧDνzjηΌγ5„"aΠ­εΰδιBFq[Π ŽΣ\Gu]tά Δ›w[J(ΘS2O˜'ηό[”E[δKεwΤgŽB€$WΜs47ΚρC<2τ G2vΙξϊhΚλΩ lq ­Mœχ'―#—Νqpϋ^rΩ©czF·ο)X²~ΉΫ>-I`!8ξσωόΑ(†­E£­I»άψ֊ό€ €+€ΡUάΜ*χf€Ή†Ά7,G„CF΅₯h‡FφρL­Ωg©d£—žz:[PΑC3_?FxΠr` /‹Pm+.Τ^–Η΄ ₯&>ΰΡμλ§ϊΡͺ2XXΫÜdž‚ϊΦ?|‰±αγ[+#G†‰%β,ίΈΚγŽx3[Ηφ ζki¦)7ΐ5ϋ‚Ϊgέ}P5―Ν—†ιψΫGsγμ?ΘΦ£{˜’9bV„d8‘ ’¬€kϋGηΫWnJΈiΈΎ.>A~QΣvυΰ*x¦+½ž½{λ8(ΣάC­hydCα”H xχ'+C‘kΑ‚:kœωV„ouΓBš’uξ{{ΆμδΪ―ψYMΨή­»Xώ&κšll&E‹ΓΩA# 7EM 2,½b‘A,zI3aveVΏ³ͺ[$ΈΥ TΛΔωΘ”ΜΣ;1ȞρC gGi‰7 E΅οΪ1§‹5g―gΈoΫχVύŒ ω;yšΥgo Ά±ΞρPC4ΗλΩ:ΌΛχΝϊ‹j5jεV$D΄9ΙθΓΛε tG€Ν'’θ.ΊΛ’ώz¨]έ©#ΗZ2ŽΞ„σΥ¦3(ΏΪyL^€’kωϊκ»}0θ/ͺ}”PcIΉXfz±εRψ…j-Κ„Άϋ©fΆΠM±’n1eQ_χΝ«ΨώΘ³«/‘Μœdύ«Osο±)ZΛώΙ~§–ž@‹NΊήΏ.¬žpFDˆy©n–ΤΞaeύΦ6-amΓbΦ4,bUΓ"V;?«²Όnσ’]4Ε¨ EΙrδŠy fΧ)Ή†εαΌ’—²#lΩG2œ 9ή }ίD*ΙΙηlΰθ‘!φ>½«κη4™ždϋ#[l’sωd8A¦επ䠏ˆ2­ήK ,ښdjp‚Ιέe;Y@§=€ήόyΉΒΝ5΄Όn V$d€{–^\Σά„πψϊn{.O°,­¨§ε&½ *D0ž`*-ΕΨrλͺ‡–™ (\kE‰tυPh¦―χAO’%΅³΅]ι[ψ%2ιg_[bπΠ‹N^FϋμN;‘Ε  EΨ3Ρ«SŒvΎ₯ϋ–†ΒЇb,«›Ηk:OeAͺ›Άx#u‘$ρP”ˆ&,ΒφΏVˆ°"b…‰…’Τ†kh71/ΥΙͺΖ…,­ŸK̊0™Ο“'0Ώ¦‹%/σμ;@&—₯'Ω%J…Ba֟*Cύƒμ~²ϊ(ΑΡ#Γd&2¬#YΕ²ΚD(ΤR`nφŸRΈ"B@5―F K –Χ6LΙΊ†e¬χ lΨ±—zίΏΠ»ϋΰs:‘­έν|ύΞKΔ\PλΦώΝμ›<μΆ υ•Ρvn6iΕxuΫ€TΈG‡Žςπ­χρΘνχ³Ϋ†ϋ†ΘLL"‹E€”X–… ‘¬OΡ5‹N^ΚΚΣΧ²τ”•€™M;ŠH2ω,›‡žfΛΡέψ x*ŸR’vBXΌqφY΄'šQΏΔΔXšώλcΛ}W'@–Ε__ς1N{ύ«άΫΪ3z_οωƒΫΆLο7θ%•ξ¬τwζΐΫ>}K%@μ</U ΰ`~Ή7k·pκl_Η_άί ρ Τψz™]ΓoGψ²€Ίϋ«u(₯£›΅–ΠΨmj©p½ ε …•p —†¬r T0Ks 0¨ΑͺπΫwωšφS•υ*yΰwwqϋ5Ώ{ΞΝΈ‰±4Εb‘ΥgwQΎžD+OνΦBj©°˜[Σ₯aάtΏηŸΈλ·²ΫF2•ΙR,m”‹E ω™τ$GfλƒOrηu7σ‹ΛfΟS;±,‹Dͺ†x"޲\Ϋ b…˜“μ »¦}ι>r2―ΤͺD3Ε%’§ξ¦«¦Ε-X ‚H,œ₯σyδφͺ«Z$%ƒ½GΨxΡ™D%• Χpxbΐ κό 4ΡΓ„‘TŒb:Ηϊ‘Ι“°‚/9pπ/εΞ)Βνo9 + –ψώ–znΏŽΨ«l>…ˆ£$ίψ‹Z %™uμάμ?Ι7@³s°Ϋ·ΤNBV@j±₯ΡΆ\U„ͺp‘ΤD°¦aρfW¦2Sόΰ?ώ‡ƒΟOΝ}[w³ζ¬Shj·-6€‹p03 …UΥjHabSΣ ’α„ϋ}Ύν~.ωΛΟw0)%wξγήίάΑƒ7έΝΠαAβ©š»Z5œ 6’dEγ|Ζs“^™3­U›|‚£θJΆΪ= œs5΄6έΖ}7άYΥύ χΡ½`³—ΞwjΕXd}ㇻ¬ε•6¬pcœΡG{)Nζ*EΨξε9Κx@»ŸίΚr4ž1—šωMZέ|wξ,ΛΧΛCώΥΪzψRƒ-%΄&ArΣ*ύj„nvŸTψ–'΄%½`)#OΐR: ©ΤaKΩΕ-ρ'K:£d‘Αι-«ˆYQWξΩ²ƒ«ΎπΌqDΘe§˜KsΚ§ ΩK₯1ZKofΙBF³NJλ<б±qΉZόώΏ‹C»φ?'χ”gΫΓ[xθΦϋΨωΨ3,Z³”šΪR±‰%,ζ¦:‰‡’μŸθsιΦ:ηΉ.EY€wr€9Ι;¬κΫ½p6“γU‡Vw<Ά• ήϋ,ΗύkŒΥ±ehS2§Ϋ"Aΐ τŽΧΗΘ N2±s°’Μ&λ€β³}žΦs΄V6TώΒu1’«:]±Σ#γ|ύ#ŸγŠOΓ­,TΊ›y΅έΌiΦيZΧ3+Υ^'ϊy|h›ΆΖRu)ήϊwο­ϊ^n½ϊm-mh[A€΅oΌ¦c{犡$i:g~₯K5:2y)(€Vΰ/Κj‡¦5 ›½²^( mRψΒ".‚«ρΕ•'&ύ JI³JE£ gΒ₯‡Yλ /ΤX±ΐ0 Uκ1nχSξEPoχΪζ%„/W+=F‰M¨ΧΪ⍴Ε4}ψγΟ_Ξ 5ΉύξΏα.χ †-‹•υ œμ;•· ζΨ?ΙϊZ’υ©ηύoϊΡυ|ώΟ?ι„C=αn7σͺŽ΅„J‘X#ό¦ZχyŠ‘숢ΎΦž³Ek—Uu‡vξη±;<Ζ%,ζΥuk5ΪΑκ…_œ7[_Ώ”p}ΌεώΒ‘½]|ža!H,l!PvNioŒRšRGΉvWΊ`j)|pιΧ²Ž•ΰ«Ή‡gj› av½ΆβfsŠŽnh •ΟοZ#B―ώcn»kK—6BΑ’δ,Β"μ~fηΫxδφΆΛτχύ2GΗάgΥkb^M—Q°H—yΝ¦kŸΣΙ¬Εσ^{άφπΎςα‹Ώέ¨₯usY^?O±ΖŒΚΖΚ9n9τ 6Q΅uœύ– άZ •F>—η‘ΫξΧ6œΥ-Kό=WΝκP¦5„γaΪίXQρ€Ω{Q€>]φδ±0΅+ۍqΒ§ω„¨(ΚΞ'}~½Ι³β4)ΝRwRIήΊr0kr‘ΉόŠ[Pξ‹(ƒsΎp%zRKiSHE’,¨ιΟ­WίπœΠ~ΥηώΑΕί^;΅qaφˆ[ςθŸ:κZα0oύ»wSίάπ‚άgοξ|ιƒŸuAΗœΪΊ‚φDΗΚ;Αpv„'†vhΨΞ©―=“¦Ž–ͺΑΐΑή#ξ΅ΫMΤ„c]³2”SύΖ"Ν5•.χiž%—ηΩF>M…ϊώΙν€–·{ΒSJ±T™ΊKši’Ί‰,„ήΪ;Χo £ ν“υgφΠΊ£uΦK“c0 -/\‰WΒΜ#%‘Χ%Ζ€Q‘½ο|vσ;NνœχΠΞ\σ•S՟ηjΨΎ‡gœLkW»Τ…kΨ›ιsWu)—vΒϚ:ZXΆa%»žΨNv"ƒ, …BΔS5ΤΤ&IΦ₯HΤΦ«‰ŽDI!ό΄ζLz’Ν7ίΛ™o>ŸX"ξ>σ…΅=<:΄M1σ|QA€L²ΜKuΉυ £±(G σΜCΣΣ„§&3¬8}-m³:ά j s”Αμ~Λίμϋ ‹³ QΜζIWξ)Pξx1@»ΤWm i΄Όn)‘XX/σ…ΡdSH₯jŽžˆc²υΧJ1wC`2ωτΤ]·ž€[ΐCιϋg ίρ#uXaͺ|7γOaΊΗͺΤ`£VY;OυΫc¬kXβΪΕb‘›~t=χόϊχΌ£/0:8Β† O'΅Cfυα$}SΓ€Ky2Ε,ρf»X©³¦[ΊΪ8ο]―eώͺΕ,X΅„uηΚ«ίύz.όΣ7rα{ίΐ«ίύzΞzσω¬υi,ίΈšξE³‰ΥΔ!—=φz~civ<Ί•υœF,nM ‹ϊhŠέγuώ‚α‚Mζ³t$šiŒΥΉoΜY6Ÿί~οηΘ’œΦ ˜΅x Χ,uΉ$ω {ΗzΛμΥ *ƒ+fμ±ήJ‰BK± ξ_hπΰεΞ‘ZΡαξώnλνR–ŸΫά―GιK-Έ!˜¬7νTΪrΠΞ[©ήƒΩΕΕJΐ¬"$*"Τͺ[jϋo―~–τcU†Υ’eŠ£ΨyNλZ’‘„»nFςυόΩ‰ /Φ8r y'-bΦβ9ξ³N„βμ›μ£θtqΚσ€ ζΥtj™²θšΧΓβ΅ΛYΈz)s»ilk"ΥPK².Emc=­=νΜ]Ύ€Ug¬eύω›ΨpΑΤ΅4°ύ‘§)Žm9ΨΗTfŠΥg­wω"±Zz'†Λ§(7‡%ΰxnm§Kߎ%βl{x }{{§ίΓaΦ_°‰p8Œ@0UΘ±cdΕ’)ΰZΡ¨€‚αΊ“{GΘμ/ΫT$μΑn7ώ‚boΒNNDμ\ ·d@L^ϊΐCΌh˜c²Iγm©ϋT"ΰœ’ΰδ‹€άL­[2* ιrTQ|ιMΈ9τ₯«ΒΏ(5‹¦Hvͺ[ϊ5#ΓΌ˜#—β—σS£iχ)uΕZθI΄jQ–Ω#ά<°Ω¦εŠ2˜Ž0V€–&λRΜ[±w}μϹ잫9γMησύήφΣί²εΎΗπφ‹Υ m7L–Ÿwχ2UΤwέ τU]sΟΣ;Ι+VK2’π*Iα_Qfω«ΟHX‚† =ˆhΩ½:κΘβ κœ όPτf|^΅«;*ΌκΞPwΏd)”kϊ©μΊ ~1j牀μ>γ\–Ja΅”^€FF"—yh\#()H_z0ja#4δ)7A"ccγ2’‘„+(cGGωΟχώ3Εb‘{ κ§cN V-qοΉ.\Γξ‰^»ˆ¨3Χc… ž΅«υJΰRŒŠRR @Aάΐ/P&ρd‚S_{ΝlxKΥά‚B>ΟΠαA6^t¦ήμοθΛ2–›@Χ;ήΪ“²HkΌ‰&Ε ˆ%βάό“_O›vIO²ρ΅gΡΨΪδ†wd<7‘pE‚Έ.Α»YΌ³–‘)kωΥ·‡_ €7ο*ϊ«ίΠC΄­V+Υei½ΈυRΩ^βN€0+©Όώj>ζqŠ?―β–Z€|n€Ώ=˜AυUλΊτe /ΐΚ„λ؁YKΓ%ρή_’œΝΒd† ]φρKΨ]ešκ 1Άάg½ω|’uv8Š“-Nq$7’qE;5ĎτŽζΖιΟq(3Hov€}‡y&½Ÿ-£»Ω–ήΟΎΙ~§F˜,ΪΕ:kB1M σW.fΞςlΉοq&ΖUέkώ^ζ­XDΟ’Ήφ’ΙΎtŸnΥ©1™B–₯υsέ9°B‚­>Α‘Ση^,Y³—ΪαϐbΟX/C™Q½°³€Κα!‹Ρ‡URseÎΗH8 pDšβΔΊκT»N ΛͺΣCnW8‘d H=>o2ϊ€4ψΏ‡ρŸC¨.†π\u|δ 5xΰΛ•SΕ([ ZH…¬¬[ -„‡o»ϋ~{'/₯‘γq™φ\ΦΦ/ΆιΚͺm ξ”Μ±kβ[ΖχςΤΨnžΫΓΆτ~e0œγhnœήμOοεΑΗΈΉn9ς 'ψζrΝΩ§π‰οΞFψ«ίϋΜ₯‹EwuΝOu³’ΡοοΎΙAΟa’p$Β‚•Kͺ >ΊUskBqΏ§!ƒYͺΒη4 6φ«H~³#›Ο»hΞ/λtΤO¨3Ώ€Š @ψ„£ι¬Ω­V•Β@‘z[R/n'„€,)IHEύΨ„Iݟυ\~»΅wYΊ―+ˆ‡Ό…99>Ι―.Ω‹ ό•ή|›oΉΧύφ–qZγ #¬Š¨ j{&|u'‹YφMτs둇xpx«-ˆΚωζ,›Ο'ΎŸnΥ’ιΖp w^{3%ŠxԊ°€~Ά.v)­ %‡&ŽΈ‡ΒaΊΝ0 ΄4ΔRXεX’LPωӊ…i…ρ Tv ‚Ζ3ηNGϋω«BΌ―, P'>«>H­α/lλzΑ€‰e]"KC©|ڎ@ΆΣ4ΎTXn¨όn„Tό5ΑGmT*”zΡ(]N°±aΉφΥΖ†GωΩWψ¬\>ίγ©{εοΒƒq,NͺO̊½τ*oΓ$gΉV‚b;mΫΛ}COQ€¨” œχΗ―cώΚΕUέλm{ΣάΎLm8iƒ|>χΝ»ο}iWK5Φ‘¬―φZcΓ£6`λœͺΤΖΜ ‰ΐώ§Ίij0£-5€V΄—l>W `J}Χ,jF„JyFfάtΘ’ϋMΘ RΝQ²€Λ΄7ΞͺψπΒίΡ5( QρHτ$&eƒχd©Y/B (*ΌMQΩ EΧΥ-&ŠkΌζΛίgοΦ]Ό”G>—ηΊΛv’mΡF— (‚*β*jΣ[RK‚ΦΌ(glOοηξ‘Η΅5ΥΨΦΔ9oΏ°zώ­χiπΤ‚Ϊc»ΡοπhN―ΑYS›$‘ͺ©"ϊP`r,WΤ4DHΫ@‚­`΅Ό) V4Lέɝ•.ΫνΘθσ¦*j˜δIνώ/¦ v`ΰSΚ4FZ½jͺKσόeμ‘β“ΊHeδyΚΚ»vΎXΤ”@(r«#M7¦2YE ϋBκJGκh±ζ¨tεxg-ρžϊγ’Ρg«^M9ζZΦζσ e%ΰΟ4ρ…ήVΩ›-mΛ3Τ$Ύ‚!εΰ53ΐg€˜·5Π–ΖΙ-cƒχO²0ΐ%α3σ65 ўΥ_Ί’Γ{žP  /πέO}]›ηUu ¨§|0€jυΟ €ΘzΟ~pj”ώμQ}qΎϋυUέηθΰQFŽz˜°¨€|‰XBY,y鬐…¨RJ„!_’h@Qα3Œƒέ€X˜δ’‡x8’ެ>/ ΠφΡΡΞ:ŸdωSύAU₯₯{}§”¦“ν+C`Ή›ΰ‚θμ»rΨ’_kcrŸ^zΕG\eε›HΟ\ΚsXU»@˚xόqσO~Ν‰8½γAξϊε­Ϊ³:­q…ΩVΓ ZCοΑ;§Π’:Šτg‡œξ@φ΅ζ­XD$6=:?6<ΚΡ#Ê’αΈΉ"•ΝΗ7‰*p-§k”^"NβK’ΜςΓWEZP»Ί+RVtγΟ‡ˆ«ΚAμΌ– IDATλ¬%TΡv8Ip½½ ίEΡTΒΜ O\{Έ-― hΙdAO<P›Oκe$₯17I!υWšQ‹’¦5€λχ―¨―}·τθ8?ϊ―ο?!@.;Ε―Ύϋ3F‡FάοΥ©gQr–Ώ"―©g΅έQTp83lӎΔ1Ίζ͚φ₯”Z*΅Μσ[«ΚύD-k›Κ‘Ÿͺ.;±T&<0G€¬θ›˜˜τ\Wη―ΔάF"-ΙJ2½ͺ’΅~< `‰σμΟj€₯έ€ώ6@ΈΛ–‹HΓ7σόpΫ3 :‘PVψ€HuA Ν “’μάP©Κ Γ*ς™ωj‹r‰ бΆ~©›o^{Ιl{x 'ςxζΑ'Ήο†;ά"–,NΞ"fΕq!‚ζ-HDt0’£ ‹Zδ ©³Ί YιΡq7§B¨;u—#$,Bnι3;’«R¨ΡI…4b£&VδŸ‘nMηqΛλρ(€ΩΏΡ‘–d Y€ο¬jqMƒ΄SΖͺ€fΤ•)η₯!όš)ι³§|¬bZX˜ρ„WCθjΖΧΥW-!XWΏ”¦h­φή?ϋΏΊόgœθCJΙΟ/½š‘#Cp7GꘟθT£ ΅j il›3’)Ny•¦JmΣλͺΆTT:Ά―kŸ²dkœ,Ύ­g“U³2U/|΄ΆΦƒΌM)Κ(C¨?uΦqΙλρ(€vφ_౑Ί8‘†˜±+ΥyUiσ{ϊxYΆKzζ6*9Οπ΅ƒΒƒ2H½θόwαϊ'¦hX«°δZ”Κyϋ)žBp>³Ίa ’:•♇ŸβΚ»”—Λ8΄k?ΏόφO΅Ύ²v>q+κ#zκ˜Ώ±g₯ΚΝfλνR· ιF±P,“…£χ8hŽΥiλsr|’ͺ$€šΪ€Ζœ*LωΚΑ«M–΅-Jꀩ·€½›NτΤm­θœLΙ~Υ<±pzΉ7Í ΒΙ~’ΎY^["ΐΊ’‡,W ψ ‰κνι|†€Yͺ€ΏTpC4Δαύ ιγI₯€³4 P¦‘,NΝbuέ"νΓ#G†ωαΕΓΘ‘a^NγΊo^Εή§wΉΟ*nE9Ήnύ‚žΏΔ,œ =·ˆλm·€±‘‘κ­DŒ`:Ί4yA›SG°dՍ™£iιnΣώž(LωάciB/•’λRW†!Aκ€ΆJ·pΊ#»Ο‰Ψ(– ڞ$0Φ!₯‚γqePί_jqsσ`©‡]όPϊϋž¦0³E `(›A3ƒB7ΞλͺRλˆ5³IEΔ‘δ¦rόό²«xςžGy9Žω§ΆΩ˜Ξό-ͺι‘)R«-jSYkα\©«ͺ:hŽ5h-Η₯ή{¨:Σ<™pΣΗ%PE­„Ί,f';ά;•²Θ‘}ΑΦ’1ΊΞΦVΪΨTΪDΕ†αΞ’Ϋ+΅‚΅:NQ·«-l|@εš~X–ώ“'Ψe1Ί,Ζ/ “HΒXH¨<‚η„š°'ϊKχG\κͺ¬x9χ»5Dj9½y•RΑ~οΦ«Γ/ΎυΏΌ\ΗΣ<Αm?ύ­67§5¬Τκ=˜XŠ―gC ήk $< ·+T͝¨onΤ8Yτ»»O©q¨ςSyφUI͞»|‘vΓv1γKˆ @§ΔλfDπšuΧc%ΚfC6:²ϋ¬ΐΚΚgΜ"ژ0‹Ϊϋ₯G½ j2ΰ- ,Ί$›R©~@―A ΥFΎ„Jσ%3Ξ'”Δ~¨Ί΅ΰœ#ŠpVλκΒIν”χώζ‡8σςΕb‘ί\y[& 1RΛδlΣυv¬.Οτ*ψ*Μ|y‹φX“Ζ.|fσUUŽ'€°°(%ιΒ€OΣ`^J—ŸB.ώǢVυݝμΥτ/Θξ5€f²γ₯t–΄ž$Œξ4αT”xWέqΙξ±(€SΚϊR­)­γ ¦ Σ— όŠdŠ ~yP8Ο—-T¦Έ‡4’ξ»B T¨}^δΒσσ₯‘ θWNn±G)ymΗ&Z’ϊ$νxτΎωχ_¨:ž|"Ž9sζH$ΨρθVξώΥνv1Η”]XΣC*T£«d­n’ άύέ hŠ–X½Ά#ήqνΝUέWSG‹ένX‰GL―=Fr`Ϋήi―!,‹ΆΩξB›ΜO1™›°έγ°ΧŒ€Κ "RZήa‹ΔΒ¦γ’έcQe+!F;Rί_”ΝΥΧcεͺƒΚ|c•/]~\°Ώa°~„ B-έ¬+₯ΝρW,[ΙIε~…γ» ‹ΧuNc΄Nϋ^½{ςυ|ŽτΘΨΛRπ“Ι$ύθGωέο~Η'?ωI~qΩ2p°ί}^‘Zζ$:”Ύ˜Β“¦‰ ½ω™—μΆ+μ8οe'3€‡9Š"ښΔͺ‰T’ο¦g£.*ϋ0C‚PC’Œ\ζU‰‚j*­T"Φ‡φz`‡p€4H”LL_0›„Ιΐσ–*ISωΈΠƒΠͺϋœΥv2s’>œχyΎνώ—ΰ―[·Ž'Ÿ|’ύθG455ρα˜Ε‹sΧ]wΉΗάpεuW!8“.κΪδ€Gϊψ·?ωxΥfκ‰0B‘λΦ­γ‡?ό!›7o¦©©‰Λ.»ŒE‹qι₯Α€¦οώλΧ)δσξSœh§;ΦD¨Θœέ²†ΈεQά Ε"w^{³Σ ΄ τΏ΅‘“_΅Α½TAJNΡΫ΅Kh‹7ўhξI­wPi΄Νξ€gΡ <<1θΛ6Ee1:.­”€HfΣρ­HCbΊϊ˞8«¬6mͺ) NηΊ„κ“\rπŠ•ΑBαgκ΄Iα’ΙZΥp‹~‚ρ€ΝZR§‰HXŒˆ―ν>šν:ΩΙ _ύ»δΡ;|Ω[[ίϊΦ·Έε–[xσ›ίΜ%—\Β…^ΘίόΝί044Tφs{·ξβ†ούB{ήλ—ω•±Π•°0;ΡF£Ρ(eί–\υ…οV}ίηΎγ"mŠwŽοχΛ–e±²aaΕό?΄λ@Υ–[ΧόY΄Οφxϊ“ω,‡&ŽΨ…gƒ"€2`#ξrZͺσr(!RYœυlΐŠroΔ:RΖ}Ψπ²œΰό^ Ζ Q)ƒ“xK&RεΆmΒ―3ΤΜC—ΒGΡs©4!©H‚ ΊN₯3‘wΝεrόθsίαŽ*}ΗeŒsΦYgΡΠΠΐ}χέΗg>σžxβ‰i?WΘΈιΗΧΣΰ°ϋ“‘Έ“©ϋακτ[Βbqj6!·™£]–ό‹ω¦2SUέsͺ‘Ž?ϊ«wj ΰ±αθ θN΄2W ΰ¦]_?±κΜ΅^ °{μ ·‹Θ·X5*…ίΦA›Ÿ’œλ¨­U[qΌ  "xnͺ ¬Ψβ“O£œΏV‡―\–°”•ψ₯fFIsw/J­|@ιΒ>ϊ±sni Ύ^XF.}™ †yΦKr~ηOψ•C/ϋψ%άπ½λNxSΏ΅΅•χΏύ|γί ««‹‰‰ >ύi»+ό† 8σΜ3Λ~vΣ¦MμίΏŸeΛlKtοΣ»ΈύšίzΩxΒ Φ‡k½DΤ†k¨§΄s_υΕ+ι«’ω𿽏xάσ•χ€{Ι§΅΅aΞj?YΫ¨μΪΗΓ·έWϋ/žL°ιυ―ΦΚ#Ϋ\•& ΔΩL5——AkΚ„ΠXΪ5’mIDε<ˆ¦γQk(“S,ΒV,llτ…„[fη¦RRdE.ž/X±₯’H5Qn―€-΄ζ nυ^ŒΎ ²‰hŽΦσΖΩgΣΣΓEΩΙ,ρžδŽλNܝ?™LrξΉηrν΅ΧΧΧΗW\ΑŸώιŸς–·Ό€kΉ†Ϋn»ΊΊ:>π{fh,cέΊuότ§?εž{ξ!rκ©§Ίος[?₯ŸΧd³6”`AMϊYσQ+βζδK 7•ηήίTί{ώΚŜ©τ”R²mlEYtacœΫ±T8‘Y,wύβ6ϊχWΧqkυ™λ©oφΦBοδcΉt™mζΔVf°–ϊQHΓw’€*VZs<  ™2ΩD‘dΤVΎŠ/ Vˆ*€]5ψ¨ΰ΄λΖ₯δ–{ʞ‰ΰψD kαG5-ΰΫω—4Μζ-sΟ±sϊ•sŽπ΅άη_Ύ|9W^y%·ήz+6lΰK/ε}ο{oϋ۹馛\ν§?ύiςω=5mΪςιΫΫΛυσΣꭌΏO»ΫΝύO֌΄–₯Š9•aτ§πϊ\8.@y δΘrπσ©π}f•U5§όwΙΤ>ίZ”t‹”e”€ΏnFΉ5Σιάπ‘ φ$&£ΗπK*7$Ζ-H?<³"¬j^ΐΊV?Έzpη>Ύϋ©―ρΔݏœ°ΒΏlΩ2ΎϊjV­ZΕΕ_ΜΧΎφ5FFFΘεό¬ΕΝ›7σΓώχΏύ|ώσŸgΛ–-Όε-o!™Lςε/™―|ε+τφφz΅ρ”qΗ΅7σͺ·]ΘΪsOuέ΅ ΛΈu`³ΫHΏ^―I% …«λtφΫ.`ύ«OS’J’­£{Θ½οΣ™ha]σR,αes .ύψ«’lzέΩ΄υtΈ›ΙPv”C’™š’ž•˜―VS‚₯k…j£„βaŠΉr `ΦρXˁ@†Uσ΅rV…Vί@€z IΧvkιfτ@l)Λΰ R/1†Q^MΓ)€’~κΏα΄ϋͺ‹&ΉpΦ©¬k]¦”υΆΩΊωIΎτΑϞΠΒ‹Εψδ'?ΙͺU«ψΠ‡>Δ§>υ)… ›ΝςύοŸώώ~Φ­[Η{ίϋ^6oήΜ‚ ψΨΗ>Ɓ…Ώ4ΎχΩK™Ÿp}άξX+σkΊ γΝΚ’2σ©†Z枴`Ϊο3oΕ"ήσ‰Ψ=œ5Ρ›`ΗΨ~oχ΄"œήΊŠ„Σˆ΄΄~τΉοΈMDͺήϊ·οV HΙ“Γ;Ι :οkώ!-e_½[ΕT†±’R‰°ˆ#ΛΗ¬Κf„kcˆLα¦h|Αb‰@–ώΎ<–‰ ψ*ϊu%i/–3σUaͺο.ζΦvς¦Ή―rΓ|ͺΙπ‡_άΒηή φoίsΒ }kk«k·΅΅ρξwΏ›x€+―Ό²ͺΟί{ο½όφ·vΖ___^x!»vU—1w`Η>~]κ}ΰ<κυυKˆˆ°΅Ν₯ΟOhΣφžώˊΝ@κšψ—οŽΪΖzwͺ Rςϋώ‡4zEύ§;7ξωΥοΉεͺίTύ /xΟhŸΣεήήπΤ{Ηzύ@žΙEMU*ž&ƒ[Ϋ¨ΗG[*6+©;V ¨ΠiԊGέmέ„‘šιLπν+Βmπž₯T¬Ϋβͺο/}›†f'™1ΨbQΟ9ΣO£Η]£‘(›:VσšΩ›œΞ=ή2“\ω5|σγ_`b4}Β ώ—Ώόe.ΏόrfΝ²-ΔΉsηΠίί_υyςω<ίόζ79zτ(---|βŸ¨ϊ³ΕBΫ―Ή‘ƒ;χyHΊeUν wΙΙ<ϋ&ϋά|z€E'/εγίώ,Ιϊ–’ ‡θœΧΓΏώψ‹ηί›²Ϋϋ7“.dέ}!b…YΫ΄D[?ϋ·νακK¨chκlεμ·Ύšp$μlV’}γ‡ɍ—νχ) cΧ³d•’χJά@U¬·ω抍eœί²@½σ¬b!ϋ‹Ί ‘R*]r(οsΫνtTk]'βKμΊηΚλzHΞgKωNαΎ φΎ;>ŽκΪ{g»V½XΝ’mIΰήp7c›fΐ€CL}@!οGHˆSΘK€<%πθ5y8!τ6ά1`cχ"ΩjΆͺΥΫJΫηώώ˜έ™{gξ¬$c;α½Μη’΅»³³³χœ{Ξχ|Οχυ'Υ΅•Ru9ε1 Ja·ΨŸ” ™#ξL1| †ͺ:|πό›}懳’$α»ξ‚ΛεΒƍρΒ /¨$ž™3g"??₯₯₯ppζΜ™7nΒα0Άn݊?ό»wοΖΫoΏ»οΎK—.Ε[o½ex-ϋΎ<ςμv;ξ»ο>ΤUTγσwVcΩ/ξ€dUvώ‚ΈTτΤ‘-Ψ©nΗ=Uα„x«K•ˆœΊp,ΔΦOΏ@}E ,6+ ΞŠΉΧ.€έaγŒεP{9*»λ 8‚1ΙEJήybΐηΗ;OΌήgΤf-ž‡A# Υ]άφcos‰Ά³³%(jμTεAnΚJ EΊπVύwŒ€΅ηvσ­X—>ψ±JH_g^ŠΖU–$MΝEbτήuG€HΚƒˆsPžy ‘Η$fˆ$2-GR~ς―—αH"Eή_y_ "EΡ‘]D}\YM"–’:%Mο]’—…ρ™Γ‘? ’ΛjΤμήτ ή}κυο|SΟƒ>ˆxεεε˜6mΌ^/6lΨ€ .Έ•••ψιO ŸΟ‡yσζαškAa‘1οήΉs'–.] Ηƒcǎ!55Ώόε/ρδ“Oς ¨G]]† ‚@ gœ O¬~ω# ΤΕ]β©ΒŽφ#ρE±g k.NŸΔτsPΎ€Ζ}\ͺΡӈ§v"ιϊSΞGpMή\€ΩΥΉdχ!όφΪ{ϋ|r ςπΠίώρ‘a‘ΐΞΖCΨΩtX»ͺ]›ϊ“£©ΣΘσ˜ψ“Rζ10άύλ΅σzŽ4’ςρ-f—zŠ6@e_SLδ„ˆU‚d· <—χX?%₯bP°ΓΚ"ΊŠθ<§°wˆγ©%qX혟.2y YŠƒΡ±·ήyκu<σώλ;eό‰‰‰HII1Œ΄zε•WPQQΒΒBά~ϋνπxˆ„d•ό£8ΰjo#Ύhή'^–ςΧy›±©q‚ͺφ€Άs:%;‡;υ΅έ7zόϋc?A|²ΦξέθΖζ#ΰ<ˆ˜φΕX)¬^½Ϊp­§NΤ"ψδ ’φt{ŽwWq†Πτ ΜSxkβ¬Ξˆ"3α"‘nlm>ˆέmG¦aAΕ‰`tJμ’f[V}Ž“GΛϋto—ήw+f,žΗYζ''7£;δε‘ΈώAkΌ™˜΄™s£b+€4vΜμr;Ό ±―)ΐ„H `nMt mΑpXS\šˆ€0ͺ‘IΜ@ŒˆaΦYHγdC~)b΄ΡςšΔ€ρB#—§q‰ͺͺH Ό=ΡR’ ρƒ$XˆωΙY˜˜u²γ3˜Η5ο]~θ86Όυ)Ύό±ϊ&Nœˆ'Ÿ|S¦LA\Ώ3œ8qΛ–-ΓΆmΚn—ŸŸO>ωcǎΕϋᅬ₯KΎ|ZZάn7dYFww7ΪΫΫ ‘΅έnΗλ―ΏŽeΛ–α«―ΎΒάΉsqΗwΰΥW_E @(B||<ώσ?oΎω&NžŒ­¦“5(φ ’SΤ%Ύ½υŽyͺŒa4(ΨSγHWΩ{Šόφ)o+*{κΤ4 Βp€bΩ …ˆ‹N’ΐG/Ό…wŸ\ΡλύΌ`ξyζW°FeΏ)p¨­ ›λχB†φ~D Χ©ZifΓϋθυPνA>τΧ§4’’MΐZVΞφ‡qδΝbj#)ΐήoΐ*)ικ“FFϋΌ`Ω”@f‹ ΜO*λ_AΉχ§\έΖς —†dΉ3° `:ΜDv|ΊαjΪ[Ϊπη‡_Β“?|ΰ;cό X±bvνΪ…ΉsηbΥͺU(**B\\/^Œ`0ˆΑƒγα‡FB‚’·VUUαΝ7ίD0Δe—]¦ςϊ[ZZPUU…šš΄΅΅ Ήπ@}€τ:Œ=ZΩ?ω;wξ„ΣιΔηŸŽμμl<ςΘ#½Ώ¬Φγ“ΧήηrΕq‰C#κ?ΖEΣθoΓΎΞRliُΝΝϋ±₯y?Ύi=‚=uJ€MΝKͺ–ΩρqSΞκυσ†Ζ Λo׌@[ {šBfΚR,‘™š²Sω)Rͺ’ jΠΞK8< jt’D`qΫϊ˜9'́Z$Sή15b3ΊB~lγϊ JΕqT„’ Q €d!ζšˆ+‡ΝAAΚ@X$c΄ρέΟp5uo~‚ΆΖΦο„ρ/]ΊΈυΦ[ρΕ_`μΨ±XΆlΚΛΛαυzρΙ'Ÿ`Ι’%e^x!¦M›¦ΎφΥW_EYYάn7~όγΓf³υω}£%Έϊϊz΅„ψψγcκΤ©Έκͺ«pκTίuJ)Ά¬άˆΚΓεκθ΄Ψ1&±P·f(ηΠ)%<¨ύ°Yj@₯(*»λΉηe ΞΕU?ΊΑτϊœnn}ΰGΘ2P+eRΫΒς2τ\b€±–Oa$žιηΈ VΥJνJ·(­Αύ΅ηώ;‰¨GDdΫ’`αΠέΜjκ ΅_ _PSαa eΤΤΠ”|όΫψk0&c˜a._(BΥρJ<|λ/ρ?>Φ†ζ>u€ύ³έέέ „ ΰ駟ΖΑƒ ΟΩ°a6nάΈόrmœvWW— ΰ͟?sζΜιΣ{:5exώωηΥΏ―Z΅ ;wξ<­ΟΡX} ŸΏσ™2/2y9ί•… [²Vm“O’ϊ}ΣψχβΆ2εϊz‹Υ‚λ|ψΑup'%ΐζ°Γj·Αζ°cθψ‘ψΝ[ΐyŒεZς΅•£²«ήX#B"Ÿ9ͺΗpϋΕ|T]”m˜ZL 9­ύvf ΰyΎ/Μ’œp Mל“Wv>^΄Œ0ε6pσ•΅ρΠΜ<= ¨†a$’•δŸΓTό°θ«ςspr.<“rΞƒΥbatι•·¬-―Ζ§σ^ωυΣL―ϊ?χQPP€yσζ!??΅΅΅8rδ 1aΒ 8οΏΎΒK)ň#0kΦ,μέ»Ÿ}¦±έ>ŒΩ³gcĈp»έψδ“O Ε–ΩϊγˆeΛ–aΥͺUxπΑM)Γύ=*ŠcΒΌ©HΟΙ:-° ΥήFš<˜ΎΟž­5QƒΞ`W°Cά9κI%‹cfNΔΤ…31tόHŒž>WόϋυXrύHΝJη ψDW=6Φοdμ—ί±Μ+W4fT¬χU$Ζɞ½}[‚­^³ώoŽυ50ﴐˆ–Ψ˜’HaΰφŠΩR”«ηκˈ\©O­…C$ξNΑ’‘3qιΠYΘKΞ6\—ίλΓ/Ύ…Ηοϊ-ΦΌ±T–©^’$̜9»wοΖργΗρΑ`νΪ΅hllΔΒ… ρΘ# «« ³gΟΖΒ… _ͺՊ‚₯ΦώΞ;οœΓύχίY–±dΙLœ8‘Ϋ]!°X,HOOΗm·έ†¦¦&άvΫmψϋߎ;ξΈέέgŽ  †πg,Ίs‘εHεJΌ΄—€R3D£Ÿ)Yα©Υz˜‡²ηbΪe³1ο{‹0tάHfš‘ς¬šξFl¬ί‘ ‰‰‘γ³τ^6Ή7«ξQ}š M@ϋ·G  φ˜£­ύILaqΝZo‰©γ²εŒ(θό FQGCS%"afώ\{ή| O»Εnx“λΏΖ/Ί+_yΝu½S_“““1ώ|άuΧ]°φ±νLN§&LΐΚ•+±eΛδδδΰ‹/ΎΐΊuλPWW‡„„¬Y³ƒ RυψžyζΈ\=Τνvγζ›oΖ₯K±sηNψ|>dffrο³wο^ΌύφΫ€§Ÿ~~#Ί› IDAT0tθPlΩ²₯₯₯¨――GSS^ύutuuaωςεΈβŠ+ΠrΖ?σѝΕ1•ι©£[ ›‡’Ύν©”©ΗΙ ΨάΈΕνe‚SˆOx°₯λk·#DH€χΧaCuQJμŸ (ς½ˆ‚τΛ˜ίJ‘,It!bοLΝ<ϋJΠΟ‘c5ΤΐGΙ‚Ό€lά<ώJLΚ=+?΄T‡qκd-žϊρά}’©Ά‘O»ώ΅Χ^‹·ί~λΧ―ΗΛ/ΏŒΗv»ύœμψ3fΜΐ+―Ό‚={φ`Φ¬Yxψα‡1{φl̟? .Δ5Χ\ƒύϋ•Ω‚/½τΦ­[‡ΚΚJδεεaωςε€… β―ύ+^{ν5ŠŠΟŽ;°rεJάyη*θπΪk―‘ΉΉ“'OΖν·ίŽRlΨ°’$‘ΈΈ/Ύψ"nΉεL™2O>ωδYύό{ζ―hihVβkΖ$ρμ² 4Ζξέ)ΝδB4ŒmMΕxηΔzœπœB˜†’a„©Œ•ΥWzκπVωlm:Ÿˆ]ι"T¬oΑ]/b0iΓ_Ω’  ֘φDϊcθK‡Φ9σ’‘Ί`¨R‹η0p”_Uβ9RχΧp0\€(Υj½ ZAδ1IΛν£u}? Qs’`Bξyž1HKl‘‘¦ΫW…Υo¬„7‚ΪΫ1mΪ4<ϊ裘2e ·›FΛ]K—.…Οη;k`³ΩπΔOΰή{οEww7F…κκj8yυΥWγΝ7ί„ΝfΓ½χή‹ΌΌ<όόη?‡ΗγΑΧ_ωσηΓf³α‘‡ΒΧ_ΜΜL,_ΎcǎE0ΔϊυλΥ°ήj΅βυΧ_ΗM7έ„ͺͺ*Œ3@θκκBWWW―Ψΐ™:!ΈμŽkρoύH5€ΒΗυ›ΡκKε” e­|]«»GΣΎξžh‹CŠ-"©uϊf_;:‚ζύ΄£JΝszΚΩOΚ\—²yμt*γ9€“ΟlCΧώz³Ϋω=(d >EαX₯] ΒΆ<Ξ[ι’Kκh)Ρ!TΗf³aμΨ±xα…Πά܌ΔΔD,Z΄ο½χ’’’πΰƒbγƍxλ­·0nά8άύ°ΩlΈμ²ΛPRR‚K/½‘PΟ>ϋ,:;;‘;οΌ^―UUUhkk;gΖ]k[?ή„}Z_Ύ]²b|0ΖψuA%«σͺδ¨87d™ΑnœθGΉ§ε]΅¨θͺ?1Μΐ’B-[σš” ZFΉ3ςRvκ< f˜Y$υΥi0&C5ܟ γ[Q=¬–ƒ‘θdδq™ζά 0l)Βόw@4²σ‚Σζ0Ό±}Gπΐχα½gΜ΅œš ψνo‹κκj,^άλT%Lž<«W―ΖΈqγΎυb0`-Z„gŸ}λ֭ßώτ'\qΕΨ·oŸJ}ΰ”$n¬ͺR>ŸέnGmm-^~ωeδͺ¨ΐο~χ;tvv^σ裏bΑ‚8~ό8RSSρΩgŸαρΗGYY^{ν5άxγg=ΜονhklΑΪΏ¬BΠPΏίμΪ΅ Χ_=ŠŠŠ …°qγFα}(//ΗκΥ«‘””„qγΖΑf³α›oΎΑ_ώς>|ψŸ’ϊqβpΖΝ™ŒŒY(,D‚έbCUΟ)ΘŠ(%ϊ›@λΑ΄ΧŒ &X‹[Ι¨g`Cu'ைQA4l(%φg7ί–uψΜv7”φ5π*"Π0P“(˜…;Ž!iΨ’ νΥ%#;1Cϋ”‘0Ύώtώσ{χbλg_τ{‘544œΦ6lή}χ]άyηύzέχΎχ=lίΎ―Ύϊ*RSS±tιRŒ3&Lΐ‡~Έηž{0pΰ@Όχή{$ K—.EQQW!xζ™g››‹M›6α7ή ƒ’Γ9οΎϋn΅ό':***pχέwcϊτιΈβŠ+°{χnό³σΐσψκzδΚBŽ3έ`ΓϊΈŸ*Sϊ”ΑT(’‡ °Ω ΝΓ¨PS“@<ζόψ;*Άj†Φ1‘₯²ί4pχGlΊΟ)€ι ¦ aΚ‡>‚/δθ9ϊt?€•A6Ό˜šD|‘kiͺm@W{ηι…›mmύRΓa””Όψβ‹ψΕ/~Ρλsγγγ±wο^ΌσΞ;Ž=Š}ϋφαΪk―Ε¦M›`±Xpο½χβψΪΪΪ0rδH,Y²pΥUW‘’’Λ–-Ú5kpωε—s9ϊš5kπΕ_ΐαpΰα‡ŽyM^―Ϋ·o?νΟΆŠβR|ϊΪίΈΏΝH Δώλμˆg£ScD@)o΄”ΟΕΩγ5œŒΔ¬niΐ€>  ά&hx{z*H[B²ΧΤ˜nθ±"Ÿ8ΜδδΊ4†κ&S΅^vvb²ϋSΞk§ΊšΥ'J f]yr ςNt‹rΪ…Ύ―—6`›Ν†Η{ <π€8Œ555€ΰυΧ_²θξ»ο>Θ²ŒI“&Αf³©F|χέwγΐXΉr%Ό^/ξΉη\zι₯πϋωοΈ«« /ΌπΌ^/ΏώzLŸ>ίεγγWήC]Eϊo—Ł©©ηChS~ °QAώΜΣ‹τxΒ-\ΎbOΕ‹–Ϋ ΄‘6„š&(LDΟPM.G—G€RŠpwύ΅g3ˆόg4ΐ 9$χŽk°·_1*Ifμ~‚$§ρ©ΘHHU‡Ch§€ΨTφ χMfδdbΦσ Y€Σr±ςψ;wβΠ‘C½žηΑΔsΟ=§vά‰ŽϋοΏ~Ώ³fΝΒχΏ}ΣL9JKKqμ˜Βή5jV¬X ??£FΒ/~ρ ̚5‹γαλΟ?[Ά(J1/Ώό2άnχwΦt΅u`ΥKο¨S…ŠaρyH³'š ™β+ξͺψ³qf½N–˜`ψΊ­—θςyJΈΌŸΝ(l€”{Z΄ΘEΓΑ¨ ₯¦Ί™šΫ₯©=Ηͺ„Μ0„eΔ: νΊΠη8 ² i—iρ)˜3t nΎΰjάzΑΥΈeΚUΈnόBŒΜ,Tψ‘5w·£΄©’;χΒ#gH£€P(SΡΦγρΰ†n@YYY―ηΊγŽ;πΩgŸ!;;[ψψ‘C‡πΤSOPtDθ>!‡Rέ8yς$ΪΪΪπK/A–eX­V¬\ΉRχ0;:::Tv`II‰ΠΡ|—ŽkΏΖ‘Ε*g!Ζ% γŒZ‹ΰωŽ9ΆtGu ¦šΤ€ώƒ α γεΕΡΏ1ΣW΅)1ΩA Β‘B(‘ž`o€Π™‰Β2δ@’ͺ8—Š)P]πJζNΖε£ηaΚΰ1pٜκΝN€KGΝΑ„ΌQj(&S{jŽΐΒ_I’pΫ―οξχβ ‡Γ1S€!C†ΰΠ‘CXΌx1ͺ««{=ί¬Y³°fΝU!GΌςΚ+¨Ζˆ#pλ­·_²d °yσf5ψψγΥN»X;?{lΨ°ωωωͺvίwωθjλΐκΧ?ↂŒ€g:#ΊΑ‚KμrczŽΐq€Βœͺ’OΜ²0U/#ΖΧ=Έ'Ψ;HE¨ΕλΆυ;h‡@ATM!£«“u!˜H—˜A‘Ϊ―V"aΑy31 >ΥԏŽΝŽWbd\PΧو²¦*ξ™Cnjΐœ«ζχkqQJΥά\t€¦¦"33GŽΑτιΣϋ”Œ;ϋί1yςdΓc555κξόπΓ#=]A΅322πσŸώσŸQYY‰ϋοΏ_mΆihhΐŠ+0wξ\,X° Χkπz½}rXί•γ›ΟΎBρVMάΖ c’Š`#ΐΐ¨7ί‡ ·,uF)ΛQ@ Ψί(Σ…hθyƒψΕ’‘tTgβζ ~€n°)¦c7΅ηX ΓΔJ вjL€Sgγ2ŒrE‚ˆkވi\η4TΧs:νΙq‰•UΔ‰"l?Ήޏs,ύρ-pΊ]ύZ\MMM¦»€έnW΅σkjjpΡEaέΊu½žsΨ°a*uXοp>ψΰ>|X§υƒό;vμΐγ?Ž•+Wβϊλ―ΗΦ­[ΉΧ½ρΖ8pΰξΉηžοt^ΊΗόζ9ψ½>ΥX²iȏΛdΜQRέL΄ς%β؝PΓ kƒ"±QόFΌΑQJL;€£υͺω84!’Œ₯/DήΣί³³ΓΜXbΨπΥ0)δ{VOψQΡ$ΒY’*’ =}qM8?wζ ŸΒMnιhnΗKω$NUΥaτ΄ρˆΚ&e'eβxc|‘@‚α ₯δ¨uU›Ν»Σβν}Χ•ššŠkΉ‰‰‰ΒaǎjσMww7Φ]‹aΓ†aĈ1Ϗωση# ©z|€RzLNNΖάΉsqώωηcρβΕ¨¨¨ΐΝ7ߌ—^z •••B¬βΔ‰Έι¦›ŸŸ―Ά_:Ί;< „`ττρκN˜λΚ@IηI„©l Κώ`šr‘Ρ Š‘†<άΐ2"ΊI>"g o 6mω3©˜ΰμνΫͺΰ;Ωav»BΠ+S—ςψ…χAkŽyL“P*ς{‚ӍΉΓ§2„χ^ψ N-ΗΖχW£ό°ΆΘ-’„‹‡Ogή‹bWυ!4yΪ΄ΙA’„i g#oθΰ>/¬ζζf47‹‡:Z­VC>ίά܌λ»+WμυάIIIxμ±ΗπΫίώ–“θ~ωε—QUU«ΥŠ΅kΧbΦ¬YψόσΟ…τ]6―ψγαp8π£ύθd°φQQ\ͺ."»dΓ΄΄ΡΜ.Iu?ʌ„ ΅Ž˜δηΙ;"”±ΠE”?¨$ £·s‘‘βΆ]Dl‰ΩγbjΛ±F˜#r’Γ WAjD„§χF‡wHQέ•¬ΡT_‚Ή#¦"?-‡‘γ&XύΦ*lxOS­©8\ŠyΧ,T#ˆ$W:ΌhξiS#ŽΊFŒΝ‘FΞ8',V+nΫΫ§Φ_Y–qΝ5Χ¨‘>χΉ% •••X΅j•α5ο½χ²³³1~όψ˜TbI’pα…"!![ΆlA0„ΟηƒΧλΕW\δδdlέΊ΅O"š‡FQQ~φ³ŸΕΔ.ώ·_^O&͟–}lqhπΆ)³©8\g XΟ ,ψέΠXR5ωΏV” | ωz¦šΑOΡ&βΠτq ΒέA³ ΐΆτ78aV:w€0€>ϊ"+a,E2Σ1tΐ ¦Γ©¦ό$>|ω-ξ}O¨Εη¬ζ^?·h*¬’ώ΄φt Έώ8χs―Ύ€Οδ ΗSΰbΰΐ¦»νέwߍ_ϊΧ1wξθρ“ŸόoΏύΆ:@γOϊΆmΫ†ττtά~ϋνjω/ΦQ\\ŒK.ΉΕΕΕψΏzμέ΄‡˜‰Μ6Ɋ1Ι…PΥΰΗ䣈½Γ ‰y„!ΉQ6Πρ(ξ›Β|šΙNΩ¦DΛY¨0K {ƒu™’€B[F@t³~UΠ4’D’ΐTΰάtŽ€‚a#ΑΟν¨―=τœρ}Γa|Ήr½¦ΤK—́iƒΗ«EeYΖώϊτθΑόξΗύͺ˜5₯¦¦"%%Ε4zHKKλ“ρΐβΕ‹ρΡG‘¨¨²,γ7Ώω ছnΒψργρ―£χ£§«Ÿ½ώΌέ^uMd9Σ‘νJΣ!ν:*Hδ:βώθΎN Ώ”Ζύ9ܞΔπ7μ‡>Ά@ƒ'–Ζ ±ε~;€£0γΒLγ5q”":%5 uXl˜8θ<οάόιη¨?)ik+ͺ±mνW ,’ ŒP€4W²zή¦ξ6kͺΰΞ9hx.½ωκ>-ͺϊϊzSΪoff¦P`Ω²ehmmΕςεΛϋμEηΛ/ΏDaa!ΆmΫ†χήS°š¨$ΧΏŽή}_μΐžΫ™(ΐ‚‘ρyˆ€₯Ў²y€`ϊŽΔ3…G‘½.ε ”η&²μD3€ rͺK\ žήX€GOΗΤ †0H¨έg0w½D5qμ=™]§έ©ΎΆ»ΛƒΎ†6ΟΧ?yύoθjλTοTΌ=csF@‚€ήΜ―OξC0δξέe·\ƒ΄μŒ^Tee₯©πEjj*RSŽ‚ΫνΖ¬Y³°{χnΌω曦‘AoGnn.:„I“&αε—_FKK .ΈΰάpΓ ²ξ>o<ό g>™ŽXᆆrε5Žφo ―)³pΈ _g„ΪΞ'¬‰υo@mΧ7Ϊ’ξ=˜Χ›zx]ΰ±ε~;h0;ΊbΟgd%“τYƒ L-Λ}e‘βHi/ΰο=ξoγsG"Ω₯qοCαΎ¬ΨΕ]YbjώχουΊ˜JKKM%ςσσ±hΡ"¬X±›7oζtOχp:XΏ~=† †gŸ}%%%ΘΘΘψ—eχρh=Ռ’]1+Ι― w5ζΊtž2ˆ>U‡‡†.L(5eF…EŒŒ=ccRΤ¨i€€θ€H±Ά5…xΠ φ'©.³‘`1mΈ/ΐ”εlξ6v=Λ:μ:β£ήJd…οŸβΦ8π‘`Ε;φ!μ]vjϋΪΝ(Ω{ˆs& GΜ€ n(C}WwK'Μ™Š‘cGτšΔ’Ύzΰπξ»οβΊλλΣβ|ϋν·ϋ4!ΗεrαρΗΗΦ­[±hΡ"<χάs²μ~'K*t%iMΥ€λPŽ?+-F(oΤ†Q_TŸαλ±£zEl.Ό7:¨hγ§w¨ΫmeΘl@―6άPbκZ½Jή!r<@γΝ¦•SΔ½ΜοσγΐΦ=}ϋžx!u§&ȊOΓψάQ\ž·‘tdζΫHHIΔ‚ο_³[°ΎΎ¦€ 8PHΕΕΕ?~<–-[†3fτΪDτ駟bμΨ±Ψ΄iNœ8ρ/‹ξη!Gqυλ恡ͺ”~s5Œ­ΰE>5Z/6 qό}=˜°ςc̈’¨ƒ‰b"A"Φ)ήSEŸμπ™©υjΓ}qkΝ °°ξ(π± ΘgξΠrͺ m}ט?UU΅oΒωΛ©ωcgΧ¦ ΅φt`oέaΞ3O]0ΓƏŠϋœ¦:P(BII –-[†1cΖ¨¬ΑŠŠ ̞=Ϋ@λυϋύΨΎ};fΜ‰+―Όςg\ΉEƒŒ«‘<©˜V§ο ’Π=ςzVŠβTi/͈ΩΧ³ΣΑD#ΏΈw&LšB5qPΚTΣB~„:ύ§eΓ}q1•4ύu‘JΘΊέΰώuτοpΕΗaδ”ΡκΪς„z¦pOΘ―eΕmψμ›θZp©ˆοo‚φΡ> ‚ΌoE*‡€•ΚγUƒ„=ΣΆαΎŒΈωΐ…B0ξ”ξ‘ψ>•·@  ΩH&ŝ€ΝŒ'Žτ_TσΤΙZ|³n3ΌύzX,!(J„Γ e8εiΠӎ’¦ LΙρ¦©™©Xς£eψπΕ·„ηέΏ?nΌρΖ>]C}}=~ύλ_«“zz{ξw܁ηž{8+SuNη „ΐξrΐαrΒωi³Ϋ`uΨa³Ya΅ΫasΨ`΅YasΨ!I¨¬(έΘ”B‡AeŠP ˆP(„P „€ΟP0ΏΟ_ωπωαυx΅pύ ΧόΗχa΅YUΓͺχΆ $‡Ή›£ϊSQ>O ›–žFL…κΓPχΎli‘;‡n•, `"Ώ„ΓπŸςτf»ψΆΰ3lπˆSπƒΉ¦F ΘHpΊ!Βψ ŠκςΣΛ{Χόu¦-œƒΜόl Δٜ—3kot{μ¬)ΖΘ…Htj,ΎKnΈΫΧlF]…1μξKs ₯O<ρ~ϋί£«««ΟκΓΝΝΝΨ΄iΣ?t·Μ)ΘΓ€Ό,€fg`@n&’3ΣΰNpΓ•ΰF\‚qρq°»pΖΉ”)Ή6³₯Βp;˜–e}ψ}>ψ<=πϋόπu{ανκAg[:šΫΡΡά†¦Ϊœ:Q‡†ͺ:΄5œž3œxρ4,ΈI“qSžZΘ+;%•ω!\κͺŸ=‘Υέ Dƒ<τ«œˆFϊβ7δ(˜Η7ΔPίH’ΒBΙπV΄φf»ίΪ¬π+Ρa_‘v?l)N°]T$ςΑ5ΑSb ΉlNN& £³΅γ΄@ΐΐΏŠŸΏψ ϊ·‘ pπΤ1Τw6)Ή’Β¦ςΈκΌyκsβάX|ηυxωWOυΫμίΏ;“³η‚’q#qήc1xT!Σ’ΓΆ+;ΊΥnλOβΕ[»Ι(wI"‘HĔ$‘ΡΘF(B(@ΐDgk;NΦαΔ‘r”μ>„c»χ:Θeκ’YΈύwψ8υ|­¨νiτψ`άε9~Υrl"ΔτaΎΫ>γJqW‘Y<‘μ4!]F!μXδotΨ‚Ώ¦³7ΫύΦΐ\#K–hμ‚5ΩΑ3e”’ω¨€˜πŸθρ|»ι²GvΔήΝ;1aφυŸ…Χw}€–s*ΫkPΩV‡!)Ή*r;~ΞŒš2GvδΞWVV†p8ΜuνιCω6γ—$ ›ς²0rΚhLΏ|.†M<ρI ίςΜΖ€W?“w ΤΰŒ"±‹—v—qHΞHAώπ!˜|Ι uη­(>Žλ·bί;QW^P0‹ΥŠ‘γGbΡ-Waς‚ά%†δJ:O ‡xΰMGι%άΨ-’Μς£FŒ@PΝ$ϊ| €»"ϋΏ`Ξ€Ρθƒ*ϊ ‚š Oo`Ε™p>_Aι δ—E˜"ΠΰAά°tρzΡθJp$ΣΘόΏθtν±π9υζΔ¨ΙcΰŒSD@’œ ˜š7;k©e—MείΰΆ WΓ")H\ξ8,Όq1Žο;Βρ’ς`ΎΧΘ‘#)Œήαr"#/yΓcΔΔσ1ι’ιΘ-ΜΣ¬ΩΞO§ S9„ #DCΚ‘Θ€Μ0dȐee‘Ggjο €ΒB,dY‚D,D‚Xa%Xˆ+±ΐ!Ωa‰tLΙ5Ϊ"*= £‡α{χέ†` ˆžΞnΈγa±Y λM¦2ŽtV’΄³ΊΎaW¦Ί €φͺ`ˆκ<4…`caKΗ:Τ z¬Aίcΐ;LbZa`qρ˜•ͺ―`&νίOΰ°Mδ Τζ…μ Αβ΄ρΖcύ’GH¬ΒΑimM­ψμ/aΙ]ΛΤΣOxJ[N’ΝΧ  ΣηΑŽšbLΟ‹¨,ρψ9S0nφdμώ|;wΎ£Gš:€όό|ΈέnUͺλ\ƒF`ς%3P4n Ο†τ™|¨K£›„’3ΤƒPwδgzΒ>δ ζδ04„0 #LΓόi Ρ1exξT}[ l’H°I₯=[²Γ.Ωΰ²8ou!Υ–ˆKlq1 sε°ΩlHJKβεΈ#ψ¦ΉG;O 5}d‘ρeΑŒ1,ε έ@ &’O ΞΖΧGο'”…`(‰φϊƒ1ΙeΫ`2  Ώ `ΏιƒJRrΪŒΣ€a~‚“ΧχζΫ]Ξom”Rl[σ.Έdvd€pXΈ o,V—~­:€’¦ ΟŒ΄Έ$uYίρΰ=pθΠ!̟?ί4άΞΛΛCIIΙ93z§Ϋ…W\ˆ‹Ώ²‡ D|J,+D’Υ*ΈJP¬ΖΧ„ΆP'jΌMπ„½Λ2dΘ ©J…%ΰ΅©¨ΐψEΰΥ͈T"ŠP8²CΤ€H D‘JoœΕ‰lg*2μ©Θu₯Γ&Y›1μ„Η:«°―ν:ƒέZΘΜ™1―!ΰΪ ³5AΓ„π„2η0ξόΤΰΕͺ0ΰΑ°Y“ΐhξι-ߏX3>ϋα…MT ίxƒΆφΐžαŠ€λLμ‹€@ˆ'Ήά ( Ώ•‘΄Τ7aΛ§ŸγΊΈ « ΐ΄<δ5f’¦³A‡― GΛ0}ΠX"Ψ…;1·άCόεχ/«ηκ 0qβΔ³ξ¬v2σspΩνΧ`φΥÝoŒ—‰–—ː¦2Z‚hτ΅‘Ό§]α“/D[Φ†\Ίκsx’wl^k{σΖ"ƒ‚Frυ@8„ξMώ6€–H±&`;γ2dgF $‡QΩ]‡Γ'ΠθauΒΑ4† TTη Usq6Ÿ'Εa?Τ1όŒ)*a@wΥχγSDJ'l IDATŒΞ]΅±–MzaφΧT™9Τu"nh±pqΏzou ,€/θC˜Κ°Β’„ e@:kκΏ΅Ρ¬}λcΜΌ|ε+Q€Ε† 9£PΫΩ) Rμ©=‚QŠΈ(`ξ5σρεGλp²DΑNb mΤΧןUΊnjV: Η Η’ΫΖΈΩ“Τ)Μ"™9oΘ‡φ­NΤϋ[ΠΰoE†t7ݘσ—T”5ψh J O¨ ’¦†ΌžΚ¨ΎF-Ψ‰ΦφNμk;¦Φ¨Qν'βΔD8‚φ~z>½ŽžnΆ5Sύ{κvyΚu½ 0.y08\aθ‘/²°A₯θάSΧ{ν8ξγzl„ΒδΜ_ίκG‰D"Ώ7{Ϊ iΐ αCΘύωχ/qΰlaj†€δͺ‹M¦ΚΆqΘj³aρ–ͺ5οθΘmΞΩxπΑ1lΨ0NάσLiΩΈώ'·ΰηό~΅βΕψυh3(Br΅Ύ&liُM»°±ivΆA΅·9Δν”CΆ¨nΡQ]α‹r ZnΆ^΄‘F?ފiΐΆEέHw½1RžN η2ͺCΥ™KV;ρeͺ6ύdΊΨτF稨ΎA€khγs~*¨p©Ξ™P!˜ΘjΔnŽV τŸY‹Rό§<πΧuΕ*Ω쇉 πι:ψΪ T{‚΄τλ§ϊ0,ϊ₯ω~Β!Ξ Ÿ7iΜ3€²βcψrεzξo ‡Νδv‰ϊ&n(γvͺρs&cΤε:Ό^/ΪΪΪ(v>ύτSᑇ‚Ηγι3ρ§―Ηεw^‹Η>} ΧώψF ?Rx~OΘ‹έm%ψ°ξ+llΪ…ξZ4ΪΦxΚ₯žaJ£₯” {© •RέTF9ς’p”Ώt7TW"ΔQsΰΫf©Ι”XΒfμ:‘ZasZτ³v«eή‡Š’'V±‡šD1βωƒTημτ©‚ 1θ'σ<Ε  ˜“€ΏξsŠΩυω1!ZΧs¬΁IL}˜δͺΪTΣZΟuYtF κƒ—ήΔδω3ΰNtƒ‚ΐa΅aNΑ$|ubςε‚Ν'φ`Xϊ`Ψ­Κ­°ZmΈμΆkqlοψ|>£©© Ο=χ6oή|VΒύΒ1ΓqΟ3ΏBnQΎ¦4«†έB£ΏG»N’ΖΫ¨J©«| J@ ‘•ϊ*Ÿ·ς ‘]μ’Ξ6Ω c)Ξ(›ω2»“†R]t+¨“›‘fΈΌW+m„)ΥAԏkδ(PγΨ:ρΒ|_¨O!TΘ7\‹A‰HΟ$ΒT‚hrPFwI37‘[ΏGDl΅O‡₯λ4…,œsξςΑ}ή«Δ+G=šZ ˆΞ Αˆœ"UεΧξrΰθξb΄7·Γ ‚=}‚ͺPœαNEEk5Ίƒ>„†Leež@dιg ΜBmyN””cλΦ­X±bEΜ‘‘§{ :Χέ{ ξzό>$¦&qχ Ϊ‚]¨μ©ΓΦ–bι:ΞP·Q†ZgΠD`"@”cE„«]p*€ΣΊ!™†ράTPΓώΜ’Θ¨Ας»°PrK0ŒΈϋΊΟN Μ]&Εc”ςύ…Β 48*½βεRŠΐ©.΄¬/ƒl> π+9Z,3[;·φn­Yˆ„u„ £§ 3†NPY„’$ΑΣΩ…’½‡Ο˜‘5Υ5`Τ”±HJOD$ΈμN”΅V«φΡασ`prN€XΉ–σ§ΓΖχWγT]lqΣ9’ΣSpΥoΐΏΌcgMR―#zƒzΒ>μl=‚½νΗPΩs ώp€ˆψν_gDUΝκιͺ5ν‹+`φza`'Ίˆά&Rˆ•:™Ξι‘uΨ#5`ξLή­ ₯ Ρ#|€.ΰkΤψΩ¨I‘‹κ»Ω?ώs~bζˆωϋΰ9Ψ€φ―cβ{?PvΆ@€ί˜a4Fά° fP”υG΄ί£†RŸ”†ŒEcHlv;nΫˌ}ϊv‡ίλ‡ aτŒ °HΚΗMq%’« ώ.€„"D—AΙ9"–hsΨ1``vϊŒμ«/ΖέXŽΙσ§+]u ‚bΠ‡Ϊ+°±aύν"£ ”XEλ‚ ―,‘<*iΥΗ3Έ j>G/)]T]3"ΫβƒΊΞ ΅\ΫhόΊι=ΤXj£†h‚p˜”ΉΚ5D'1Ρ Lξz¨±€ˆΛύZP  σ]ʁ0šΧ•Α_kZΈΉ?λΡrk8ΐd!θ Β‘—KœM =£αΎΊX  xΌ=7H£Υ&€$αδρJԝ8sƒ.N«ΔΔ9 9#U5Άx» eΝ'•œ— έ­–6X‹AFΞ”,ASmΓ·Ύ†yYXώΪCΈτΆ%HHIδξ‹L(š|mψ¬n+Κ=΅‰¬a&㼍cΎ  ‰‚}"ΘzYΰ†Β 5Ϋν τp0fΖ XήλχbŽ%NJkRξšxPTΰ!z‘δ₯œΆ₯±ΪA ΐ+ Ρ;O„°e« Ke[}hx·8–π+VŸmP ΰ.Σ²‚έGn’:™'šσ«ι€nΗλx‘—š…d·""Y$$$%`ο–]}μλQq€.Y bIΞ4χ΄£ΕΫ‘Ξ&¬σ4alΦ0υ›³Ϊ¬HNOΑΞυ[cͺχfψWάy~ϊΒo‘‘›Ι/š}ΨΫv ["Dv&b"€C²Γ&Ω`%V5ZΡlšpSl{³fMZ‚gξιϋMv‹σzιF¨ ­«&@°C Ψ=F§@cD)Œ 5dδ"ž‚‘!ΐ€ PQ!ͺoΘεM ]ΔCΈKiΫrž1ιΏw’Π3αΊ\ `€p‘ΘAΙ VKd§'œ3P’†B!9„αΩκί² ©ΆU₯'Θθhi‡ΓεDΡΨ‘jτ1(%N•@¦2@z‚~Ψ,Vδ$d¨[iFv&ͺKO Ά’Ž8'έrnΊί1yώ % b Υ/±£ι0v·E½·šΝkSa βs0>yF&F‘;EρΉpYœh t"$žΩΒ9­ϋ4nΥWΔ+—Ζ2<Α@Hήpυc°¨iŽaι =Γ«§’“ΫΛ” NυŽΖ ΐdC’ˆΤήΓΤYRσΘ$" 2E݊½±ΤψLfyœIΰ $ΙSφρ°¦Έ”»-Β먐@䁯ƒs‘δJPWlαωΓ°kΣ6mβΛ8ͺŽUbςΕΣαNˆ`! (m©R °ΙӊαιCΰ°Ϊ•ΏIr† ΔζUϋLΎx:ώγ©_`Ϊ₯s‘”š ν( €΄£ κv ¦§‘ΩΡ5η8,a.ʜ„α‰ωHs$ΑmuΑmuFΈςiΘv¦£Φۈ ‚zC Ώ’«bΡς!CͺΞς΅ΥΆ.‘‘}LΩ μ38JME*Έ0m'fZυj₯’2Q‰yuΡδ…θΤθΈ¨Ό€½~–Έ₯ΓA<‡ΡΊ1f%κI›ϋ€Z|k@‘€_ NfΪ$8&ƒHό€P0A‰65a9Œ°FQΦΰΘ`M‡ΣΌ’ΑΨΎξΜΥήUšρs¦ͺ©H²+΅ θςwƒD%HC(HΙS)9=νΝm¨8»7 )-w?ώ3\{ΟMHJKΦ”‡#sέ;ƒέX_³Εme0—6[$ †'ζγ²ά™(LΘ…Λj‡€έ,ξp[πΛœς΅€ΝψΉβΐ<(Ψ~.#ٝΖ@©IŸ< ΘqΟ"ύΛ‘&zΡ”]Κ4ΨRρό j2ΎΪ0 D-i&…θΣ’sm„/EPΝρΤu?‚M¦β(ΝΠo Λi‰6(œ€aˆΠ釫( ’Σͺ–¬Ψ2—Z dμ¦Vδ§ε ٝ„θΜα΄μ Θ2E遣gl1·64cθΨ‘HΝ̈ 3–ΰΆΗ‘΄EAΊόέΘI€»&6vΦ$|ώήjψ½F2dRz .ΊώR,εAδ Β…ςΠπ`σ1l¨έO¨‡{άNlš8σ²'cDβ X$ΙpoΪ›Zαt»Τ?gΨ“Qα©C@Ζ!u“e9$š‚Pb:­FP…”υQMΗ^—?σΡ 5†ΥΒ_OΒ!FŽˆž'b ί‡ΖˆτύTŒ<@ΜeΠΟŒzκ)₯F ’»Ό-«KcΫ< x@@FΔ Ο!S‡φμΔHιŠ- κKƒT}Όͺ₯ŸIRfΊB0xd!šλQWyfΤwΎό^?&̝ͺξΠIΞx4υ΄‘ΥΧ B€•’C(LΝSq B²‡ Δ7k6sHόΌλαζ_ύ;f]uQD=Hγ=ΘTΖΖ#ΨήpUέ ŽΥ―„^|£‡"%3Mωβˆ„D›ežύτU³Lΐ<4hϊΡ>„Ώ<¨FjaΤ m'Žu³οy½!`œ₯bδά § ΤΌo4tν7BMJzζ +)Nc§S†ΐΓHk@σšRxΛZbΩβ+P@8WŠά@8 3Τζ…{TˆEb*Z9­D€?D«§#s‹ΤΑj³bΔΔσQ[YΖšSgΔ ΤVTaΔΔσ1€AεS\I8Ϊ\9π΄ψ:Ÿ”…DGΌϊ₯€ε @mΩIΤWΦ`Δ€σqο3χcΦβ‹‘š•fψ<΅ψϋ‰-¨κͺG€†T£!ˆ·ΉpqξTŒMŠD»›wŠ‘Χά²+z «^z·μAλ©fœ(9OGPVxΡό:/1K5”Μ4䬦0Qϋι φ ΈΉkNlAk KΛσX% ‰ΉΈhΰ O¬•D΅U‚κγ•ψθ…·ρΗ_?‹ΦSΝΒk–Γ2Zκ›0ω’ιpEπE5‡ ΞΫl4F& PλςZ@Λ„φD0ΔRk@1©ϊσa=Ψ%@φΝ EΌCλ’bdθ±y6υϊ‹A*O ₯|ciΠ ”BŒd"-έa_OΈΟNtXυ z1a_ ο&qλŽktώ£@'”ζ 1¦Q€Σ [f‚VϊΛ$€€EΡά9LeœlEfr:’#ΓC£Ο9y4 B}U-:[ΫΏΥΕW?ρs¦¨²Υ„€»SP\‘F(Mέmš>N«ƒζdY±¨ύ%ψΊv/Κ;j˜¦GεzΖΐ¬œρŸ1qV'Γ‚TomlΑ§όή|τ5ΪΆ―ΧknolEjf:†N©ž#Α‡ͺžSπΙΑνΧ ±Α>tEΏ¨S1c³¨2ΩZmΞH6Ξ‰ΤGΤ”? ’ω²!;LΨƒ" @Gή!ΏιgG‹λ0>E ,@©ΞΧdxIΧΑ΄nˆYϊ{ ΐŸΏ |[(β?6[Cr@†sp2$›E3|– &5ˆΊ³†Β!”ΤW ή‡¬€ 9'ΘΞΟΑΔ9S‘’ˆςCΗO[Q8’§«ζ] F&n» υ]Mθτwƒ€@–eΤz001.›“IY”αγ²/QΦ^ o8ΐE+«ηOΕY£‘βLΰθΊωκΓυxυWOaχ†ν½κί³Gεα2ΜY2_­ Ψ%ΒTF}OW²Σg¨Δθͺkω%±RζΉL ²>!¦†pΫ }§ Έͺ§3fbμΎ‘T7–RΌSCρ½‘(²γsψ5:Nk²―‘13&6β~QœLΝK;cεώpu€"χu^…F Γ֞ l©.ΨβVαΨ€,ΐώ€ (m<»Ε†œδLŽRμp:P4f8f]1αPUe'@εώ t4Χ5 hτ€ηf‚DΘA@E[ςVAwΠ‡ƒΗΠζχ ©§5]ψΊf?φ6?μΧJQ °[¬(J„« η"Γ•ΒU’»KΥ±J<ύ£ίaέ_?§½«ίΧμχϊΰλφaβE¨Λt₯βΈ§ώp*ADΠ>‡š‘ι+Y"Ι`‘aSA¦"˜ΎCu‘…¨ήΟ–χ"žOά>lV²·sš}¬o3œΞΨ ₯+‘› lԏό»ύλ“hίr2Φ·π€7ΡαΟ³νB$ςz³σ…»p₯X$`γA ν\Σ4TΩR‹ζξv€»“αvΔq―sΈœ8‚ρΈhΙB€d¦C–Γ „@Λ NΠ‹jO8,Γαt`Τ”±,J”ΤΣ†ς–j-2‰8€–žvΤu7’ΞΣ„ξ —isV4πσ³0gΰ$ŒΟ ‘ Τήϊ“uψlΕGxεO’ΉΆρ[έτŠβγα€ee¨&”jOΔρ.ΎU4z ”ŚNύ,qΏ—n½Ρ›LΒ4ˆb΅$¦uD(i7…(gΧηόJ ,6 $‹•ιΆU~ Κ!”4WjΘ3?Ϊ”ω·b“³ΞΗΉc IZν—½τ]·αΓήBMΩΙΣξ(4;N­ΐΊ7>Ζe·/QΣ€”Q¨θͺƒ_1p3Ρ†hψ? ε4ΚκŒ@―¬υΙ¦]Ό||¬γD,^ZSͺC&bqg¨™HΣqOc-ς?ε”…‰`„ΈX ”λ)ΰ0Κƒz‘RŸ":ΰ€Ή;jbΏ?bkg€IΖrΧγA7Hn"AΕΉI܎M˜t@ϊkeCΆ₯˜" ’’ΉϋjΓis(εB0σ%ψR ήj³Βj³Βb³Βb΅‚Dz@ψΕw²½»κŠΉχŽώΞςRIΈ¬h6χXΤι4Χ5βΥ_?^|-νg\@4ΊpΫN΅`ܜ)p'Ε°JVX% jzbϋϋ,` ˆ"Άα‡š4Ή ˜€Ÿψ£Bƒ5Τέ…M>Tj τtσΈΥhAΰ‘¨HΤƒ<ŽΑ™PΎ±˜UY¦TFϋζ“θό&¦F9€œ©5d9Γkς€[MΑ‚6_D0ΔΞQU Σ-G f Fσ~< †Γ(k:‰½ΥGΠαυ –Γ °Yl|•Ώ˜ΌL)z‚^4u·aOνal9Ή;"Œm«ε{ζ ±_Z8 IΞxξΌ­ ΝΨπφίράOEMιIœν££₯IιΙ>ρΌˆ#άΆ8Τv7ΒφAi‘ώΫ;»y <‡HUGχ˜ˆνBEύψ’Η!ζφλ»λ¨Ήψq„ΉxT9IGC 2ΥGz&lgE Ϊ5†Z}¨σ>ΘΎ˜ΈήuNœ©υCΞš\ ₯SP  MCΚΌB΅M„(2\„Ε˜šΝ»!)ι83`Tέ₯#%Δx‡ ρΞxΔ;݈³;α°Ϋocڏ•yρ9_(€ξ@zB~΄ϋ<ΚΑHΘ Ό! ^A˜œ?κ‘πΓ‰ΧΑnΥ°ˆ―VΗϊ·ώŽͺ’ œΛ#>)~ϊ"2σsΤΏν8-Mϋ•EJΔ–Νuλˆ?š‘S36PΨΰ  ½§ ‚!Ζσ™Ό@W€Uφ5TY°N_‡§Fœ€²U}―CΉΧQ‘ό˜αυT›ΫP·booz«"₯Ώ3vXΟΒzό €ΕfΞΕw²ΎΪ8σ’‘ [₯€.׎&£ΡΌL«Αθ{OΨ/Ωγχ’;θCƒ§Y‹$‹jΜ ’€Θh«αΌD"Ž(’³Ι$FwŽhDblˆU–ΪΖsnόΰιθΒ›Ώ ?}ευ~ŒH„ΓhρwD,t[7a΅ηˆnΆj€šφΛryN ΐ€ P“q½Z)œ@¬ύΝ1΄ £ρCΠ“GΕΐ₯^ΥWΰ=„I|‚ςX1 Jš=e-½?ΨΦ=€³°kFΕ‡RcίΏξœσ‘ƍ‡₯έ:₯H˜3˜„μ‘ώύ½}ΕOElλŒ–³°e(Β‹$‰žξτCŠ·Γ> >B+%†όάΨ(c[1ΫX€.%Ύ •δ _zδIνο`ƒI‡#DΞΫξλΔyEκsq.XlVά²ηβΚφΕΌ₯‹`‰ 9qY„ƒhτ΅šύΜΒ{Ξ`Lk„4Ζ―| "ρ˜Χτ©`61›mP†©ΗφήΞΒEŽœηŒ5KΆΤG X; ‰rγ»©(J fΈί ΠΌΆ΄·aŸΥPznΏ PJΜ4[_ΊNΔ Ο€δ°0„>ΟΦW’α!+.Βvν˜Υ`‘@VDSΧ•h`#F_ΗδώΜgθ t#Ω•€ WŠκ\² l ZN5sΠέαέιΐΘ)£ULw&£Ό«Vaκs|*˜ˆ…>»°hlΦηόΌl˜–κEθJΔLΆ[δjΜ0ΨΗzِέΠ~L%R})D"@άy£ ςo_Mή;ΩŠ΅‘Ύΰύ³±f,gq=~ ΰv³(2E°ΉqΓΣ΅²Ÿ€U8#η U·³S’vh”LD‘Yc'NE} ³σGو<“γ,ͺ7v·bTz¬eΧuΊ\pΖ9±gΣ7§EMώΆGK}Ζ̜€„TεΆΫ$ μ’ 'Ίλ…F*ΞΣψnV3 –c0F Τdž5eΑ:τ*έm(4R“χ!Ϊ. λΤƒω΅Π ₯ώ2)?σžr ŒΖΐ[Ϊλ+­‰DΣτ»ζ(n€ι ‚°'Ιaƒ=3ΊaBΊYΜο:c58CˆEΌΉF$UxŒ―ν³έzœ.?α»ό’o―쬃³ΥΟ0pθ ”ξ?Іͺϊsξ<ν]ˆKpcΔδ1 ׁ(έ‚§Ό-θ zΝΛ?:Μ.Ά‹ΠKφ@Ό;“j{ώj=† κψTO;ώ#Ρ""&mΓΠγQΒΤBωˆf˜FžΠΉ§M«zeσ.p֐eΛY^•†gφ„`Ky °ΔΩωή&oW“B\ΫgCxΆQ ΰ™~„p,8V2›έίυš<ATνώhϊΡθF^Rάv—ϊΌΑ£ ±ε㍇Βηά ”<Ž©‹f!1-Y)υ ¬’•ž:˜dΏ<[―O;ΏΩnx¦iή`:όΣ8aΘ€ (*XΚΐβ©@ΤΜψuŸΗ W ”Ÿ/@™pˆ δΖύ ]¨yq§B3?ώΰι³ΉV,η`=n…Β H.Ÿ`²7ˆΈ!©J @Xq‰Ο½M; ωό{œς8€κXA-™κ€HέH3=φ¨"ƒ“sΤΧ&¦&# £dWρ9wr8ŒΖκS˜yΥEκίRIhπΆ 3Π-€υΦBb­>[0sΪΖ/R6ͺβjl.’"‡‘'ΰPq‰”oΐ!ϊͺ†‰£’ϊΆ`J ^πœΤ˜W?χ  έ±ΎΖRίΠσ]w=P¦•,1Cm>HqVΨ3xΐ0FΙR†9}[ΒχΪλCzΆ: PΚ‰“0ιƒ :9sΒȘ@CO ŠRςgs©ΟΝ-ΜΗ‘mϋΠΡ~Νΐ©“uΘœ‹όCΤΏεΖeΰ`[)oθ”a =άP»νahΟIDATϋT˜茁}5ΣΟVΧ…RγDη}Œ©€9†!N¨IAΝ«΅1³‚ βx«α££θάYΫ[%νGvνub9Gλρ€±F˜=Α_έ{N¬‰ΝπxO ƒμ./Η Xœ€Ωνy₯nζyϊj "ξ}£|Κψ°£Ο4y[1:£Hubv§v§ϋ7οϊ‡‚₯ϋβ’ο] «έE8Δ’ΑΫbΨύM4E JΌˆ!$NΨ‘V‚€‚Δ˜φc«‰9ΰΞ?nDη‰)† ΠϋεJ‚7€¦XΎyϊ#Ό?Κs=GΡτΑaΠ@ΜΠcΏ‹8‚@°ΐRρfO ΤvΑ9$’Γ&Π `σ~G@_9ˆΞΨγa-ΰ#θ~-γΰΐŽ― ΠΩπΒes!ӝ ³ βP)šΞ’qΏΧ*Λ8ϊxυoΞdj+‹ŒC@Ρg1€GŒ  (Γ2a F Υ½­σSΑB‚τ\ύ€"α¨1c5ƒBŸΫ‹?mΰ%Τ"οςψQϋκn[b6ς5DB–s±F,ηp=ΆEΠΜ₯&‰&h ¬ ’αED‰Δ"Υ‰‰~^¬ϊΎNΠΩ0½˜‹> Ξ(`I@ju™yBΠΤΣ†‘iƒΰ°* Ov‡ξ$7φlΪqξAJΡRΧ„qs'#!’{(‚φ@ZύΜ2%š–Θǚ &—'D ΐΒ΄" EπM$ΏM§Η¨PnFΏ‰}|OŒ`5s>ڃĐ P†‡@ άiφ³T?ΏފΆή|λ-Ά«%b9Η›Q(sg˜=!άξƒδ΄Α–―pτaΒ $‘€\`Ό†’X Qώ駁πψ%\ -rΣˆΒAȐ1$i z=ΩCrQSz5eUη< θωνi\Wy†Ÿso/³υ,²F»eΙ–Wΐ2vγ°Α—I  €HŠ يͺŠ€ώPό ©P€R›!l€ PΑ66YΆdI–-/’΅ΞLόF³υ,έ=έ}ςγnηœ{nwΠ6ύͺZ=3½κή{Ύσ-οχΎ₯yΦ\ΎŽm7]‡G8TκK~G%¬oNͺυΚ…Ω6 )½Q#f(­«`θ,ΓκŒ‚š£ ½(βEipΖΛ šF’ϋ1GcŠž›ο)€΄Β…Υϋρο?ΛΜ#MΟΏΐχόΧsy}8œ{ϋ{<:ρD›ωΥ1ΚG§μ^ή„~ΪͺΉ2Κ<€MzIƁΦΠ]R©K+@„†χi'Qœ?©]κΏχαχk^ην}V“ZΟ;Y]σ^κ’”Κασ1ψ2Bε i]όΡxχΈˆ‹όωοe`ψ΅Ρi¨νJΝ9Θ0›Œτ”BΖηl-ΰlaηˆ) K©OφiQ‚©ˆ1ξ0½γχ=Χκ4νσΧΖ95χ<\5`·ίθlVΜoθΕ-δZJ‘˜Θ>“ŠK$qR…>[(yΎFφ¬ͺλ¨ΐ#ŒΦ’ς’Z£FC6Έr`SΨJμ*t“ΝηΨΘηό€;Γ­χΌ‘l.λ]Υ9Ξ`Ήΰ25ΚM亍UΟγ]© -EΆ$RΩ„†Lj1D’H(FΨ. ‰q‚ψT Κ—$­’‘’…#SΏ΄·ΥŒ€ŸŸσΡαόΨγΐΗ›]rS?yž₯™ŠΆk}Y›ͺ΄κ…-E%u;Œ€˜―Uv„ρj΅zήEτέœ<Μπμ˜φέ^χν\}Σuηό`/U–tΌ»>γ¦ibHebPT΄1ίH”iJ£u&υYΓπ3Μί•¨DjS|PηQ§£Ε'”;|ρϊΔIG”kAqζN/•nAIHν»ŸY›«2ς»[ {H-<~>βωrŸ~Ψμ υR•ι^π½§ΠχŠFp‘4°cΙΝΈOΖΒ;%ΈΧ<ΌZω;γΔ£; υΊύΩ‘Ϊk»ϋzΈγ½w“λȟΣ=°vΒ—03Φ”jk#ΎZ@O„j3&Ω$Ζψα G«*‘1Ψ ~¦Θ1Ϋ~d#Œ8‚EšΔΛocο2Ώ{°Γk…T‡’Y.·†” nW+°ώψΜωZ„ηΣ€7δπtΣVΦ‰f9ŽL Τ”ΏΗ.½œίΈ6€΅j+›ˆΗ€ @‹2‚K_ŠΛ₯’ιJ‰#ϋ΅‹vϋ-/ηE―ή~Nς–Ά‘ΙDά/εZΥ[.t|Š?³HeG9Ό² Ÿ!₯Ζ`₯Ζ’RΖΖs‘ΒΰΗ“[ŽTή[?ώŠrJν₯μΖκωΤΩ€Ζ,Qˆώc “»_κd²‘ΤPόΗκK5Š_ΨΓόΣ-'BŸφΧ—ͺx3ήΔS’Ν?5ΖΜ/Ž‚lΔCψ†9i°SHc2ΒΜ€2K;ΊΤY*’“­\`ήΣ 4 Œΰ‘έΕL•gΓο˜οκδΝοyϋ9+ζ;σlύoΙΊ %υF±…SJ1S* K ΰ₯T«r+ΗS*nΪ1oDΗ2|f,š΅ύ?„ταZ„­£šlP©WΩΦΏΩW‚|w'½}μ~ΰΡ³r0oxՍόέΧ>Eοͺ>"Ρ“%~xμaIh½Δ"΅MH$,\ƒ=QΪΪv’uΟa™Š·=gΦΠγ™Pε·}1i‰&4ΒZ[©Hw'³»†ώl[…ό{}@κβφ=ZπА”Νΰφv[ένTΔ`Dυ‘Dδ†|˜ΤΈΠψb©κku €ŠΤ2cN@ ˜,Ο²°šŽΎπ΅k7―ηΠΎη˜;cpυ†5άύΑwρώˆ\GN»xoh'•im‘ Ω>@S+εFΓͺ΄A°B6Σι»LΝ?¬;:–ί-rΰ²Iψ/uόΎŽh&L"™~δ8Ε―ξƒzΛ#ωe<¨/©HΆŸψNΰΖ¦Nΰπ$N!η± ©»Ίͺ1¨Œξͺχ‘0^E!€ ’c HΤVβ*Bθ|†FζΖΩΎζš0 Θδ2ϊ μωωγRοt¬§Ώΐmο| οω؟πς7½&]τmηψSμŸ<…[Θ‘[Σ­μΊRΛΓγd£z Žc‰ €91h–b°…_Aη~R[bkΖπsV­dθΠq†_8}Ψ+οΌ™χύΓ‡ΈωžΫιΠΞF‡w±wβ9=4 •RO›£i6!†v”ŸU°d]« ΄Ζ1βPipώΩ|›4JDiN¬qρΉύ£ }φρvvώxΤx₯ m±]ˆΏ ψΏx2H}MkG§ςλ Ϊ0PlΘ'(ςIb€Q- Ϊ•΄aDX"ΦΉΖ€ϊ!ΐdy–-ύ|ϊ0AΖuΉβϊ­μΈο!ͺ•κ²ডόρ'ώ’»?ψ..Ϋ0ˆγ:¨šsΓσγόπΘܘWF‘₯Q  Νr€…Χ/Ρ9΄xά*$*B}λ'ΰΤ a‘"·x ]ΉXj>Λμhθ€ΊdκαcŒ|-ϊχγ~q{όB\hͺ˜Ύά 6ŽM#+5:· D‹ T"tCo8Ώ€±³«tζψ°JM(CŸ‘θj JP—u*υ Ϋ6#„'GήέίΛ–λ―δΐΞ')Ο·}νΰ7.ώμŸ†+Ώ2L)]8Ε―FχρΛβ^ΚυŠυ=lΌ g+αkuΆEm[wRO6¬©BXΕΧ?A&° Ϋxνν½8Ά?ώ½ΜvŸˆ>KJd­ΑψwpςϋΟ΄s nF.ΤEv!;€YΰΗΐkΝœ@΅X’zrŽλΑ8:Ϋg@)#‰9 bcα`ͺΩT”B­p0Sžcma5…πύΦn^Ου―| σ³sœ·rτ­ΰ5wέΚ»?ϊnΉηdόαžΠcVΨ1ΊGΗφ3Ίp*Ύ™ KΆ/ΥHIͺ^Μ“—qήkv ͝C)B%ιΨ…Ν™ύθwaσ.±Qe,‚Z Π(JlκB~νI¦<Ξ΅ϋ8πΞΓ€ΟrL°2μJ<Μτ ­žΨqΥ*.»σjάξœ–γ«ω{(ψ,JΑNκ  δ|‚Q‹Α EΥίc‚§za±Ώ³—χΎδmδœlψΈͺ eFŽρΜΞύŒb~fŽώΑUlΉα*Ϊ~-k―Ψ@.Ÿ3ΆmΙήSΟ³{όσ΅rμδΚΔo,ΌΔhΐ’ΨH=¬55iOιePj΄εΫΒAb—ύŽεθ"‚†ΫpH1θ3ρ±`[ΥΏQ©1ςΕ'(νn‹κύπvΞ"χ₯ζ𠂏4- ϊ–ΫX`Υ›ΆEcŽEPΔέqΩ‡υήΏRHt”ΚΎc„kύ…\…jWΐ±ϊΔϊΒjήqΝιΘζP(ŒΓΗ½λ²αΏ·£ kΘεiΩMqώd|ιͺdŸI§ΪώΡͺλj1SڟžXΙ³‡χ-…΄w ‰aXZό’ΤΔ&-·V“hΒ>$₯€<4ΛΘvS91ΫΞuzπζΔJXTξ r³ΐ77š=±^ͺ²xdŠL!Ofu—Ζ(¬WμEΜjΈA‘T»₯Κ €Q¨ 8 €Άτ…#όύΰΤqzs=τδ»p…9Η“MΎZtGx,ĻƟζώ‘Η(U°nϋMε}[μ †X°Z U:²ΙϊW€΅„΄LUŠ₯ηχδξjoj KWAΗ¨"%Σq²7›1b~}ρΕ2{­ΰ',Ϋ΅°; eWΆK{ˆ&Υy{b`-ͺ }FΗVΖo&*ML€HΙ$‘RΛΏtjžΙ‡Ž1ωγƒνΆG%ε–‹i±Έ‘ψOd½Θ΅zrex–ς‘)pωu αεΘΒ 0υ1j zψ.„P†ΐP οά¬«΄- 4͊ρϊ7H’οHΪΓ­Ž Ή² ­γΑ±…¬ΐ|υΠ ¬ΏX&IΩ{δΓνŽ@Ÿ#˜zψ8cυsOΫ½¦ζOψaEecΨνΐΝ­²γ`Ξoξγ²·^Cn°+Κν Βθ ο걂¨ŠΕkΖλ \Η―Iˆψ‰1vv‘‹TΩΓ{“»ΟŒ€Ρ>΄NΧ$ƒ‰“;ƒΊsˆ=p~θ­ 2JšJq¨nδ„ΖtlΆ%+'η»χIζŸ9ΩΞop4Žά1.’‹Ω€gm‘qθΏm+…—mΐνΘ„Ήyb8U¨±aœο§ΒΌΏŽœœ‹“u£έ_ 3@ˆν¨&ΒPžΖI•-’+=` Δ+β|zޞP­”…€Žδ—fΡ»’°΄Gυ…*ScβžEΦ–₯Έύuΰƒ\€sόi ОUφ«Ά―£E«πΩ†¦X<<‰ΘgΘτε'Z¬κΪ†dΉnπ •*W£H!p³ΚΌ€ΕG ‘k!Ζw}‘«γͺu°ϊ•:€•ΐΓ¬κ| 2ζ$²\ΪΚ ¨€ R-˜ai²ς cžWhΔϊb•ξ"Ε―μυ@=Ά±Π3ΐ_υ―!R°²ν1Ώ8Έ•6»υΉ* ΟMP91ƒΘΉ iŠγΏ–Υ)ό™©…μFΕήˆ¬ƒ>) “Π΄,‘ πˆ„°]΄x+Ωτ ABEPΆς ΅tβυ?ΣsΨΥ„fw 3ώνLή˜ϊte9ΧΚƒΐˆ0&΅ .-λΗC~Ή=sωM½¬Ίc—χωiA°Ι*‘>Β‘ZoίۜoG:exΠpr.N΍ZpB―ϋ2θRϊu=Ch}†Υœ^(]€° μξMΡ}FJ.βS‚H¬=Λo¬Hά]š”MΎD²xh’“ί{†ΕΓSΘ₯e…ϋΣώŽMgRpρΪ5~mΰΆεΎ°λ†5 άvΩΑξ0gjaΐ B{ΒQ%=0¨ΚέΌ―έ§Vρ£έΦcϊ Κπ΅4γŒD§ εΝΕ­Aoqή¬8”0zͺI°!-5=ηŒ ς©…=sT²Φ RœeβG)ν:-ςύ\ωKm!\ͺ °?>t-λX8‚ξΧRΈqWταtfΓΚ VΠ*ώd‘ ΐνΚ!ŒEgr– žhϊ“XΣ‰PIWΘ8e¬aVϋšTmD ­šˆAλΨqΤ΄Hž!F ΎXeρΠ$3;†˜έ5²œ?ψ„ΰ#ΐΏ]ͺ ΰRwkξYVZˆ¬Cώς>Ί_Ό†Βφ΅Έ]yVlή*αH0 δ9ΡιzύcΡDλ3δ/D„Cš4`Z« >―Φ:…EτC™O2S,)‚5‡"аΔΨ„°>Wefη0₯=EL΅CΙm χΏ‡'Π1v)_ό©ˆμ5ΐ_ο\φ+Ϋ₯η₯λι{νf²ύΡLA€$4JόVa¦'Υ€°EχΨυΞΤ)Oβ΄‚Ω¦dΔOΙσ…•OI €mΩK–¦™zΰ(³ž 6[i§—o³o‚GΥ}Ι[κtΛ/Ε,Ίφτލ η¦΅τΎzϊ9ΗqŽ/;¦ςζ\ϋsšw‰ΣΐαN―CΥ2όrΟrTCΠώ*T5 Xγ/!*Α.πΣP‡t,)EΓγβ+•˜~ΰfw—ζ«φ’oPI/υΤ΄²?ώXΝiΆKσ tίΈŽΞ­ύδ6p;²˜ΫCΈ£oh–χβ{΅h'/·ύ¬ΦηE¬ζf{¨ I²ςg3<4ΥβO©/,Qžaρΰ$₯=EΚGN»(_&π`ΌŸI/ιΤ,ΧzΏΌθtίΔιΚ’]ΧCΧ•t]ΏšΞ-8§3£…’αϊ…Iμi>GΛσΆwŠe‹δΐ\ΑRq8ZΝΠJ.ΪD;P™Ρ_<2ΕάΎQMR.QŸϋ΅08OγΡt}O2΅ΤœΆmή‚Χ5ΈιτΆ@dΩΛ:ι~ΙZ^·™μ†^ύ'i)†!~“ά?šηωζΪ΅!νJί:) xυ{T†g™Ω9Μάή"K§‘Kυe‘—Zl/^UΗΐPzι¦ΰL«.ΰfΏˆtέ™xΣμšn ΫΧQxΩ:²ƒ=Έ]DΖM€+»?Δ¦[νθm|ڊ€!ή|d$†„ZƒϊbκΨ₯'ŠΜνciμŒΡθ=‹WΔύ%^{O¦—lκΞ¦έό#°Νw ΏΆeϊςtl σΚrλzΘ­ξ"ΣΧ‰[Θ%;%Lꀝ0TwC PΒ‘ŒΆy©LΪιΑ[ K >[aiz‘₯‰Eͺcs,ž’|dŠΪΜ«Α-‡€?J/ΙΤœ»xž|Ω gμΔdܞ™2ύδΧχ[ΫM~}μ†n>…E$Π`‹iθΈVEHhΨΔΨπO½\£Z,Q)Q›§::Gmz‘ΪT™Zι΄[vIvψπ5<%ιΤRpήm π*Ό‚αoΡΙrΟ”ppΒuΘτw[ΫCnM7Ήυ=δ7Θ­ιΖΙΉΰ:Ρ2 ύ·hQ{³κ€€(„―ΤXšXπz±Du|j±DmΆ‚¬7 .½ϋ3€WΰφvGΣK.u’uΰ! fΊιœžΠŒη2}yάB·+‹ΫΧA¦'‡ΣΕνΚβδ3ˆœλ΅ …@ΦκΘjƒFΉF}q‰Ζό΅Ή*υ™2υω*υΉ*΅™ ΅ιςrηιΟ„ αΑ΅Ώ‡ΰ+§—Xj+Ι>'5Χ›–ιΝz«ϋΗhΨ?fΧ¦—O\LvUΩΛρ·€‡όpώ0žΆήύxμΞ©₯ΰ’΅NΌξΑ΅x’&·γ‘˜^Jφ˜ΏΨŸΐƒιΣK#u—šeόΊAήΒ;ρθΛΏΘώŸΟΰΡj _φo΅τH@jφss‡άl²xέΧΏΏPΟīΧύϋ%` Ϋc~HŸsRΪ―iλύ”aοΦ=ώο½~QΐλBˆ³°Θ§ρrΛxψϊq`(ϊ ~άι‹ι©J@jηΖ\`θ3@ΑΊόŸϋόϋ‚·ΌŸ~ΰ‡α MΠ@ ‘ >|η…†¦λd_άZσ΅«{δ“Ο ΊLGA„ΰδ(K7>‡/ΰ§qw~ΣCfI=‘. /ΊΟ"}ζh€‡N?‘N>»+Gθb‰‘λG34P ‘Rf£‰Δ«ΈΧ7•ήάL<ϋ†£ΥnάI™§MΧιtϊ((.$άΩ‹Χπ`Ζ0=6.B2f’¬‡ Θ3sΣ*ŒtO|„5(₯PΙ―σrΛ©ύι»\:uα[­ϋ«sΝ\ήv”tΓGZ»βΒ—g™pο42¬4šg|Ε$Βφ B|₯Yθ~s§YšΟπθ> ₯RΘ(™P έLc΅?y—£uΫΔrpΛ^Ζεα1=,^σ(“ΧΟ₯υƒΣ΄5™±v!έ±0Π=zΐςY:0Γ*<λ›˜‹fκθΙiY&3ς&β=9ΘΦΧ7Ρ|ΆιΆΰš3Τ?ˆ6 )X1•Ψμ4:’=ψΏr©Z:)Špl%6έwΌ•ψ=  «dΒΟΣ /󳦳ο—ΫhnΈxWΉZΊ‰΄hΧf¬šG€4†mΫΘ%βΆMP7JΙDκR „H8:Π+mdbqzξ揝…¦ι\»ά’Hβ)ΰΰο>dͺ―€p|ˆA;aΧ ΙΧμΔz"Άt#6–Πρ΅ΊμωΕflΏ ϊυο§c‚φ 2–ΧΒπγ ψ±Ό‰ιΒρbΗBXI“UΓz8cΊg .^θΆ»‡Ξ;}ΡςŒt:.^'xρ*ΑΟ™τΘl*ζΝ’αΘ)tΣΐ΅Š&•0υž JfL"0&=`‘$ΨύQϊvΠ}­³(@ΔξK€g)‘RαΪ.JI†š»qν @·1σ½§C§_ψθ³xΧPy$:DzvΨRCρe,ώξ ŽœΒ΅F•²μ₯Υ>4…λV˜^'B76¨R&j1%Κ’!ή†c;Θdm †‰(ΕΐΉnμΠfH)%p(Ϊ³‘gj/Ω•³),-’ύ«V>Σ>|m5‹ž\†aяWc ΊΒ½tFz{mQ K–ιJ"Žƒ›¬Ž†Χ€tQ”+ι?sΰ€Ϊ{NA7[jŠz½V¬^…nCΈ»q3'‘6 Σ¬"t¦…w^ό-ΑΟ©Z΅§₯d*ϊa@W*€λβΊ.25%γWίFgmΣQΰ >œŽ£€αθγΪψLFn7»˜βΚhŽ^§#ΪMfPγ/―ΌEt0BO¨›Ιc'β”zˆ;vJfwΨHnάŠΣϊn=NOτ͊wž8ΦQs^θ#t½;hW S+³FΫƒtEz‰γ …bάΨb‚uM τυlΈΜ}-&€ϊ@)©rKW"7IΜu]nμm$|€΅x­£ζΌΠ’Εˆ˜ΎεIΌ Ά^μDΪIΩlΗqω²«‰Η6¬I±FΠΓ.ŽγΰŽœΆ›xΊ.Žλ¦Φ{κ‚tξoj^Y§՜£bΛ᎚σ'£ΝέOΩ>ΛΘσ£ι₯ \\ς'’'ΣšFυ―^’%7Μ€IE―RΡ&TpD½G‚΄½S?ˆ«žNήΆ1©Ψ²Z4|οοK€χ2–ŒΙ~p¬,0 ž@x^ΝΠ@ 2+QιΉd€{έ ΠΌ!Μ[ Α;(ePd£)R½£Δ[|΅Ρ#}ξ‹E€οwώ9t!π ` ΩΐΤl˜E°±o‰MΣB 4aΨθΨ`»( s[9@)J)P ₯$Ik’ηΙCŒξs£{ψ ΰ΅?@_Ύi™mτ"“_@ι’Z4ξ5š@Σ-`P Χ8+1Vn,Ι±@17Ζ€€P €’δYFφαΜƒ-€ϋcφ3¦ΏΎσ P °ΈΞ>PΊ¦žIŸœ‡π’š‘!t MΧΡ4ΒR)! ­η— ΘK‘’y”²”ΝήV&`R*2£)Ί~ΉΑߝFε€}΅‡/Γοf ϊHΔΏ>  ‡ΌLώ«%T^ڈξ1Lpt M7Πt $MCh‘iam αR=s­ σΈ6 &¨ŽΔif’κ:―¦ζθ>ƒβΕ΅x«CĎτ!S9€9ΐ `›ΕQο @MΐύΐEώΊ Τ}aαεMΧΠu=Œ‘iMhh8ζoš–Δ>.‘»ΑΒζ ςΗ±Ο±ώ,υ L)‘xQ-±£}d‡“υ–_Ά θ―UlΊΞ „gWSsΝ\|εASb<Ί΅6L  Ν0 GR,žy’ΞkΧψŽ΄²9ΗΪ¨τ—6όx5”’L.M"›"šI0”Aʜ₯z)ΩXŠΆνddŸγKξ>œ|―*ΆλPΌpΥfα) X ˜\#<¦τh†^ΘC†ζΌiGmμW>†‚c¬½e­0§dsKgPμ ›Η]Φ-•K3”‘'1HG΄›ΦH'9%QR!•ι‘wlzƒΑWΪμ[νή €ΆŸΟ­‘ζ#³ρ”ψMφX x44Γ@·9ΘΙ”"„[mά~ Ψ"dψΌΟ₯œ3]―0ΈͺlΟύψ1φo{d,BaxςΗrΠ?_ΤO ζcsLp40LυΡ,k₯λ&ρš θΞqM&9Όc­5₯3Y^9‡ΕS˜Q4Mτ§‡ 8HA=€q"Ξ¦ΏϋF‡‰Gc$’qbΓQz;Ί9Ίη Ϋ{ύΏ}Ϊ†)\0½‘Ι‘j<ΊAWΜτ„G'8­ŒtΟ(©³Q›SƒΐoώP€Φ[©aϊ8 ρ–-ς՝λΊ’¦»¬˜MΨ4 ]ΛƒbƒUšΔβ M΄<΅“ΞCmLΏ ‘Ϊ’*:“}$eΪ![!i²LυTρϊ― s‘L›θ•Tδ²9»ϋyυρ—ˆ0gι<¦–NΒ§{ιˆv£0cίδbβ­ƒd‡’Ά£»Χr*ί•Š…,2›0ι†Ε„§•™ΐXκ€ιΊΗ&hέ\–Τζqέζ!MwΜ»mΆ/,ŸΓφ»~Ε‹=ΐͺ kω›ύgύ<Σ½sŒ~ΝC³6_Lΰ―."ΘΖN pl[ »Ÿ{•‘‘r3²ΰ’₯άπ›)«­d{Χ~φτFI‰T’Θktό|―mέΊ¬ :φn$θΰ€(½¨ž’…΅.ŸΖ\λ–ŠΩD¬YΗ5ۊιB7\f_8 •xΓ4jω·Ώώ?(+1ά7Duέ$gΝ$–KɎš\₯™>D##œ1i‹wΡλγLn€ήβ8ήε\vέU”aΊ[;I'St·uΡΫΡΝ¬eσ˜ZYKobˆ‘ŒyMί€b2C β­ƒ (*€§ή)@ˁoEFyšΝq|a«“eΎ•*Fwˆ:―~šεΏ˜71PNτwμzj›sΣT"E"gιΪT—q2Φ… „`j°ŠuΥKYU1E₯M,*k’©€žoˆd.M4ηlv€ΰά*V-ΈŽ­Δ†£œ=u₯`ήΚΕx ν£=δd¦•}³Ϋ–’Yΐ‹@η;θΫΐj€ͺkζΰ0}Ν Ž°ΒˆόΎ)MΒQ1έ&hΓνM ΌΊAsq=OχAzΪ σ\ύ]½TNfΦ’y($=ι!ΒΊŸΥEσhyroόf'wμ§ύH+™XŠ ¦62»|:^ΓK$e8cΈ,Γς9K9΅η‰Ρ8'φ‘yΩ\šg6Ρ9ΪΓp:jΊαΥ=ΠƒΚJ•ΰ{πχt‰„†CΝULX\λHMΜΆδ8Η…KΝtέ²lyπtMΟ;‹B£ΤWLu_ύπ!2©tΑΝeNι`ώE‹©ͺ¨’+ΩG‰7ΜΐοNρ/·|‡ƒ;φspW ϋ^ήΝΑ-Ωs€β²ζΜM™―„ξD?ρ\Šd™ 1PΛ‰½GΉ]­¬ϋ䇐(ΪG»‘J‚€ΐ”FφŸ%3˜lgI‡΄―ͺP²h’©V¦ μΔښPε„œNΌdZΝ‰©ΠpΆ5+φ24†ΠDžΏο ’£ρqΕχδ›ΗΨΎυeΒψΈ ¨ŽΈL‘yΟ}—gϋxγωόπoΎΛσχoer¨Š•sΡ„F,— tωTꚧΠϊζ1NΆeFΙ|Ί§ΐY­ώΨ,ΠVα‹nγ5φ³­ΔWe°©’πœ4―α2ΟΰΣMΠ–ϊ +rΧ km›~Λδ MPβ-bjo˜Ητ£‘θy-Dλ[ΗY~εΕLXKG²YααβΛΧ²ςκu¬ώΤεLŸέHί鳌F’$c νj‘i³šf1˜f(%₯税‡9Υr ™“ψΓA­YJλH'ΡLώεψ*C¦ %μ<ϊ3@οx4hΝ(Gχ{μx99++8T…ι 'PtεzD>rtΛ₯₯MΌπ‹§Ξα{ρz½Δ†£<ώγ‡ΠRΠcPΡ5=MO³€³>Ί²šOoωŸ\xΕE!HΖ“άϋFΚ‹Λ›Ar*Gω‚)‹ΓΪΥ@™B>ͺ΅žΏκͺ&wXuεx*Vb™uΌ5a<•!3ήq'΅„‘ςΈά(;ΟVζ―šΘ›φΖπ†φœaίoΝ_}}=7άpwί}7[·nεπαΓόΰ?  πβΓΟpκΰ ¦…&Qν/#I†Q™ ŒΘΗ³]\τέO0iϊ3ΟΪ7Δ‹?KΉΏ„o˜œ’xλŠπύtμ!ίyε65/šˆQⳇs•…G@ΥAγŸ2£Θο¨’r0R…Ήdaξk6P*S—H!Aζυ<·ω z;ΊΝtδΧΎΖψCΎς•―°~ύzšššΈώϊλYΏ~½σPφυ{Θ€3,,ž‰OσZ`›Ή!% +5ΐΊΟΨ,od²ψeΎb”=μΓ0Μβ@&1Ο1©hΊΞ„εSμΫ.°dmL «NފBΣς©P%œTgΤ(Wΰι¨UώΈΧΡ”Ζ₯KΨΆεv=χ{V\I8Ζλυ:ͺY\\Μg>σ&NœhφQ^ϊΏΟRξ-fZpbaŒ&)™¦vΙ +°WtŸξB πλ^˜kΧ’ αΐφΛΆΗ4ae}Z‘M56@^+&Α( `LXαtžklΪ)Θ[`Ψκ§lqšk«qζυόκG:‡©Ξ§9Tώκ6l`έΊuΞώ/ξάΘPο‹'4Τύ©―ξ%δσŸ“/Ι!Ρ…Ž/jJ@Θβ’Ρlά”~wΑ@“KΠΓ «― ίςž1ŠΝBJε9ΜΉ±pν˜`(ΝIΛδο'B.4–WΜΑs6Γζ;72؝Oβ͟?Η,²J)9|ψ0£££Ξο·άr uuζDΩόνx„Ξ’’™ iŽWϋK:˜'όͺ2³@–‰4Ό€ΫFHΖΝΪΩΤζi€` 9μ#\ε&‘ Β³ͺŠ5W3Α, ŸιaζΣΌNrJ(›£]Ι.IaΛΒΞΠ –VΜbb²˜·}Ÿ“-G ΝεόωŽΕΚεr<φΨcά{ο½ΞοΛ–-cΓ† θΊι‰lίϊ[ή|u/3‚΅T{KAΒz€YE <»ιqΣg1tζ]΄˜x6ΙP:ʌ’©|eŸγN,Έx)ΡLŒX&αlσ¨mΣΚμG˜κΘ4] ‡}.q΅RyrŒS›R./ΡΝOΒz€UUσ˜’+η{7έΑϋΞ1ηcΪΎ};›7ofhh(_‰Όύv¦NjΖiρ$ή}R*–•4[<γcΗ¦_s|―Ym…ΈμΣW±wθ(•Ύ LŒ„8ΌϋM”4ίβκ«ΧqlΈ΄Κ’œ·οv“Ί{―Ω―πθAΟ˜κ¦Ήγ†¨° *Ν|ŒυϋδP—N^JΡ Ξί_χUο~kόβύ¬YŽuQJρΦ[oqθΠ!6mΪδœSUUΕ­·ήκς°ςτ}Qζ)fvQ= ™’³΅¬•ήψ«oίB"γT¬‹5U yω‘ηh?bfW\u αΚΪF»Θ©άΉyΛ2z+Cξ¨’H³6ͺ4―Žζ7\βα’U¨BΒ>GšΙΡ αgν”%\:y;ŽρΝλΎJΗ±ΆράεεTTT8ϋ±XŒ.ι4<ς­­­Ξo_όβYΊt©#E/my–ήΞnšBux„Aσ—.ζΪ[oδoοϋ63?΄mύϋAŽόz//ός)Ηg»φάΐ‘α6ϊ’‘·Mƒi^‘k&ΫT Νk8S”#RΚ•0XXέΜ΅Md"e<όŸqΟ-w2Τ{ώ\xss3αpΨΩ?xπ ³½kΧ.6oήμάCΧuξΉηžΌ΅εΉΝOύΜ)j έ3€Έn ms2ΌΠ»›‘LŒ΄Κpζd;ι€™ίΎρŽ/#Κ}Žœ"§rcF#ά„&L#e.S λŒbΛI@ZΑΰ•λOVƒ™\RΓΊβΟ΄<Ώ‹Ώ³KuήβZSS@‘H„kΉ†E‹ΡάάΜ’%KςeiΛ_ϊμg?Λύχίo‚ψτΛ,ύΐJ¦.œΑ$_9g}H%‘ΧD3q¦―h`ή‹™>ο–}d »"H 3œΣ@‘ΛƍΉύφΫyόρΗ Τσ‘»ο#6<ΚμβŠΣX₯Ζ4~*«=&O…em§η(+ɍ:΅Ϋ$Ίd&7I&2Φ₯ΛJXPyOoάΒΎί½Ξ΄9Μž5ƒΦH'eK'SΧ4ΣGZί1@”•™ξ|6›eηΝo{ώ¦M› œΗ±KOϋYΆώτ»υF*Ό% ¦†)²AΙ»%…‹²9ΫΥ½6¦AtΘ– pB₯sδFS(™Χγ²π"ν}Ψέ‚RЧώοδ’YLlζθHWωΊw%AlΪ΄‰o|γά|σΝlΩ²εξ|ραgPJαΡ „gŒmΎrϋrΚN)TN‘μpϊ«’@άp΄X“M£”BHPΊ’<4ΦΗιΪκΩ{€-αΠ@+}2Ιͺυ—°ύι—―<ϊθ£<πΐd2™‚㦛‘*QZ]NΈ€ίkΎΥL–ΡΘ‘Ύ!’ƒΓy °ΦS›@Α@2‚TΉξ1‰Xž+QΞΆuijM’Ν‰»€ π;ΰ–άHŠ\4…V@)ΠrŠHPΑ@ψΗ{iZ<‡K–π@ΛS,θΕμέΆ›ΔyͺFBΰπ‡4/›ΛάΥ‹¨[ΠHhZq=Νh.A"—$-sH•C( Cθψ5ι!Υ>B߁NŽξ| ―ΟΛ§ώφ/Ω=xήδ3`₯RεΛΦc%F©ΒΚΠΥΊv7*b™H"”‹¦0&J‘Θ€1΄Bηj »ηΪΚΥνZNjζp_;ψΤU<ρΣ·WMΧ(―©δ‚%³YyυZ¦―K[²›ŽD―Λι°$JΉ$ί- Φΐ'(ΤJAΝEΛλžέM,›4Α.©@‘¬!·…Λ―±@Νcι‘$ιn''υ pΤ μ“ρΜκΜ`ίδPΠdNuΝ9ƒ}ςgrαΧ°’n>{OP»€ŽΙ/ΧΡyότΈΰTMaιV±ζϊ’žδαπhGΊ·ηsΧRΊ- Ή¬”"šŽ3’b.Υ‘uώΌBυ’ΰ|%Ρ=]Žή[‹cN.Y+<Ύκ"4ΏAFf™^9…Σ-'H%ς ―€€―«›εZCY°„έ‘CΜ*mΰθό €?`υΥ—²α–O1}ΓB¨v:’½fΚΎN«―₯ΚΊΊXέ€”r€₯Zγ‚RΦ³ΉŽ£οΪΌμH k:Γ=ΐθΨΒaψd.–φš• Bd=‚Z9§šσΎ3=Lj˜ΒœΩ³ι@–{Θ΅Φ`ΡΪ Ήζ–ΟΠό± 9‘;9ΐΜβ:VVΜer ŠώT„x6™‡EΉς 9’ΰ΄=X»1 ΅m­q8žτΨ•hΠχΔwΛπcγUV‡€UH5]+ςβ©£ιΡl‚™M3Iv0Τ7X@Ίέ§»Xyε%Τ–V³§0Σ+¦"Y>ώεΟ0οšUτW₯ιN25\ΓςͺΉtΏΦΚι·NΠΠPα18“θ#η’\"―¬Ψ0/ŒΚ€T@Ω’%m PδΉ’%=°M|ΈΓš s@i+ύΊ>MiΑϊ2΄€‘ 2£,ZΉoRΠέξθ*ΡΘRJ―\J2—‘Σaωe«IΦ΄gϊ©U°¨²ί δg·3Oώλήx~;‹/]Iέδ:ϊ“†3Ρs%Χ`)$Y3ή,T―p€<TiζVEσχτ`œΝϋμiW»€»-,Ζνξ8|D₯sUzΨ‡·&ŒΠΜζ₯ΎΤ΅‹f°rΝjJBΕȜΔλχ Ή`ΡzSƒœιδlvbˆε΅ ˜^2‰g6>ΚΖΫΎΗιΓ­d²™,#–]Ά’€ΧGgΌ—L.;kΕpήΊr τpdαΎ«žΦ΅₯Rτly‹DλQάιžΫqΎδΘ€k~C―όψl<₯AtCwZ\|>?₯αb*‹Λ ‚(‘JΠŸŒ 5πϋ|¬›~!ΙClϊ֏ΛDϋϊΟοbώ%KωmΟNŽvRΠήκκ“vΒJιβ%Λέ7[κT!ΙsΛIYš΄T2Ρ‘ν_΅γ―VΉ'ϊϋ¨φŸPYY%Σ9‚ eV;‹i’%’D&E"BOt€ήΨ £™8J|K$|aξΚ?qqΦΨεΨήC¬»ξ ͺCι #scψΑε ΙΒΉκ<–L#MRΚ<—ΙBžκz …d{Δ6ν·―ΏΣΕ=ΐΩώΈζ©b”ΜNy!¬ΒUwΧΘORqurD³q–Ο[FΛovŸχ±αQ|?σ—/"#³tΕϋΟqβPη‚Β8aKΠ dŠΉΉI2ψJC/²s`{€Ώ~7Mœg€iΐ‚ΤΩ‚T yσͺhfο3Za›‹=u"#³Τ4ΤΒΩ$=§»Ξ{“£odΥΥk™QSρ‘“ddΦcΘv Yώ η“(—ΊΙ±R&‰φaz;Df a[k­1zΏ'ΎΌh“± Ώ>ŽΜε½T›ΰkgγTήδΎΩsŒ‹Ώ°ήi?7η“Ξππχ~ *}LP€[=άΔ+σό‘³ ZŽoήsΦί8–-KΣχμ1’νΓv@φ`\QΧήA6α" ‘>%ςΚ)dΦ[ι<¬JN"%ωAδΜίNyψЍ}Ϋ΄Ά₯HfRΞ΅{XΥ ϋzRŽ %€ ’ύ<ΚΩvI’ ™Ξ1πβIFv9Ν¬;€}ήψρΤ |₯rρƒ=Œξ;›wΚ€-ςΡλ±ΎFG€‹šΥΣ™άXwή\φιυ $GθO 9ΆjV*ΧΰsyP`”"'•5ΣGž)sͺyδ΅ϊ~uΨΙΊ'ώ1σŎ $—₯z’ByρV† ΄‚¦©‚‰*Φ±5ʊ ΩχRαΤΡ²š >ηWhΈlΫ{[ˆ€G Hx|ΟΩJšŽ CδېΈTȜddo]?Ϋk;„ƒΐ5ΐΑχjBέΐOV­JvŽΝgΰ­™έ‘Ik”Χl@A–,ώŠ"&ϋ+i=p!WιZΦ—θ*eίΰQS#okΎq‚ΤσxΜξˆ]ζΣRJT6Gδ΅NΊξΫk[¬πY«qό=Ÿ³ϊ]ΰ ―&Š—O‘hI-FΠkΝΙ°I{&nυS t]§ΊΈ‚¦ΠΒα"""ΖΡHΡtΒμ¦?–ΝUtpKŽ‘JYυ…sΝΖΣ ύΆ•ήΗ΅ŠŸ³ƒΡχ ,‡κNzhN5VΧ™]†ζL=Ί†0Μ`lΠtί@3t«ώ― :hΞWμtκV*Ÿq‚Y%σu°1.AςΜ0ύΟgxg‡“λώxςύš³j/―‡gzcFκΜ0šGΗ[2Gk5Z*‘ŸKͺμώE―nZ= UΓq›•»ά펟p…ͺ /ڏμl§χΡCΔτΪΟ|Κ"δήΝ@ΨyσuX“΅€A ‘”uΣρΦ„ΥΦ| ‘kh>έg8Θw¦εEH5¦σM€ Ή†:wͺxςμύ[2ϊf]!UVΎύ/€Σοv€οΥ§)A`θ!/αΉΥ”^2 £Δ0Lp„&0Š|ΦGΖ™//DΎsΜ§)ς‰EUP²qϝΟΖRτo=Ζπkδ’)ϋΤaΰ^ΰΦ?t`οε·;Φ?΅ϊ‹5-`^PCιšΌU!„WΗφ:­“_[…ͺc9HΉ6ξr²"=`ΰΧΗήΡα©'­ΌΞ7­δ;ΩΛW›¬8NπO/₯τ’:ŠNDσ{Χΐ™ΰ[̘nPε*[¦>—Θ}³‡αννďτMψ½Ž9}ύ_ί‹ΑΌ_ί*>m€K¬.Z³αff9Α *4”β­ £ §­Χi3VΆ ‘3§t§»cΔO ?Oβψ9)”(π’ežΰχLυώsΘq–Ε˜s@.ζŽ o΄€1ΑoςSΐ@šΩ@Θ’‹¦ΘF’nΥq/Y+ΐ|Ξ2-–wόž.Qί0σ`Ξ©Αό~Ωj ¬FΜ&φw²$­gΏe•φ[΅Ό+ΩυΎ,Š―ΰι˜Χ|Φ_ ζLΏ*«™΄ΘΥυ±θΐόΠ[ΚϊKX%ͺχ}ωbι‚€J…eIENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-warning-128x128.ico000066400000000000000000002040761524161175200273400ustar00rootroot00000000000000€€ ((€ !8KVdpwwpdVK8! Gƒ³δνπσυχψϊϋϋϊψχυσπν䳃G  J™γγ™J =qΈόόΈq=,‰ΔξξΔ‰,DΒυύ  )&1,81?4C6E4C1?,9&1 )  ύυΒD BΡ2AOf(g„/w™3ƒ§7Œ³:•Ώ>Θ?‘ΞA¦ΥC©ΨC©ΨC©ΨB§Φ@£Π>Θ;•ΐ7Œ΄4ƒ§/w™(g„Of2AΡB RΏ/<'g„9‘Ί>žΚ@’ΟA¦ΤD©ΨD©ΨD©ΨEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩD©ΨD©ΨEͺΩB¦Τ@’Ο>Ι9Ή)h…/<ΏRNΎ )6*nŽ@₯ΣC¨ΧC¨ΧD©ΨD©ΨD©ΨEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩD©ΨC¨ΧC¨ΧA₯Σ,p-: ΎNΆϋ )4 Tk5‡«=˜ΐA’ΞE©ΨEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩFͺΩEͺΩFͺΩFͺΩF«ΪFͺΩF«ΪF«ΪFͺΩFͺΩFͺΩFͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩD©ΨC©Ψ<–Α%`{/< ϋΆ]ωG[,r9‘Ά<—Ύ>˜ΐ?šΓ@ΖEͺΩEͺΩEͺΩEͺΩF«ΪE«ΪF«ΪF«ΪG«ΪG«ΪH«ΪH«ΩH«ΩH«ΩH«ΩH«ΩI«ΩI«ΩI«ΩI«ΩH«ΩH«ΩH«ΩH«ΩG«ΩH«ΪG«ΪF«ΪE«ΪEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩEͺΩDͺΩB€4„©!Skω]*₯ Si5‡ͺ9΅<”Ί=–½>—Ώ>™Α?šΓ?œΕC¦ΣEͺΩF«ΪG«ΪH«ΪH«ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩG«ΩG«ΪG«ΪG«ΪE«ΪEͺΩEͺΩEͺΩEͺΩEͺΩC§Υ>›Ζ&a| ₯*eέ ?O5ˆ«9³:΅<“Ή=•»=–½>˜Ώ>™Α?›Δ@ΗC’ΝH«ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΩH«ΪG«ΪG«ΪF«ΪEͺΩEͺΩEͺΩEͺΩEͺΩD©Ψ@’ΟK` άe•ψ 3@.u“8Œ―9±;΅<’·<“Ή=•Ό=–Ύ?˜ΐ@šΓBœΕBΗDŸΙHͺΧI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩJ¬ΩI«ΩJ¬ΩJ¬ΩJ¬ΩJ¬ΩJ¬ΩJ¬ΩJ¬ΪJ¬ΩJ«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΪH«ΪF«ΪEͺΩEͺΩEͺΩEͺΩEͺΩD©Ψ:‘Ί?Q χ•­ #+(e6‰«8Œ:±:³;‘΅<’·<”Ί>–½@—ΎA™ΐBšΒCœΔCžΖDŸΙG¦I«ΩI«ΩI«ΩI«ΩJ¬ΪJ¬ΪK¬ΪK­ΪK­ΪL­ΪK­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK­ΪK­ΪK­ΪK¬ΪK¬ΪJ¬ΪI¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΩG«ΪE«ΪEͺΩEͺΩEͺΩEͺΩC¨Χ2’,9­.ΝL^4„€7ˆͺ9‹­9Œ―:ޱ;΄;‘΅=“Ή?•Ί@–ΌA˜ΎB™ΐB›ΓCœΕDžΗDŸΙF£ΞH«ΩK¬ΪK¬ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪJ¬ΪJ«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΪG«ΪEͺΩEͺΩEͺΩEͺΩEͺΩA£Ρ(eΜ.Hγ+oŠ6…¦7†¨8‰«9‹9°:޲<΅>’·?”Έ@•ΊA–ΌA˜ΎB™ΐB›ΓCœΕDŸΘE‘ΚG£ΞK«ΧL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪM­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪJ¬ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΪF«ΪEͺΩEͺΩEͺΩEͺΩD©Ψ6‡­ #αHGρ0;0y–5„£7‡§8ˆ©8‰«9‹:°=³>‘΄?’Ά?”Έ@•ΊA–ΌA˜ΏBšΑCœΔEžΖF ΘF’ΚG£ΜJ©ΥOΪP―ΫL­ΪL­ΪL­ΪM­ΪMΪNΪNΪNΪNΪOΪNΪNΪNΪNΪNΪNΪNΪNΪNΪNΪM­ΪL­ΪM­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΪH«ΪF«ΪEͺΩEͺΩEͺΩEͺΩ>›Η=NξG8ϋL^2~œ5„£7…₯7‡¨8ˆͺ8Ь;Œ<ް>²>‘΄?’Ά@”Ή@•ΊA˜ΎB™ΐDœΒEΔFŸΖF ΘG’Λf²ΥŠΗβ“Νθ•ΟιΜηtΐβN―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫN―ΫO―ΫNΪMΪLΪMΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪJ¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩH«ΩG«ΪF«ΪEͺΩEͺΩEͺΩC§Φ)fƒϊ8Fϋ &c{4€ž5‚‘6„£7…¦7‡¨9‰«:‹­=Œ=ް>²>‘΅?“·@”ΉB—ΌC™ΎDšΐEœΒEΕFŸΗG Ι‘Ηΰ¨Ρ䘽Ξ|š¨ŒΎ¨Ρδ¬Ψν‘ΤλhΊΰO―ΫP―ΫO―ΫO―ΫO―ΫP―ΫP―ΫO―ΫP―ΫP―ΫP―ΫP―ΫP―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫNΪNΪM­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩH«ΩG«ΪEͺΩEͺΩEͺΩD©Ψ2} ϊFEρ(e3~œ4Ÿ6ƒ’6„€7†¦8ˆ©:Š«<‹¬=―=ޱ>³?‘΅@”ΈA–ΊC—ΌC™ΎD›ΑEœΓEžΕGŸΗ’ΗߝΒΣ@OV"%qš£Λή°ΪξΛη[΄έQ―ΪR―ΪR―ΪR―ΪR―ΫR―ΫR―ΪR―ΪR―ΫR―ΪQ―ΫQ―ΫR―ΫP―ΫP―ΫP―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΪM­ΪM­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΪEͺΩEͺΩEͺΩEͺΩ6†«νE,θ (g~3}š45 6ƒ’7…₯8‡§;ˆ©<Šͺ<‹­=―=±?‘΄@“ΆA•ΈB–ΊC˜½C™ΏD›ΑFœΓFŸΕn΄Σ¨ΡγCSZ.:?x•£±ΫξͺΧμ}Βγ\΄άS°ΪS°ΪS°ΪS°ΪS°ΪS°ΪT°ΪS°ΫS°ΫS―ΫS―ΫS―ΪS―ΪR―ΫQ―ΫQ―ΫP―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫNΪNΪM­ΪL­ΪL­ΪL­ΪL­ΪL­ΪJ¬ΩI«ΩI«ΩI«ΩI«ΩI«ΩG«ΪFͺΩEͺΩEͺΩEͺΩ9Ή "+ί,Υ #+k†2}˜3~›5€ž5 6ƒ’8…₯:‡¦;‰©<Š«<Œ­=―>²@’΄A“ΆA•ΈB–ΊC˜½DšΏFœΑGžΔQ€Θ˜Κΰ„€²6CI ΖΧ±ΫξιiΊίU±ΫU±ΫU±ΫU±ΫU±ΫU±ΫU±ΫT°ΪT°ΪT°ΪT°ΪT°ΪS°ΪS°ΪS°ΪR°ΪQ―ΫQ―ΫQ―ΫP―ΫO―ΫO―ΫO―ΫO―ΫO―ΫMΪLΪL­ΪL­ΪL­ΪL­ΪK¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩH«ΪF«ΪEͺΩEͺΩEͺΩ<–ΐ+7Ε΅  ,o‰2|˜4}š4œ5€ž6ƒ‘8„€:†€:‡§;‰©<Š«<Œ>°@²@’΅A“·B•ΉC–»E™½F›ΐGΒHžΕsΆΤ¬Υζ"%ˆ§Ά²ΫξœΡκ]΄άV±ΫV±ΫV±ΫV±ΫV±ΫV±ΫU±ΫV±ΫU±ΫU±ΫU±ΫT°ΪT°ΪT°ΪT°ΪT°ΪS°ΪS―ΪR―ΫQ―ΫP―ΫO―ΫO―ΫO―ΫO―ΫO―ΫM­ΪL­ΪL­ΪL­ΪL­ΪK­ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩF«ΪEͺΩEͺΩEͺΩ<–ΐ !+¦•(e|1y•2|˜4~š4œ6Ÿ8ƒ’9„£:†₯;ˆ§;‰©=Œ¬>?±@‘³@’΅B”·C–ΉD˜»FšΏF›ΐGΒQ£Η€Ογ^t} ‘ΖΧ³ΫξvΏαW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫV±ΫW±ΫW±ΫV±ΫV±ΫU±ΫU±ΫT°ΪT°ΪT°ΪT°ΪS°ΪR―ΫR―ΪQ―ΫP―ΫO―ΫO―ΫO―ΫO―ΫN―ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩEͺΩEͺΩ9·[ό "Ym1x“2z–3|˜4~š5€7‚Ÿ9ƒ‘:…£:†₯;ˆ§<Š©>Œ¬>Ž―?±@‘³A“΅C•·D—ΊE™ΌFšΏFœΑIΓ…ΎΧ•ΆΔ cy„³Ϋξ’ΜθY²άW±άW±άW±άW±άW±άW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫV±ΫV±ΫV±ΫU±ΫU±ΫT°ΪT°ΪT°ΪS°ΪS―ΪR―ΪQ―ΫP―ΫO―ΫO―ΫO―ΫO―ΪMΪL­ΪL­ΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩI«ΩH«ΩF«ΪEͺΩEͺΩEͺΩ5…ͺ φZ#νRe0w‘1y”3{—3|™5œ7€ž8‚Ÿ9ƒ‘:…£:†¦;‰¨=‹«>Œ­?Ž―@±A’³C”·D–ΉD—ΊE™½GšΏHœΑbͺΙΤδBQXSfo³άοœΡκ[΄άX³έX³έX³έX³έX³έW²έX²έX²έX²έW²άW±άW±ΫW±ΫW±ΫW±ΫW±ΫV±ΫU±ΫU±ΫT°ΪT°ΪT°ΪS°ΪR―ΪQ―ΪO―ΫO―ΫO―ΫO―ΫNΪMΪL­ΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩF«ΪEͺΩEͺΩEͺΩ1zœΧ#ΎHY/u1x’2z•3{—3}™6œ88‚Ÿ9ƒ‘:…£;ˆ§=Š©=‹«>­?Ž―@‘±C“΄C”·D–ΉF—»G™½I›ΎKΐžΛέ„‘­ l„΄άοΡκ]΄άZ³άZ³άZ³άZ³άZ³άZ³άZ³άY³άY³άX³έX³έW²έW²έW²άW±ΫW±ΫW±ΫW±ΫV±ΫU±ΫU±ΫT°ΪT°ΪT°ΪS°ΪR―ΫQ―ΫO―ΫO―ΫO―ΫNΪM­ΪL­ΪL­ΪL­ΪK­ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩEͺΩD©Ψ'b~še-6-r‹0v2x“2z•4|˜6~š7›8ž9‚ 9„’;‡₯<ˆ§=Š©>‹«>A°B‘³C“΅C•·E–ΉG˜ΊH™ΎKœΐv΄Ο§ΜΫ;GMͺΠβ΄άο’Μη\΄άZ³άZ³άZ³άZ³άZ³άZ³άZ³άZ³άZ³άZ³άZ³άZ³άY³έX²έW²έW²άW±άW±ΫW±ΫW±ΫV±ΫU±ΫT°ΪT°ΪT°ΪS°ΪR―ΪQ―ΫO―ΫO―ΫO―ΫNΪMΪL­ΪL­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩFͺΩEͺΩEͺΩB₯Σ9IMϊ)f|/tŽ1w‘2x“2z•5|˜7~š7›8ž9‚ ;…£<‡₯<ˆ§=Š©>Œ¬@Ž―A±B’³C“΅D•·G–ΈH˜»Jš½V’ÞΚέ{–‘z•’΅άο΄άοyΑβ[³ά[²Ϋ[³ά[³ά[²Ϋ[²Ϋ[³ά[³ά[³άZ³άZ³άZ³άZ³άZ³άZ³άZ³άY³έX²άW²έW²άW±ΫW±ΫV±ΫU±ΫU±ΫT°ΪT°ΪS°ΫR―ΪR―ΫP―ΫO―ΫO―ΫN―ΫMΪL­ΪL­ΪL­ΪK­ΪJ¬ΩI«ΩI«ΩI«ΩH«ΩFͺΩEͺΩEͺΩ=—Α ςΎ!Qd/sŒ1v1w‘2y“4{–6}˜7~š8€œ8ž9ƒ‘;†£<‡₯<‰¨=Šͺ@­A―A±B’³E“ΆF•ΈH˜ΉI™»J›½{·ΠΣγ6BH΅άο΅άοžκ^΅ά[³ά\³ά[³ά[³ά[³ά[³ά[³ά[³άZ³άZ³ά[³ά[³ά[³ά[³άZ³άZ³άZ³άZ³άY³άX³έW²άW²ΫW±ΫW±ΫV±ΫU±ΫT°ΪT°ΪT°ΪS°ΪQ―ΫP―ΫO―ΫO―ΫO―ΫMΪL­ΪL­ΪL­ΪL­ΪJ«ΩI«ΩI«ΩI«ΩH«ΪF«ΪEͺΩEͺΩ4¦’A0<-r‹/t1v1w’3z•6{–6}˜7~š8€œ9‚Ÿ:„‘;†£<‡¦<Ѝ@Œ«@­A―B±D’²E“΅H–·I˜ΉI™»U‘Α«ΡαQbižΑΡ΅άο²ΫξwΏα\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά[³ά[³ά[³άZ³άZ³ά[²Ϋ[³άZ³άZ³άZ³άY³άY³έX²έW²έW±ΫW±ΫW±ΫV±ΫU°ΪT°ΪT°ΪS°ΪR―ΪP―ΫO―ΫO―ΫN―ΫMΪL­ΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩEͺΩEͺΩ$[uϊ=γ -qŠ/s‹0t1v1x’5z”6{–6}˜7š8€œ:ƒ ;„’;†€<‡¦>Ѝ@Œ«@Ž­A°D‘²E“³H•΅H–·I˜ΉK›ΌΐΦ‘―Ό Xjt²Ωμ΅άοœΡι\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά[³ά[³ά[³ά[³ά[²Ϋ[³ά[³άZ³άZ³άZ³άX³έW±έW±ΫW±ΫW±ΫU±ΫU±ΫT°ΪT°ΪS―ΫR―ΪP―ΫO―ΫO―ΫNΪMΪL­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩEͺΩB€#-²q!Uh.q‰0sŒ0uŽ1v4y“5z”6|—6}˜7›9‚ž:ƒ ;…’;†€>‰¦?‹ͺ@Œ«@ŽC―E‘±G“³H•΅I˜ΈK™Ίa¦ΓͺΝέ7CH"œΎΟ΅έο³άοfΈί]΄έ]΄έ]΄έ]΄έ]΄έ]΄έ]΄έ]΄έ]΄έ]΄έ\³ά\³ά\³ά\³ά\³ά\³ά\³ά[³ά[³ά[³ά[²Ϋ[³άZ³άZ³άZ³άY³έW²έW²άW±ΫW±ΫV±ΫU±ΫT°ΪT°ΪS―ΫR―ΪP―ΫO―ΫO―ΫNΪL­ΪL­ΪL­ΪK­ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩF«ΪEͺΩEͺΩ0xšϊEη+5,n…/rŠ0sŒ0uŽ2w5y’5z”6|—7~š9€œ9‚žD‰₯X—°cŸΆf‘Ίf£»c‘»U™΅E―F’±G”³H–·J˜ΉL™»·Οβ€š¦au€ΆέπΆέπ„Ζε^΅ή_Άή_Άή_Άή_Άή_Άή_Άή_Άή_Άή_΅ή^΅ή^΅ή^΅ή^΅ή]΄έ]΄έ]΄έ\³ά\³ά\³ά\³ά[³άZ³ά[³ά[³άZ³άZ³άY³άX³έW²έW±ΫW±ΫW±ΫU±ΫT°ΪT°ΪS°ΪR―ΫP―ΫO―ΫO―ΫNΪL­ΪL­ΪL­ΪK­ΪJ¬ΪI«ΩI«ΩI«ΩG«ΪF«ΪEͺΩC§Υ8H°h #Wi.pˆ/rŠ0tŒ2v4w‘5y’5{•6|—7šf²’ΉΙΑΟ ΒΟ ΓΠ‘ΔΡ’ΔΣ€ΖΤ€ΗΧ™ΓΣXΈH•΄J—·K˜ΉLš»_¦Δ―Σγ°Τγ°ΤδWjr ΅έοΆέπ₯ΥμdΈί`Άή`Άή`Άή`Άή`Άή`Άή`Άή`Άή`Άή`Άή`Άή`Άή_Άή_Άή^΅ή^΅ή]΄έ]΄έ\³ά\³ά\³ά\³ά[³άZ³ά[²Ϋ[³άZ³άZ³άZ³άX³άW²άW±ΫW±ΫW±ΫU±ΫT°ΪT°ΪS°ΪR―ΪP―ΫO―ΫO―ΫNΪL­ΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩI«ΩF«ΪEͺΩEͺΩ6‡¬ 0ι+3,n…/pˆ0r‹0t2v4x‘5y“9}–\•«žΐΝ–΄Ώ~– Yjq8DH&.1")+*26DQW‰€―«ΝΫƒΆΜK–΅J—·K˜ΉLšΌMΏw΄ΟͺΠα°Τδ°Υε•΅Β8DJ „ ­·ήπΆέο|ΒγaΆήaΆήa·ήb·ήb·ήb·ήb·ήb·ήb·ήa·ήa·ήaΆή`Άή`Άή`Άή_Άή_Άή^΅ή^΅ή]΄έ]΄έ\³ά\³ά\³ά[³ά[³άZ³ά[³άZ³άZ³άZ³άY²έW²άW±ΫW±ΫW±ΫU±ΫT°ΪT°ΪR―ΫQ―ΪP―ΫO―ΫO―ΫMΪL­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩH«ΪF«ΪEͺΩEͺΩ›Ζ'ζ'0-m„.p‡/q‰1tŒ1uŽ4wT’•ΉΖˆ’¬ &(fz‚«ΝΪ¬ΝΫr¬ΔJ•Άe§Β£Κά¬Πί‘Κ܁ΊT‘ΒS’Εh―Ν‘Δά²Χθ³Χθ¨Μάw› ’ΔΤΈήπΆέοwΐβc·ήd·ήd·ήd·ήd·ήd·ήd·ήd·ήd·ήd·ήd·ήd·ήd·ήd·ήc·ήc·ήb·ήb·ήb·ή`Άή`Άή_Άή_Άή^΅ή]΄έ\³ά\³ά\³ά\³ά[³άZ³ά[³άZ³άZ³άX³έW²άW±ΫW±ΫU±ΫU±ΫT°ΪT°ΪR―ΫQ―ΪO―ΫO―ΫO―ΫL­ΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩI«ΩF«ΪEͺΩEͺΩ Pg₯9&au-n….p‡/q‰1tŒ2uL‡›ΌΙ˜‘%'«ΜΩ«ΝۘΒΣQ™·J–Ά₯ΛάtŒ–Xjq¬Ξέ―β€Νίp±ΟT€ΗW§Κm³Σ¨Σζ΄Ωκ΄Ϊμ­βWjr dy‚ΆάξΉήπšΠιd·ήd·ήd·ήd·ήd·ήd·ήdΈίdΈίdΈίcΈίcΈίdΈίd·ήd·ήd·ήd·ήd·ήd·ήc·ήc·ήb·ήaΆή`Άή`Άή_Άή^΅ή^΅ή]΄έ\³ά\³ά\³ά[³ά[³άZ³άZ³άZ³άX³έW±άW±ΫW±ΫV±ΫT°ΪT°ΪS°ΪR―ΫP―ΫO―ΫO―ΫNΪL­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩEͺΩ=˜Β τΝ%-,l‚.n…/p‡/rŠ2t<{’Œ²ΐ‹¦°Š₯°«ΜΪ©ΜΪpͺΒI”΄~΄Λ€Ε(03rŠ“¦ΘΧ±Υε”Ζά_«ΝW¨ΝY«Π‰Δί²Ωλ΅ΫμΆάν±ΦζuŽ™₯ΖΦΉήπΆέοfΈήd·ήdΈίdΈίdΈίdΈίdΈίeΈίeΈίeΈίeΈίeΈίdΈίdΈίdΈίdΈίd·ήd·ήd·ήd·ήd·ήc·ήb·ήb·ήaΆή`Άή`΅ή^΅ή^΅ή]΄έ\³ά\³ά\³άZ³ά[³άZ³άZ³άZ³άX²έW²άW±ΫW±ΫV±ΫT°ΪT°ΪS°ΫQ―ΫO―ΫO―ΫNΫL­ΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩH«ΩFͺΩEͺΩC¦ΣFYύqό#Wi-l‚.o…/pˆ0s‹3tf—©’ΐΛ%,.HU[¨ΙΦ«ΜΪ–ΐΡI“²ZžΌ©Νάk€‰/8Bp…ŒͺΛΨ«ΜΩm¦ΎH‘―j¦ΐΟέ(04:FK‰₯±Έέξ΄άο–Ξι{Βδl»αgΉΰhΉΰiΉΰiΉίjΊΰjΊΰjΊΰjΊΰjΊΰjΊΰjΊΰjΊΰjΊΰjΊΰjΊΰjΊίjΊΰiΉίiΉΰgΉΰfΉΰfΉΰfΉΰeΈίeΈίdΈίd·ήd·ήc·ήc·ήb·ή`Άή`Άή^΅ή^΅ή]΄έ\³ά\³άZ³ά[³άZ³άZ³άY³έW²έW±ΫW±ΫV±ΫT°ΪT°ΪR―ΫQ―ΫO―ΫO―ΫMΪL­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩH«ΪG«ΪEͺΩEͺΩ %κͺ4?,k-mƒ.n…/p‡2r‰i™©”Έ"(+/8<ͺΚΦ«ΛΨ”½ΞL’―K“²¦ΚΩkˆ 2C$''/3Yktγ·ήπ‚Ζε^΅ή]΄έ\³ά\³ά[³ά[²Ϋ[³άZ³άY³άW²έW±ΫW±ΫU±ΫT°ΪS°ΪR―ΪP―ΫO―ΫN―ΫL­ΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩEͺΩK`ϋ½BP,k€-l‚.m„/p‡2qˆ2sŠ~§Ά‘¬Ά &(Wgm©ΘΥͺΚΦr§½Fͺf‘ΊͺΜΩDQV  "'.2:EJO^e_pxl‰tŠ”z‘›z‘›zšuŠ•n‚Œas|Q`g@LS)04"% KZa·έο―Ϊξd·ί^΅ή\³ά\³ά\³ά[³ά[³άZ³άZ³άY³έW²άW±ΫV±ΫU±ΫT°ΪR―ΪP―ΫO―ΫO―ΫMΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩH«ΪEͺΩEͺΩ2}Ÿϊ%^r,k€-l‚.n„/p‡2rˆ3sŠo­‘ΎΙ09=§Ζ©ΘΥ–½ΝK«I¬–ΏΠ‹₯°***–΅Δ·ήπ€Δδ^΅ή^΅ή\³ά\³ά[³άZ³άZ³άZ³άY³έW²άW±ΫW±ΫU±ΫT°ΪT°ΪP―ΫO―ΫO―ΫNΪL­ΪL­ΪL­ΪJ¬ΩI«ΩI«ΩI«ΩG«ΪEͺΩ>™ΓQ,j,k€-l‚/o…0p‡2r‰3s‹T‹Ÿ£ΑΜSbhvŒ”©ΘΤ©ΙΥeŸΆFŒ¨l₯Ό¬ΜΪ$'333ηηηΒΒΒͺͺͺlllJJJ Ч΄·ήπŽΛη_Άή^΅ή\³ά\³ά\³άZ³ά[³άZ³άZ³άX³έW±ΫW±ΫU±ΫT°ΪS―ΫR―ΪO―ΫO―ΫOΪL­ΪL­ΪL­ΪJ¬ΩI«ΩI«ΩI«ΩG«ΪEͺΩA Ν #‘Q ,j-k€-l‚/o…2q‡2r‰3s‹5w’Α̎¨± 7AE¨ΗΣ©ΘԌ·ΘH§M¬§ΙΧbu|ωωωσσσΪΪΪ ›»ΚΈήπŽΛη`Άή^΅ή]΄έ\³ά\³ά[³ά[³άZ³άZ³άX²έW±ΫW±ΫV±ΫT°ΪT°ΪR―ΫP―ΫO―ΫO―ΫM­ΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩB€8HΘ„/9,k€-k-mƒ/o†2q‡2r‰3t‹4vŽw€΅€ΓΞERW “Ί©ΗΣ§ΗΤ\™°E‹§‹·Ι—΄ΐ$&ΪΪΪL\cΉήπΈήπ~Δδ`Άή_΅ή]΄έ\³ά\³ά\³ά[³ά[³άZ³άY³έW²άW±ΫW±ΫU±ΫT°ΪR―ΫP―ΫO―ΫO―ΫMΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩH«ΪEͺΩC§Υ!Riν―BO,k€-k-mƒ/o†2q‡2r‰3t‹5vŽB˜€ΓΞ₯ΓΟXho¦ΕΡ©ΗΤ…²ΓC‰₯b΅₯ΖΤ]nu’’’;;;***GGG444&&&§ΙΩΉήπ­ΩνhΉί`Άή_Άή]΄έ\³ά\³ά[³ά[³ά[³άZ³άY³άW²άW±ΫW±ΫU±ΫT°ΪS°ΪR―ΫO―ΫO―ΫMΪL­ΪL­ΪL¬ΪI«ΩI«ΩI«ΩI«ΪEͺΩEͺΩ*h„ωά"Sd,k€-k/n„0o†2qˆ2sŠ3tŒ5w8z’s’΄₯Γϊ’¬'.1«Ά¨ΗΣ§ΖΣEŠ€F‹¦ΊΛ–³Ώ jjjqqq «««χχχέέέΑΑΑ§§§‹‹‹``` h}‡ΉήπΉήπ†Ζεb·ήaΆή_Άή^΅ή]΄έ\³ά\³ά[³ά[³άZ³άY³άW²έW±ΫW±ΫU±ΫT°ΪS°ΪR―ΪP―ΫO―ΫNΪL­ΪL­ΪK­ΪJ¬ΪI«ΩI«ΩH«ΪF«ΪEͺΩ/v–όξ&]q,k€-l.n…0p†2qˆ2sŠ3u5w6y‘E„›–ΊΗ¦ΔΡ™ΆΑ§Ζ¨ΗΣi ΄Bˆ£g Ά©ΙΧ:EI777£££888ϋϋϋ«««'/3ΉήπΉήπ¦ΥμkΊίc·ήa·ή`Άή_΅ή]΄έ\³ά\³ά[³ά[²ΫZ³άZ³άX²έW±άW±ΫV±ΫT°ΪS°ΪR―ΪO―ΫO―ΫO―ΫM­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩH«ΪF«ΪEͺΩ3€£ ώς(au-k€-l‚.n…1p†2qˆ3sŠ3v5w6y’6z”W¦€ΔΠ¦ΕѧƎ·ΗDˆ’EŠ₯šΐΟz‘š!!!ςςςήήή ―――ψψψ,,,–΄ΒΊίρ·ήπ€Εεd·ήc·ήb·ή`Άή_΅ή]΄έ\³ά\³ά[³ά[²Ϋ[³άZ³άY²έW±άW±ΫV±ΫT°ΪT°ΪS°ΪP―ΫO―ΫO―ΫL­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩH«ΩF«ΪEͺΩ7ˆώ9υ (dx-k€-l‚.o…1p†2r‰3sŠ3v5w6y’6{”9}—I‰‘w§Ήp’ΆHŠ£A†‘r§»ͺΚΦΜΜΜφφφΗΗΗ›››lllAAA+++ 555’’’N^e΅ΩλΊίρ₯ΥμdΈίd·ήc·ήb·ήaΆή_΅ή]΄έ\³ά\³ά[³ά[³ά[³άZ³άY²έW±άW±ΫV±ΫT°ΪT°ΪR―ΫP―ΫO―ΫO―ΫMΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩF«ΪEͺΩ9ŽΆ ώQ+φ)dx-k€-l‚/o…0p†2r‰3t‹3vŽ5x6y’7{”9~—:™;œ=‚?…ŸA†‘§ΗΤsˆ ¦¦¦ψψψΪΪΪΌΌΌžžžyyyΠΠΠεεε ˜ΆΔΊίρΊίρo½αdΈίd·ήc·ήb·ήa·ή_΅ή^΅ή\³ά\³ά[³ά[³ά[³άZ³άY²έW²άW±ΫV±ΫT°ΪT°ΪS°ΫP―ΫO―ΫO―ΫMΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩ<”½ &ώd9ω)dy-k€-mƒ/o…2q‡2r‰3t‹3vŽ5x6y’7{”9~˜:š;œ>‚A…ŸA†‘£ΖΣͺΙΥ|“œ7AFTTTVgoΊίπΊίρΜθeΈίdΈίd·ήd·ήb·ήaΆή_΅ή^΅ή]΄έ\³ά[³ά[³ά[³άZ³άY³έW²άW±ΫV±ΫU±ΫT°ΪR°ΫQ―ΪO―ΫO―ΫMΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩG«ΪEͺΩ=˜Β "+rEω )ey-k-mƒ/o†2q‡2r‰3t‹4vŽ5x‘6z“7{•9~˜:š<œ>ƒA… A‡’n€Ή«ΚΧ«ΛΨ£ΒΟx˜```όόόΌΌΌ°ΡβΊίπ¬Ωξk»αeΈίdΈίd·ήd·ήb·ήa·ή_Άή^΅ή]΄έ\³ά[³άZ³ά[³άZ³άY³έW²έW±ΫW±ΫU±ΫT°ΪR°ΪQ―ΪO―ΫO―ΫMΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩ?œΗ%/zJϊ *ey-k-mƒ/o†2q‡2r‰3tŒ4w5x‘6z“7{•9~˜:€š<>ƒžA… Bˆ£C‰₯X—°—ΏΟ¬ΜΪ­ΝΪͺΛΨTdj@@@λλλύύύφφφχχχRRRxš»ίρ»ίπ„ΖζfΉΰeΈίdΈίd·ήd·ήb·ήa·ή_Άή^΅ή]΄έ]³άƒΕδšΠιšΠιƒΖδZ³έW²έW±ΫW±ΫU±ΫT°ΪS°ΪQ―ΪO―ΫO―ΫMΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩF«ΪEͺΩ?œΗ&0vHω)ey-l.mƒ/p†2qˆ2sŠ3tŒ4w5x‘6z“7|•9~™:€›;‚?ƒžA… Bˆ£DŠ₯E‹¨Fͺr©ΐ¦ΙΧ­ΝΫΞܘ΄ΐ4>C ΫΫΫεεεiii‚‚‚ŸŸŸΉΉΉΤΤΤνννΈΈΈ-6:»ίρ»ίρ¦Υμk»ΰfΉΰeΈίdΈίd·ήd·ήc·ήa·ή_Άή^΅ήiΉί«Ψν€ΘΩ—ΉΙ‘ΔΥ³Ϋξ«ΨνhΉΰW±ΫV±ΫU±ΫT°ΪS°ΪQ―ΪO―ΫO―ΫNΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩH«ΪEͺΩ>šΔ#-k>ψ)dy-l‚.m„/p‡2qˆ2sŠ3tŒ4w6y’6z”8|–:™;€›<‚>ƒžA†‘B‡’DЦE‹¨FͺGR—²„΅Ι₯ΚΩ―Οέ―Πήx™.8<ΚΚΚβββ !!!111AAAΈΈΈώώώ888œΊΘ»ίρΊίπΔδgΉΰfΉΰeΈίdΈίd·ήd·ήb·ήa·ή_Άή^΅ή¦Υ쎭Ό)27 #&x’Ÿ΄άο­ΩξbΆέV±ΫT°ΪT°ΪS°ΫQ―ΫO―ΫO―ΫMΪL­ΪL­ΪK­ΪI«ΩI«ΩI«ΩG«ΪEͺΩ=–ΐ (Y1υ )dx-l‚.m„/p‡2rˆ3sŠ3uŒ4w6y’6z”7|–:™;€›<‚>ƒ A†‘B‡£CЦFŒ¨FŽ«GJ’―K“±`‘»ŠΊΞ―Πή°Ρί€Δu‹•¨¨¨ξξξEEEψψψŸŸŸXiqΉέξ»ίρ‘ΣλhΉίfΉΰfΉΰeΈίdΈίd·ήd·ήb·ήa·ή_Άήuΐβ²Ϊμ9EK ’²Β³άο κY²ΫU°ΪT°ΪR―ΫQ―ΪO―ΫO―ΫNΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩ:Έ !ώF$ς(bv-l‚.n„/p‡2rˆ3s‹3u4w6y’6{”7|–:™;€›;‚?„ŸA†‘B‰€CЦFŒ¨FŽ«GJ’―K”²L•΄O˜΅d₯ΐ ΘΩ°α±Σβ¨ΙΦDQWuuuώώώϊϊϊeee™™™τττ "ž»ΚΌίρ»ίρoΌαhΉίgΉΰŽΛθ­Ωξ±Ϋξ¦Υμ{Βγb·ήa·ή_Άή„ΖεͺΠΰ,5:°Ψλ²ΫξˆΘεT°ΪT°ΪR―ΫP―ΫO―ΫO―ΫMΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩF«ΪEͺΩ8‹±ώ$ρ'_r-l‚.n„/p‡2r‰3t‹3u4x6y’7{”7|–:š;œ<‚ž>„Ÿ@†‘Bˆ£C‹¦EŒ©FŽ«GJ’°K”²L–΄M—ΆO™ΈPšΊ·ΟͺΟΰ²Υγ³Υδp†CCCύύύƒƒƒ///iii^px½ΰρΌίρŒΙηjΊΰo½ΰͺΨν­Πΰ•³Α–΅Γ°ΣδΈήπ›ΡκaΆή_΅ήΕδ΅έοN_hAOW³Ϋξ©ΧμgΈέT°ΪR―ΫP―ΫO―ΫO―ΫM­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩ5ƒ§ ώμ$Xj-mƒ.n….qˆ2r‰3t‹3u4x6z“7{•7}—:š;œ<‚ž>„ @†‘Bˆ€C‹§FŒ©GެG―J’°K”²M•΄M—ΆO™ΉPšΊQ½a¦Ζ‘ΒΨ―Σγ΄Φε•±½.7; ύύύ’’’ΆΆΆΘΘΘ!#΄ΥεΌΰρ¬Ψνl»ΰjΉί’Μθ―Ρβ2<@=JP±ΦηΈήπŒΚη_΅ήiΊΰ΅άο©Ξΰ%-1ƒ‘―²Ϋξ‡ΗεU°ΪR―ΫP―ΫO―ΫO―ΫM­ΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩF«ΪEͺΩ1zœϊζIX,mƒ.n…/qˆ2r‰3t‹4vŽ4w6z“7{•7}—:™;œ<ƒž>„‘@‡’Bˆ€C‹§E©GެG―J“°K”²M•΄M—ΆO™ΉP›»RΎSŸΑU’Γu΅Ο«β΅Χη¦ΗΥ;GLβββΏΏΏJJJρρρωωω___‰’­½ΰρΊίπ„ΖεjΊΰm»ΰ―Ϊξi}ˆ;HN·ήπ²ΫοsΎβ]΄έΚζ΅άοŸΒ6BG­Τη―Ϊν^΄άR―ΫO―ΫO―ΫO―ΫM­ΪL­ΪL­ΪI«ΩI«ΩI«ΩI«ΩF«ΪEͺΩ,nυΎ7B,mƒ.o…0qˆ1s‹3tŒ4vŽ4w6z“7{•7}˜:€š;œ<ƒŸ>„‘@‡£B‰€C‹§E©GެG―I“±K”³M–΅M˜·N™ΉP›»RΎSŸΑU’ΓW€Ζd¬Λ©Ργ΅ΨθΆΩθ?LQ¨¨¨ήήή ͺͺͺΖΖΖ…‘A‡£B‰€C‹¨EͺGެG―I’²L•³M–΅N˜·N™ΉP›»RžΏS ΒT’ΔW€ΖY§Θ[¨Κ ΝαΆΩι·ΪκKZ`lllόόό---@@@ϋϋϋύύύIII ¦ΖΤ½ΰρΈήπΔδjΊΰiΉίm»α΄άο›ΊΙ&/2¨ΝήΆέπn»ΰ\³άoΌΰΩνΤζ$,/WktΨλ―ΪξR―ΪO―ΫO―ΫNΪL­ΪL­ΪL­ΪI«ΩI«ΩI«ΩH«ΪF«ΪC¦ΣCU¬f -m„.o†/qˆ1s‹3uŒ4v4x‘5z”7|–8}˜8š;‚<ƒŸ>…’@†£B‰₯C‹§EͺG¬H‘I’²L•³L–΅N˜ΈNšΊP›ΌQΎSŸΑT’ΔV€ΖY¦ΘZ¨Κ]ͺΝ–ΘίΆΪκ·ΫλQah---IIIΏΏΏ¬¬¬`qy»ήοΌΰρŸκjΊΰjΊΰiΉίhΊΰλΉήπo…|—£΅έο›Πι\³ά\³άΚζ³Ϋξn†’"%§Οα±ΪξrΎαO―ΫO―ΫNΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩH«ΪF«ΪB’Ο(3f2+i.o†/qˆ0s‹2u4v4x‘5z”7|–8~˜9š;‚œ<ƒ >…’@†€B‰₯C‹§EͺG¬H―H’±K•΄L—΅N—·NšΊO›ΌQΎS ΑT’ΔV€ΖX¦ΘZ©Λ\ͺΝ`­Ο’Θΰ·Ϋμ±ΥεCQWqqqOOOϋϋϋ$&¦ΔΣΌΰρ·έοnΌΰjΊΰiΉίgΉΰfΉΰzΒδ΄άο²Φη5@EIY`°Χι΅άοa΅έ[³άd·ή°ΪΚ"“ΆΖ°Ϊξ•ΞιO―ΫO―ΫMΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩ@žΙ 2O_.o†/q‰0s‹2uŽ4w4x‘5y“7|–8~™8›;‚<„ =…’?‡£A‰₯C‹§DŽ«G¬H‘―I“²K”΅L—ΆN˜ΈO™ΊOœΌQΎRŸΐT‘ΔU€ΗX¦ΙZ©Λ\«Ν^¬Π_јΜγΈάμ·άμ'/2ΞΞΞηηηuuugz„½ΰρΌΰρ„ΖεjΊΰjΊΰhΉΰfΉΰfΉΰeΈίΜθ·έοz“Ÿ#&«Οα΅άοzΑβZ³ά[³ά–Ξθ΄άο7DJayƒ°Ϊξ§ΧνO―ΫNΪL­ΪL­ΪL­ΪK¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩ9΄Ρ1;-p‡/q‰1tŒ2uŽ4w5x‘5z“7|—8~™8›;‚<„ =…’>‡€BЦC‹¨DͺF­H‘―I’±J•΅M—ΆM™ΈO™ΊOœΌQΏRŸΑT’ΔU€ΗX§ΙZ©Λ\«Ν]¬Π_`±ΥΟζΈέξ³Χη šššΦΦΦ%(ΌίπΌΰρ§Φμl»ΰjΊΰiΉίgΉΰfΉΰfΉΰeΈίiΉί³Ϋξ—΅Δ¦ΙΫ΅άοŽΚζZ³άZ³άq½ΰ΄άο]r|6BH°ΪξͺΨν`·ήNΪL­ΪL­ΪL­ΪJ¬ΩI«ΩI«ΩH«ΩF«ΪEͺΩ&`zά‹-p‡/q‰0s‹1uŽ4w5x’5z”7|—8~™9€›:<„‘=†£>‡₯AЦC‹©DͺE­H‘°I“²I•΅M—ΆM™ΉO™ΊPœ½QžΏRŸΑS‘ΔU£ΖW¦ΙY¨Μ[«Ξ\­Π_`°Τb²Ψ‘θΈέο™ΈΗaaaϋϋϋkkkŽ©ΆΌΰρΉήπ€ΔδjΊΰiΉίgΉΰfΉΰfΉΰeΈίdΈίd·ή’Σλ¦ΙΩ&.1šΌΜ΅άοšΠι[³άZ³άZ³ά³άοœ© °Ϊξ¬Ωξn½αNΪL­ΪL­ΪK­ΪJ«ΩI«ΩI«ΩH«ΩEͺΩEͺΩ,8”>'`u/q‰0s‹1uŽ3w‘5y’5z”6}–8~™9€›9<„ =†£?ˆ₯A‰§B‹©DͺE­H‘°I“²J•΅L˜ΈN™ΉN›»Pœ½PžΏRŸΑS‘ΔU€ΗV¦ΚX¨ΜZͺΞ]­Π^`°Υa²ΧmΉά«ΧμΉήπvŽš444ΣΣΣ KY`ΌΰρΌίρ ΣλkΊΰiΉίgΉΰfΉΰfΉΰeΈίdΈίd·ήd·ήΚζ«Οΰ+48•΅Δ΅άοšΠι[³άZ³άZ³άžλ—ΉΙ °Ϊξ­ΩξyΒγMΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩC₯ΣFχBP/rŠ0sŒ0vŽ4w‘5y’5z”6|—8~š9€œ9‚ž;ƒ =†’>ˆ₯@‰§B‹ͺD«EG‘°I“²J”΅K˜ΈN™ΉN›»PΎPžΐR ΒS‘ΔT£ΖV¦ΚX¨ΜZͺΞ\¬Ρ^―Σ_°Υa²Χb³ΩwΏΰ·έπΈέοCPV!!!κκκύύύWWW «ΚΫ»ίρΈήπxΐγiΉίfΉΰfΉΰfΉΰeΈίdΈίdΈίd·ήd·ήΚη¦ΙΩ&.1œ½Ξ΄άο›Πι[³άZ³άX²έŒΚη₯Κά ©ΣζΪξ~ΔδL­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩF«ΪEͺΩ.qϋ Ή&./rŠ0sŒ1u2w‘5y’5z”6|—8š9€œ:‚ž;ƒ =†£>ˆ₯?‰¨B‹©CެFF‘°I“²J•΄K—ΈMšΊO›ΌOΎPžΐQ ΒS‘ΔT£ΖU₯ΘW¨ΜYͺΟ[¬Ρ^―Σ_°Υa²Χb΄ΪcΆέ‹ΚηΉήπ©ΚΫΘΘΘΒΒΒi}‡Ήέο»ίρ˜ΟιiΉίgΉΰfΉΰfΉΰfΉΰeΈίdΈίd·ήd·ήc·ήŸΣκ•΅Δ¦ΙΫ΅άξ—ΟθZ³άY³άW²έƒΕε¬Σε €ΜίΪξΔδL­ΪL­ΪL­ΪI¬ΪI«ΩI«ΩI«ΩF«ΪEͺΩ2@Κ s(cx0tŒ1u2x’5y“5{•6|—7™9€œ:‚Ÿ:„‘=†£>ˆ¦?ЍA‹ͺC¬E­F’±H“³J•΅K—ΈKš»O›ΌOΎQžΐQ ΒS’ΕT£ΖU₯ΙW¨ΝXͺΟ[¬Ρ\Τ_±Φ_²Ψb΄Ϊc΅άcΈίκΉήπ›¨•••όόό&&&#)-¨ΙΩ»ίρ³άοiΉίgΉΰfΉΰfΉΰfΉΰeΈίdΈίd·ήd·ήc·ήiΊί²άοw‘ '+ͺΠβ΅άξŠΙζZ³άX³έW²ά‚Εδ¨Ξΰ ¦ΞαΪξ~ΔδL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩG«ΪF«Ϊ?œΗ >υ N_0tŒ1u2x’4y“6{•6|—7~™9€œ:‚Ÿ:„‘<†£>ˆ¦?Ѝ@‹ͺC¬DF‘°G“³J•΅K—·K™ΊN›½OΎPŸΐRŸΒR’ΕT£ΗU₯ΙV§ΛW©ΞY¬Ρ\Τ]°Φ_²Ψa΄ΪcΆάd·ήk»ΰ¬ΩνΉήπL\c***HHHvvv₯₯₯‚‚‚q‡‘»ίπ»ίπΔδfΉΰfΉΰfΉΰeΈίdΈίdΈίd·ήd·ήc·ήb·ή’Νθ²Ψκ/:?N_h±Χκ΅άοuΏαZ³άX²έW±ά‹Ι暽Π―Ϊξ­Ωξ}ΓδL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩG«ΪEͺΩ)fƒϋ>Ν,6/q‰1v1w‘3z“6{•6}˜7~š8:‚Ÿ:„‘;†£>ˆ¦?Š©?Œ«C­D―F‘°G”³I•ΆK—ΈK™ΊL›½PΏPŸΑR ΓR’ΕS€ΗU₯ΙV§ΛW©ΞX«Ρ[Τ]°Φ_²Ω_΄ΫbΆέd·ήd·ή€Δδ·ήπͺΜέ!!!SSS%,0ΊίρΊίρŸλiΊΰfΉΰfΉΰeΈίdΈίdΈίd·ήd·ήc·ήb·ή„ΗεΆέοo‡’œ©΅άο΄άοZ³άX³άW²έW±ΫœΡιƒ’° ―Ϊξ­ΩξyΑγL­ΪK­ΪI«ΩI«ΩI«ΩH«ΩF«ΩD¨Φ5DΨ‘*h1v1w‘3z•6{•6}˜7~š8€:ƒŸ;„’;†€=‡¦?Š©@Œ«B­C―F‘°G“³H•ΆK—ΈK™ΊL›½NΐPŸΑQ ΓS’ΖS€ΗU₯ΚV§ΜW©ΞX«ΡZΤ\°Φ^²Ω_³ΫaΆέc·ήd·ήe·ήžΣλ·έοlŒ–΅ΓΊίρ΅έπyΒδfΉΰeΈίeΈίfΉίe·ήe·ήe·ήeΈήfΉίΛη·ήπ€œ©+49§Λέ΄άο’ΜηZ³άW³έW±ΫW±ΫΩνcz…)48―Ϊξ«Ψνo½αL­ΪJ¬ΪI«ΩI«ΩI«ΩG«ΪF«Ϊ8а‘:ψAO1v1w’2y”4|–6}˜7š8€œ9‚ ;„’;†€<ˆ¦?Š©@Œ«@­C―D’±G“³H•΅J˜ΈL™ΊLšΌMΏPŸΒQ‘ΔR’ΖS€ΘT¦ΚV§ΜW©ΞXͺΡY­Σ[°Χ]²Ω_΄Ϋ_΅έa·ήc·ήd·ήuΏα΄άο¨ΙΪ$+/SckΊίρΊίρšΠκeΈίeΈίvΐβŒΚη—Οι–ΞθŽΚηŒΚζšΠι²άο₯ΙΩ[nww‘ž΄άο΄άοjΊίX³άW±άW±ΫgΉή²Ϋξ>MTQeoΪξ©Χνb·ήK­ΪI«ΩI«ΩI«ΩI«ΩF«ΪD¨Χ&_yϊ:Ξ 0u1x’2y”4|—7}™7š8€9ƒ ;…’;†€<ˆ¦>Ѝ?Œ«@ŽB°D‘±G“³H•΅I˜ΈK™ΊL›ΌMœΏOŸΓP‘ΔR£ΖS€ΘT¦ΚT§ΜW©ΞX«ΡX¬ΣZ―Φ\±Ω^΄Ϋ_΅έ`ΆήbΆήb·ήc·ή”ΝθΉήπdy‚¨ΙΪΉήπ΄άοo½αdΈίŠΙζ±Ϊξ§ΙΩ‰₯²‘ΒΡ­Ρβ₯ΗΧ†’°Pai"%HX_΄άο΄άο“ΝθY²έW²άW±ΫW±ΫΛηœΑ'*†§ΆΪξ§ΧνP―ΫJ¬ΪI«ΩI«ΩI«ΩH«ΩEͺΩB’Ο $ΞYDS2x’2y”3{—6}š7›88‚Ÿ9„’;†€<ˆ§>‰©?Œ«@ŽA°D‘²E“³H•·H—ΈK™»L›½MœΏNŸΒO ΕR£ΖS₯ΘT₯ΚU¨ΝV©ΟW«ΡX¬ΣYΥ[±Ω]³ά^΅ή_Άή`ΆήbΆήb·ήc·ήΆέο΄Ψκp†‘ΈέοΉήπŒΚηdΈίtΎα²Ϊν}–’ /9>΅άο΄άο¬Ωνm»αW²άW±άW±Ϋ\΄άͺΧμv’Ÿ‘ΚέΪξ™ΠκL­ΪJ«ΩI«ΩI«ΩI«ΩG«ΪEͺΩ/t”Yλ 0v2y”3{—5~š7›88‚Ÿ9…£;‡₯<ˆ§=Š©>Œ«AŽA°C‘³E“΅G•ΆH—ΈJ™»K›½MΏNžΒO‘ΖQ£ΘS₯ΙT¦ΛU¨ΝV¨ΞV«ΡY­ΣYΦZ°Ψ[±Ϊ^΅ή^΅ή_Άή`Άή`·ήb·ή‡ΘεΈήπau*37¨ΛάΉήπΩνd·ήd·ή”Νθβ '*5AG°Υθ΄άο²Ϋξ…ΗεW²έW²άW±ΫW±Ϋ†Ηε©ε5BGDU]©ΤηΪξvΐβJ¬ΪI«ΩI«ΩI«ΩG«ΩEͺΩEͺΩ μ^DS2z•3{—3}™6œ8ž9‚ 9„’:†₯<ˆ§=Š©>‹«?A±B‘³D“΅F•·I—ΈI™ΊK›ΎLΐNžΒO‘ΔP’ΘR₯ΚT¦ΛU¨ΝVͺΠW«ΡW­ΤYΦZ°Ψ[±Ϊ\³ά^΅ή^΅ή_Άή_Άή`ΆήeΈή―Ϊξ΅Ϊμ y’žΉήπΉήπyΐβd·ήd·ήœΡκ«Οΰ  Xku­Σε΄άο΄άο‘ΜηX²έW±άW±ΫW±ΫfΈή§Φμ|š¨y˜¦ΪξΪξR―ΫI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩ*h…_Ϋ -n†3|˜4}š5€8ž9‚ 9„’:…€<‰¨=Š©>Œ¬>A±B’³C“΅E•·G–ΉI™ΊJ›½KΐMŸΒO ΔP’ΖP€ΚS§ΜU¨ΝUͺΠW«X­ΤX―ΦZ°Ψ[±Ϊ\³ά\³ά]΄έ^΅ή^΅ή_΅ή_ΆήŠΙζ·ήπ^r|2<@ΈήπΈήπ˜Οιc·ήb·ήb·ήšΠι±Χι?MSIYa’±Α΄Ϋξ΄άο΄άο‡ΗζX²έW±άW±ΫW±ΫV±Ϋ™Οθ§Ξΰ&05£ΜίΪξ‹ΙηJ¬ΪI«ΩI«ΩI«ΩH«ΪE«Ϊ@ ΜίjGW2{—4}š5œ8ž9ƒ 9„’:†€;ˆ¨=Šͺ>Œ¬>A°B’³C“ΆD•ΈF—ΊH™ΌJ›½KœΏLŸΒO ΔP’ΗQ€ΙQ¦ΝT¨ΞVͺΠV¬X­ΤYΥY°ΩZ²Ϋ\³ά\³ά\³ά\³ά]΄έ^΅ή^΅ήq½αΪξ¦ΙΪ£ΕΥΈήπ°ΪξsΏαa·ήa·ή`ΆήƒΖε΄έο₯ΙΪK[c 0:?l„¦ΙΫ²Ωλ΅άο΄άο°ΪξwΐβX²έW²άW±ΫW±ΫU±Ϋ’Νη±Ϋξ)38i„ΪξΪξZ³έI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩ"TkjΧ .q‹4~š4œ6Ÿ9ƒ 9„£:†₯;‡§=Šͺ>Œ¬>?°B’³C”ΆD•ΈE—ΊH˜»H›ΎKœΏLŸΒM‘ΕO’ΗQ€ΙQ₯ΛR§ΟT©V¬W­ΤX―ΧZ°ΨZ²Ϋ[³ά\³ά\³ά\³ά]΄έ]΄έ]΄έ^΅ή›Ρκ±ΧιBPW[ox·ήπΆέο“Νθ`Άή`Άή`Άή_Άή_΅ή§ΦνΆέπΆέπ΅έο΅άο±Χκ΅άο΅άο΅άο΄άο΅άο΄άο°Ϊξ–ΟθbΆέX²έW±άW±ΫW±ΫU±Ϋ“Νθ―ΩμK]e$.2ΪξΪξ‹ΚηL­ΪI«ΩI«ΩI«ΩG«ΪEͺΩ9ŽΆ Ψuώ1>4~š4œ5€Ÿ7ƒ’:…£:†₯;‡§;‰ͺ=Œ¬>Ž―?±@’³B”ΆD•ΈD—ΊF™ΌHšΎJœΑLžΒM‘ΕO’ΗP€ΙQ₯ΛR¨ΞS©T«ΤWΥX―ΧY±ΩZ±Ϊ[³ά[³ά[³ά\³ά\³ά\³ά\³ά]΄έ~Γδ³άοz” #&¬ΡβΆήπΪξhΊί_Άή_΅ή^΅ή^΅ή^΅ήfΈί—Οι«Ψν΄άο΅άο΅άο΅άο΄άο³άο¬Ψν‘ΣλΚζjΊίY³άW²έW±άW±ΫW±Ϋc·έ–ΞθΨλSgp ”ΉΚΪξ¨ΧνaΆήI«ΩI«ΩI«ΩH«ΩF«ΪD¨ΧJ^ώuΧ#Wk45Ÿ7ƒ’9…€:†₯;ˆ¨;‰©<‹¬?Ž―?±@‘³@’΅C–ΉE—»E™ΌGšΏIœΐKžΓM ΔM‘ΖO€ΚQ¦ΜR§ΞS©ΠT«ΤT­ΦW―ΧY±ΩZ³άZ³ά[³ά[²ΫZ³ά[³ά[³ά\³ά\³ά_΄ά―Ων™ΊΚ "t™ΆέπΆέπΕε]΄έ]΄έ]΄έ]΄έ\³ά\³ά\³ά\³άlΊίvΏα{Αβ|Βγ|ΒγzΑβtΏαm»ΰaΆέY³άX²έW²άW±άW±ΫW±Ϋ}Γγ₯Υλ¨Πβ8EKx—₯ΪξΪξ‡ΘζI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩ4₯Χ4 /u‘5Ÿ6‚‘7…₯:†§;ˆ¨;‰ͺ<‹¬>Ž―?±@‘³A“ΆB”ΈD—»E™½FšΏIœΑJΓL ΖM’ΗN£ΙP¦ΜQ§ΞS©ΠT«T­ΦU―ΩW°ΫZ³άZ³άZ³άZ³ά[²Ϋ[³ά[³ά[³ά\³ά\³ά”Νθ΄Ϋξ7CI.8<«Πβ΅άο’Σλ\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³ά[³άZ³ά[³ά[²Ϋ[²ΫZ³άZ³άZ³άY³έW²έW²άW±ΫZ²ΫΓγ¦Φμ°Ϊ­\s~«ΧλΪξ£ΥμQ―ΫI«ΩI«ΩI«ΩG«ΪEͺΩD¨Φ4‘-84€ž6‚‘6„€8‡§;ˆ¨;Šͺ<‹­=―>±@‘³A“ΆA”ΈC–ΊE™½F›ΐGœΑHžΒKŸΖL’ΗN€ΙO₯ΛQ¨ΟR©ΡT«U¬ΥUΧV°ΪX²έX³άZ³άZ³άZ³ά[³ά[²Ϋ[³ά[³ά[³άr½ΰ΅άο_s}}˜₯΅άο΅άοkΊί\³ά\³ά\³ά\³ά\³ά\³ά\³ά\³άZ³άZ³ά`΄ά€ΔγΛηŒΚζ|ΓγoΌΰfΈίfΊΰr½α…Ζδžκ³Ϋξ¨Ξΰ‘΄Δ;IOEW_ͺΥιΪξΪξ_΅ήI«ΩI«ΩI«ΩI«ΩF«ΪEͺΩ7F‘ Ψ!Qe6ƒ’6„€7†¦:ˆ©<Šͺ<‹­=―=±@’΄A“ΆA•ΈB–ΊD˜½F›ΐGœΒGžΔIŸΖK‘ΗM€ΚO₯ΜP§ΞQ©ΡS«ΣU¬ΥUΧV―ΩW²έW²έX³έZ³άZ³άZ³ά[³ά[³ά[²Ϋ[³άZ³ά³Ϋξ‘ΔΥ @NU΅άο΅άοΛη]³ά\³ά[³ά[³ά[³ά[³ά[³ά[³ά[³ά[²ΫtΎα«Ψν₯Ιې°ΐžΑΡ¬δ³άο³άο±Ωμ§Νί—ΉΙͺ_u1±>΄?’ΆB•ΉB–»C˜½DšΏD›ΑGžΕH ΗI’ΙJ£ΛL₯ΝN¦ΞO©ΡQͺΣR¬ΥS­ΧT°ΪW±ΫW±ΫW±ΫW±ΫW±ΫW²έX³άX²έZ³έZ³άZ³ά`΅έ¨Χμ΄άο°Χκ­ΤζΥη±Ωμ΅άο΄άο‰Θε[³άZ³ά[³άZ³άZ³άZ³άZ³άZ³άZ³άZ³ά€Δδ°Χκ%/3”ΉΚ­ΩνΪξ­Ωξp½αI«ΩI«ΩI«ΩI«ΩH«ΪEͺΩA’Ξ #•Υ "6„₯8ˆ©8‰«:Œ―<ް>²>‘΄?’Ά?”ΈB—»C˜ΎDšΐD›ΒEΔH ΗI’ΙJ£ΛJ₯ΝM§ΠO¨ΡPͺΤR¬ΥSΧS―ΩU±ΫU±ΫW±ΫW±ΫW±ΫW±ΫW²άW²έX²έX³έZ³έY³έhΉί‘Τλ²Ϋξ³άο³άο³άξ°ΪξŠΙζZ³άZ³άZ³άZ³άZ³άZ³άZ³άZ³άZ³άX²έX²έŽΛθ™ΌΜ az…ΪξΪξΪξ§ΦμdΈίI«ΩI«ΩI«ΩI«ΩH«ΪEͺΩD©Ψ.;Υ"μ1=6„₯8Š«9‹;ޱ>²>‘΄?’Ά?”ΈA•ΊB˜ΎDšΐD›ΒEΔF ΖH‘ΘJ€ΛJ₯ΞK§ΠM¨NͺΤP«ΦRΧS―ΩT°ΪT°ΪT±ΫV±ΫW±ΫW±ΫW±ΫW±ΫW±άW²άW²άX²έW²έX²έwΑβ‹ΙζŒΚζƒΖεdΈήY³άY³έY³άY³έY³άX³έX³έX²έW³έW²έW²άW²ά‹Κζ²Ϊν!), 1=Cc{‡€ΞΰΪξΪξ«Ψν‹ΚηS°ΫI«ΩI«ΩI«ΩI«ΩH«ΩFͺΩC¦Τ@Qμ"?χ>N8Ь9‹:°<³=‘΄?“·@”Ή@–»A—½DšΐEœΒEΔFŸΖF ΙH£ΚJ€ΝK§ΠL¨MͺΤN«ΦQ­ΨQΩS°ΪT°ΪT°ΪT°ΪT°ΪU±ΫV±ΫV±ΫW±ΫW±ΫW±ΫW±ΫW±άW±ΫW±άW±άW±άW²άW²άW²έW²άW²άW±άW±άW±άW±άW±ΫW±ΫW±ΫW±ΫW±Ϋq½ΰ²Ϋξ–ΉΙ@OU%)ARYmˆ•°ΐ₯ΞΰΪξΪξΪξ“ΝθgΉίK¬ΪI«ΩI«ΩI«ΩI«ΩH«ΪEͺΩD¨ΧLaχ?aώDV9Œ:°:²<‘Ά>“Έ@”Ή@–»A—½A™ΏDœΒEΕFŸΗF ΙG’ΛI€ΝJ¦ΟL©MͺΥM¬ΧN­ΩQ―ΫR―ΫR―ΫS°ΫT°ΪT°ΪT°ΪT°ΪU±ΫU±ΫV±ΫU±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫW±ΫV±ΫV±ΫY³Ϋκ±Ϋξ­Φι«Τζ¦ΞΰšΏΠŽ°ΐŠ«»²ΓžΔΦ¨ΡδͺΤη¬ΦκΩνΪξΪξΪξ—ΟιaΆήN­ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩH«ΪEͺΩEͺΩ$Yrώa‹ώCT:±:³;‘΅=“Έ?•Ί@–ΌA—½B™ΐBšΒCΕFŸΗG‘ΙG’ΛH€ΝJ₯ΠJ¨LͺΥM¬ΧNΩO―ΫO―ΫP―ΫR―ΪR―ΫS°ΫT°ΪT°ΪT°ΪT°ΪT°ΪU±ΫU±ΫV±ΫV±ΫV±ΫV±ΫW±ΫW±ΫV±ΫV±ΫW±ΫV±ΫV±ΫW±ΫV±ΫV±ΫU±ΫV±ΫU±ΫU±ΫT°ΪT°Ϊ`΅άœΡιΩν°Ϊξ±Ϋξ°Ϊξ°Ϊξ°Ϊξ―Ϊξ―Ϊξ―ΪξΪξ¬Ωξ©Χν₯Υμ†Ηζ]΅έL­ΪJ¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩD¨Χ&`zώ‹Ÿ–½@˜ΏB™ΐB›ΓCœΔDžΗDŸΙG’ΜG€ΞI¦ΠI§J©ΥKͺΦL¬ΩMΪO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫP―ΫQ―ΫR―ΫR―ΫS―ΫS―ΫT°ΫS°ΪS°ΫT°ΪT°ΪT°ΪT°ΪT°ΪT°ΪT°ΪT°ΪT°ΪT°ΪT°ΪS°ΪS°ΫS―ΪS―ΪS―ΫQ―ΪQ―ΫP―ΫP―ΫP―ΫO―ΫO―ΫO―ΫO―ΫNΪM­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪI¬ΩI«ΩI«ΩI«ΩI«ΩI«ΩG«ΪEͺΩ@žΙ9I 6†©<”Ί=•Ό>˜Ώ@™ΑA›ΓCœΕDžΗD ΙE‘ΛE€ΝH¦ΠI§ΣJ©ΥKͺΧK¬ΩL­ΪL­ΪNΪNΪO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫP―ΫP―ΫQ―ΫQ―ΪR―ΪR―ΫR―ΪR―ΪR―ΫS°ΪR―ΫR°ΪS°ΫR―ΪR―ΪR―ΫR―ΪR―ΫQ―ΫR―ΪP―ΫP―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫMΪMΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩH«ΪFͺΩEͺΩ?œΗ "+˜ύ ,oŒ=–½>—Ώ>™ΑA›ΔCΕDžΗD ΙE‘ΜE£ΞF€ΠH§ΣI©ΥK«ΧK¬ΩL­ΪL­ΪL­ΪM­ΪMΪNΪO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫP―ΫP―ΫP―ΫQ―ΫQ―ΪQ―ΪQ―ΫQ―ΫQ―ΪP―ΫQ―ΫP―ΫQ―ΫP―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫMΫMΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪJ¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩH«ΩF«ΪEͺΩ6‡¬ ύ˜„ύ Qf;‘·>™Α?šΓ@ΖCžΗD ΚE‘ΜE£ΞF€ΠG¦G§ΤIͺΧJ¬ΩL­ΪL­ΪL­ΪL­ΪL­ΪM­ΪMΪMΪN―ΫN―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫN―ΫNΫMΪLΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪJ¬ΪI¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩH«ΩG«ΪE«ΪB’Ο%\uύ„Uσ)42|ž?šΓ?œΖAžΘB ΛE’ΝF£ΞF₯ΠG¦ΣG¨ΤH©ΧI«ΩJ¬ΩK­ΪK­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪM­ΪM­ΪNΪNΪOΪNΪO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫO―ΫN―ΫO―ΫO―ΫNΪNΪNΪNΪM­ΪM­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΪG«ΪF«ΪEͺΩ9Ž΅/<σU1γ Rg@œΖ@žΘAŸΚB‘ΜD£ΟF₯ΠG§ΣH¨ΥHͺΧI«ΩI«ΩI«ΩJ¬ΪJ¬ΪK­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪM­ΪL­ΪM­ΪM­ΪMΪMΪMΪM­ΪMΪM­ΪMΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK­ΪK¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΪG«ΪG«ΪEͺΩEͺΩ$Zr γ1Εώ $-5„§?œΖA‘ΝB£ΟD₯E§ΤH¨ΥHͺΧI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩJ¬ΪJ¬ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪJ¬ΪJ¬ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΩG«ΪEͺΩC¦Τ8б$.ώΕ|σDW5ƒ¦B£ΟC€ΡC¦ΣE¨ΦFͺΨI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI¬ΪJ¬ΪJ¬ΪK¬ΪK­ΪK­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪL­ΪK¬ΪK­ΪJ¬ΪJ¬ΪJ¬ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩG«ΪG«ΩEͺΩEͺΩ7‰―J_σ|5Θ@Q6‡«C¦ΤD§ΥD©ΨFͺΩG«ΪH«ΪH«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩJ¬ΪJ¬ΪJ¬ΪJ¬ΩK¬ΪK¬ΪK¬ΪK¬ΪK¬ΪK¬ΪK¬ΪK¬ΩK¬ΪJ¬ΩJ¬ΩJ¬ΩJ«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΪH«ΪG«ΪF«ΪEͺΩEͺΩ8‹±CUΘ5cώ3@7Š―D©ΨEͺΩEͺΩFͺΩF«ΪG«ΪH«ΪH«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΪG«ΪFͺΩEͺΩEͺΩ9΄6E ώc±ύ+73~ A‘ΝC§ΥEͺΩE«ΪF«ΪF«ΪG«ΪH«ΪI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΪG«ΪF«ΪEͺΩC§Υ@ Μ2}Ÿ+7ύ±HΚό  Pf1zœ=–ΐEͺΩEͺΩEͺΩE«ΪE«ΪG«ΪG«ΩH«ΩH«ΩH«ΩH«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩI«ΩH«ΩI«ΩH«ΩH«ΪG«ΩG«ΪF«ΪF«ΪF«ΪEͺΩ=—Α2|ž Pf  όΚHKΎ (aΛ pHYs  šœ IDATxΪνyœU΅ΗΏ·ͺχξΩg23™d²ο; I€@€„} ΘSq}‚ψ@TΔ'(β‚n’²‚’’PvϋNX&ϋ:Yf²MfŸžή»ξϋ£ͺ««ͺ«g&1A|ϋω4dΊΊ««κμΏsξ9παϊp}Έ>\§KόΏΏιΐρΐ\`P ”ΐ¨Ζ3@H  Ψ lήžVΘΜλDœœLB‡ΰχbΐZΰ)ΰΐVƒYβ}ΘοΣL|‚λH€η€Χ€]–C³<ΐΗ uάa¨lωzeλzΦΈNΟ‡ΰΰ¬2ΰ{ΐgπώ^σ° ?c†‡h¬ 1²6Θ°r?•₯^Κ"^ΒAUΙJϊγz’i:zΣμλNΪgϋξΝ;ϋΩםΪίk–@?π;ΰ;@Ο‡ °Λg¨υgφa―Gΰσ(”FΌΜWΚ‚iΜ™XΞΔΖ^ςO_L:£±qG”w7vσΦΪnVmι‘»/M*£‘Ξ ΙτΈΪ0© ψςK―φ½ψ} c†‡˜0"ΒάΙε̟VΙΈ†°ΛνI@ Δ`·+\ό8‰” Hi?Άeg?oιδνυέll‰²uWŒTZμώž~ όυPG=ΔΔp“πؐ»Οd4_:g΅UƒΨ„ͺ;z†—―_wΩEA(!Σϋ·[Μ‘θŽbΞaΜΏρνLeΎγ•σ1„‹#’ϋ}6-\ΕΈ·ͺ2/KFiΨΓ«+;έΡ³?ϋο€TΰWFxc3ΜAΏΒχ/šΚœI'OE(Jώo‹ΚWUέP₯€ˆ9GO ΈΒΥ_6Ή/°Cςώ‘ΪΐN‘Τπ€ξ,JΙͺ-=\ψ½·ιgέ0ƒΫK ι­ΐΖΕΪ¨2²6Θ­WΞaτπ0a8xŠ)QVI (ŠŠb‘8› %/iΔΟIšb€CΊf‘9ιC€!₯8υ˜Μ}D1nD ?μ>I­P„1sGj+ύœ»€ΧWw²―+εΌ™y艱η&^p°ΐc„1_q8jVί»h*%!/B*]( tΙGθΞξs„ΧΡ>ΫqE ‘πΒP8Šεί†ΩΘ1Α8¦σ(ς*Ω|ί|aβ:)AΈšŒB^·q―AΏΒI kΩΩgskΏσ _jΉιQ~ΐLΐ]ΐ§ožΎ¨ŽOŸ1š`@5ΤΊ@:θκ=§ξuβηΎΌχ― ¨†9p!Ί)­SŠ„Š9~ξ~t|Φ0Π‘:Τ`_ Bυ€ §hΩZ:F6ΩC&ήN6Ρi‰άEέhΔYnωΫξόϋv·KΊψ̍nDΟβΩΞyΞ’>yκ(fl―˜ͺ]σ@Uuο_1ώVI5ε˜"OxC5 χ@ρhA8ˆ)Šό[ΪώΞLρF(±O Π.Βa–RΛ"΅ ©ήνΔ;Χ‘Mtυ rL i’{ŸΨΑuwotγ؟W|PLΐg€;ŸτΗ–Žΰγ§4šΆ='υ δm½EUQL_€Όo:Φ¨@΅¨v4£§ηžσ=›η]=Έͺsαϊ!BυPΦxήP ™ŒF:“%O°w_Ϋ[χΊk]έQ4M’( šΤ™HQ=(ͺO¨š`ΥΌ‘αd“=h™€Ε%΅ψB „dφ„r|…ΧVw:9ψ`πήΏZ,FOk¬ožyL=Ÿ:}TΨQCβ°£ͺN©‘*¨BΡ΅‚Ν¨:#(JΑ₯Ϋm―ώ±ΓVy•…bζ\ήΘpJcSs+?Ήυ~^yc»φt59“ǏdφŒq,š?i“G3Ί±ŽHΠόLͺo'ρφΥ€ϋZM€Θjά€Μ’i’ώ°‘»Ωαό‰zύ…Œήͺ¬o=§š‹Ξ‹Ο§ ŠρU §ΫušοE ¨E(¨ͺ‚š{/η'‘ζMGήαV\ ž'pžόή²QxΥΊΙΘΔIφnGKχ~¦DJt‡Mζ™!T7ω$δ2V―ΫΊXxY„Ή3'°τ˜ΓψψΩΗQ Qb–Tο6’»^GKΗ\PF]Σ|ϋΆ΅<ψόnηi;€ω@σϋΝ  ½;Ο aΎρ4<°U1b|ΕΖͺjuφ ι7΅::(r~€AδΥ»#Ϊ²α Q|eDŽ@υW˜Μ#΄,‰ΔΪגΞpύ|Z{Βϋ‘―νΠƒσz=TU”πߟ?‹‹?}F>ΨΟ$ιΫ±œTίNWM ₯δs?|‡W £΅ΐ¬ ΰfΰTλ%^ϋςtΌΕ‚ξια“B—Υœ*·Ωλ±…Γ†+XA>+hη]EXΓ,Εt …β§tτR<ώr2™,©t†lVΣ™Lυβ Υ€PΘΔΪ–‰\(κPY;žίέχΨ3€¦iDϋ<ϋ{<σβ;yψ4*Κ#(ͺ‡@ωx΄t?™xGO’₯σ‡ρΜ[ϋθκM[OYT½_ pπ#,₯Οα€Κ•Ÿ™DeΉί΄υv8#μΛΗφ&QUΛηrΜ"[Ό―8 ^3κΟ™„œ3θΘ«§α/ɚ ΫωκwnεΛWώ‚Ϋο}„h‚Ω3&πϋP}ύ»‘Ωδ€N @6ΥK¨r<“'ŽcΥΊfό~/υ΅•ŒYΛθΖzF¨₯‘šΚŠ‚A?Ωl–TͺΈ`ξήΫΙύ‘ 5L?‘(ψJG"3 2ρvθ0ϊτ±₯,_Ρζ,2™a8„›΅ ˆ+Π«vuιSŸϋΘhŽ>¬&―Ξ xΧ΄ε†Ϊ·9€ŠbΖφ9Ώ@˜οεRΒ9?!oB“'L†0UΊΫ+(Άχ%—TΜ’0Υ >ŠέRΨŸ#V¨fͺΏœ[ξXΖ¦ζΦ‚›Ιd³΄΅wsΖ‰Gβχyρ*Ivo’Μ’Mt’κΫE:Ί‡l² -Cj)d6‰–κ%ΫK’k ρφ5ψTϊ†F>zΪ±Μ?l ΟΏςύ±DΑΩ›Φ4 ϊ9|ΞDΓ$»6!΅ŒSΟqΒZξ¦ΥYO°½πfΧΑfπ΄ΑeζϊόGΗ0ͺ>l!ͺbβφˆœΧ―δ>0ΒBΕ8n|>§5 ΐP ιG¨ (v4PκRY€•pύ|ΆlΫΓoοω;]έ}7Υ²³Ι™>e,ͺ7„Μ&Θ&ΊlZ«ΰ‘ω+π«πͺπ+π*Pύe(ήŠβCJ )³f8*€$o'ΡΉ©₯7~"ŸύΔ™lάκʘ/ΌΆ’ΩΣΗ1alBυ‘x€z·ψBΐΜρe,{Ύ€ΦΗΏJΎ`ΰkΐωVΡ;fN5'Y―«f,^ΏΪ)h£˜Ύ3(¦S(„b0%ή·xόjŽΦ,†δ»@Ωhό₯#yωΥάuίΐΞρΖ--œχΡ%ψύ^Γ΄€ΜΪ2BOΘπω«§¨Ώ΄_ΙH|%#ρ—ŽΔ_6 _Ι|‘T9Z&ΜΪ‘ΎLΌƒTt'Αpρ‘“H§3Όωξ†στΤσ+8γ€#¨¬(Aυ•’ξί‹–Ž&u•~ϊbš6ΩvŸU~ΐ«‹FΨsM^υ{ψΒΩc ψU3»‡PΝPΟζσαŸb“,Κ_X<}£.@(HΓ(†έW+ς'\‹7‚53Ȋ7ίώkΦ Ψttυβχ{Y΄pBρξίmΛ ‚ lτ xΓ΅(ͺΟƒt:ƒͺζP(oo° ΩΤ`%ιX;BζUΈΜ¦HυnΕ¬δΨΕ‹θμξ㝕vg/•Ξπφʍ|κc'θѐβ!Υ·Γ’sfΑICu€—š:θ‰f¬V’} ϋ€ϋ‡RE) §o²υΝ“ͺ#ςsήΟωΠ…I;Ł―κeυβ₯Μ£zŠ)43¬hRΈ”rPPUx‚Uττφ³μΡ‡Δα?Ύιμάݎ Ύ’‘¨Α*[ΪΨ_1Ε_B[{_ϋΞ―˜ΌπTŒ?aS>BΕψΣhœ}g}κ*ξώσ΄wτΙjΕ‹Ώ€‘Šρgβ-e†ΓΉGίΧϊ"ιh ΧηΏX8wJΑ5­Z·{ξω8T_©#Τ5θΈΞXTb§ζ$ƒnβŸΥeFš·ZΨqϊβB ψ8KΉ5ο δσ G1νΉ μF>@Q οŸ<6Ϋ€Υδ5M.§ο/‹―€_ίυΟΏ2τšΚ΅·sώG— B¨€£»M]¨œLV„ψκ·oα{ΊΐK₯2loΩÓϾɯ~χ ϋΪ»¨―­bXMBQπ—Ž™Ψ^›l₯zwΰ γ¬3Nδ}†X–|€΅²G(ψΛΗ’x‚ά|ϋίθν‹νWŒ›ΝjόφχΣήΡeΰώ‡[pϊŠͺ2ΌΎzΘηλΖΈρWζ³—ώ˜dR'šΏl šΆ°5›μ!Ω³•£Ξΰ€γ·‡ͺ™,/ΛddΕψ‚Θ$gΟ9ΎQu‘ι·Ώ πqgΨ·tΑ0K2’)ΛWO‹|ŽMζΚ]JͺŒ(NJΠrL!-τ5J‹Κ·ίRͺ™E*•αΧw=t@χo―εΡ§_GJ‰κ ¬šŒμΩF8ΰα›—}’’HEQπxTσ₯;‚ξλΙηήβcŸΏ†\yZ ržΰ0›'οX‹ί+8¬Β]ρ=σ&1ΓδΚΗεao8€ώŸ˜ΰόz•AΗb[ΕI$¨²hNMžη iΝmΛcς†3(œ9-ύ'5σ»Φ:a {XBΪJΌΜ}BXήWL/=4lB¨όζχΡΡuΰM9~vλŸΨΧoΚ TNFρ†ΡRέ$»Φsϊy¬X~'έϋ#ή~φw4Ώ}?«^Ί‡ξϊζ,&ŒαzΞ_kβ+Wί¬?5€Ώr’ξεn9›&έΙάY™:iTA”²aΛNƒ(5|gX㨙UŒŽCυŽΎnMψœrτpΖ4Dς ŽβσZ*{νU>Š ζ́FŠT[>‹ώ[ΉέBΉ0Qΰ(¨JΒΓf³―½—koψ»χv0ττφ£IΙΕσŒΜG:Ί‹l’“L|!ŸΖΘΊΒΎJΊΏGγπ N\z49υΒ‘ ―½΅¦ Ά_΅Ά™‰cG0eβ(< ’}­ΘlΒ¬Ν¦£ kœΓλ+Φ²v£½°Ί²”cšR’ŠξBΛτ;ΚΠt@LU “•΍&Υθ…€-ϋ£ΞΓ²‘ΓοS˜=©,/ Ba›!ύ—ΰΣ XΒ<ƒιen§y_@Z4…΄ϋΒ*υ“π&μ[=„Κߟx™¦5›ι2§_έω+Χκηρ•4ΰ U#„ ›θ"ΡΉXΫ»ΔφΎKΌc‰Ž τοy‹ξ-Pͺ΄ρ?_ωΊν;iΰΫο}„Ξ>S»€bFΘΩD7RΛ0gΖ<»lΎρξz3Ϋ©ϊJpί ‘ί,˜VI]•}/ŽAΟ!›€`‘υYΛ ½¦ΚΦάΠ©»k«#(σNš³\ΗΟZJlx‚Γ›ΔΊ$w_Iήp=]}άxΛ}C&ς˜1c8ϋ쳩¨¨p=ώίWώά€0ΌψΛ';—rh€DiΐΠF•―–%ήΎŠώ½osβqσωε.sIψl`E“NΜ@ωXS}+Ζ#Ι$:™6y4>―½ΙؚuΫL§[ρ„¬’Ν H`bc„ιcK?}’ΘŒ¦X!`ΚΨRTUδm½Μ§^₯‘)HΛο[Ίœ3(-—c»σθΐ<­η$_π‘xόk¦ Χύβφξ³WΛTWW3uκTN>ωdΎρoπΗ?ώ‘•+W"₯€ΉΉ™Ώύνo\~ωεaΥΊ-άύηΗ‘Ό%#π„†Ω@§Όobgdw3Ιξ͜Ρ%,>r–νœιt†η^z—t:£—©ELM&€l’‹Ζ†Z<ͺ]τYŠO„βqŠXCB=!·xN5^ν9Ž3θ:((θ Μx’¦άO}Mΐ]δ]A97f°7jΚE‹fΘ's&ςΜ‘―Sμh"9F”„jCυ–πΔςΧΉσΨΒοχsν΅Χ²fΝόqΏώz.Έΰf̘aϋά·Ύυ-&NœθήqΟίΩΫցΒuσ¨ψY!ί±$ΟρŽux=π©σN.8οK――$‘L"A―H²(4-§²’‘x}Ρ8Ž΄g‘β%ΑωΓϊUg2ο8'Νέ €ήnΥ\u5*Λό&½σRo…"μš@Z€έ΄αι·Ηφ<·έηŠρ}Ε”Ί@εd|‘μkοζkίΉ₯ΰFJKK™;wξΰ)QUεŽ;ξ(ͺ–=ς𦑍BΓfΧ ΄• d6E:ήΑ€ρŒnΧΌk6l3qΕW’γYz½Χ,€fv•6ˆέζzΟ§²ΤΗa“Κέ*Ήƒ1@ΠH'κGŒ±ψv²πkŽΊji±IΒdŠ|ΚV˜a^Ξζ–s+Β b j°š`Υt€”\uέmμά½―°t)aμΨ±CςŽ:κ(Ξ;ΟΥOβ†_ώφŽ^ȍΗ_VLΩ¬Uα‹wRS]AMu…‹4Ητ»›£xƒΔγIΧ]C~ΏΧωΐ,μ:kρpη[sΠ›mΘΣ¬\βρ&4FL©GΪϋιIaι—jd„ υ+”f§#X QœΞ‘=rίQ}a"uσ@(όυαgyπQχ²ψͺͺ*jjj†ΔŠ’pυΥWΔΠtυτρßίmlυ¨šl  jFα” ΏΟ‹ίη-8g,‘ΔΩ›P O ’Ž^‹΄[T³ίo8›ιAΜ€~lΙΌ·šŽ9ƒ1ΐ60ΉΔKeΉ―tΘKŠ\:GUY΄9£D3Ij…Ÿ3ωθΑΊy[©Ÿ‡κ+aέΖm\}έν†CUΈœΆ~°5yςd>ύιO»»ϋOςΚ+A€7\'Tc„¦Jδhγ ֐L₯]σΏΧ½κ&TΓΆ–=d2v^[•vι~‡°3_Ξ―ςyN/Π>gΖ6ϋ?Ύ±€ΐΦI·0Ν(Θ“‰Ά:tH{¬o7–fυρ“yo»tδQψΒ΅$Ri>ώ…khk/ή‹οˆ#ŽΨ/πx<\rΙ%E΅ΖW―ΎIΏME%P9Ι°ΏE˜@ρ’*ΨΧήC{{a«Ÿ²έ¬fSQσIxB΅€`ΥΪfRi;Σ̜6ΞxΜΩTo‘Δ‹BGΰψB-pς` 0ΣΞαΌcηά)‹ΧΟJ„εΪ½}] s ½}›΄sEŒ\Ϊ°_Iš”œvώΧΩΦ²g@‚Ξ›7Ουύ»ξΊ‹={άΏ;eΚ”’Z`σΦVnΉύ―zψͺΑ+ͺώƒU“ЇMΝ-΄μj³›8n$>Γ,δKΏ ΗgεF²Y»8jΑ΄f¬o,-`χ=GΞ¨r~tά@ p˜σΣ£‡G( ‹΄;<Bδ D„΄Δξ2Ai˜†ΌΟ -ηΛoρΆϊ•R(” ŸK°lΙd’Kψ ο4mΤ¦Ο™3Ηέ³_΅Š‹.Ί¨θw―»ξ:W-ΙdΉηώΗΩΡͺητC΅sΑˆΙ­Ξͺ7TƒΏ|<‰dšϋxΊcŸ?@@g½ζ@/7σE†³©Ή•υ› φ²δθΓ †ι΄<«Α«ϊk*ό”„ :ΧVŒζΩΥ”ΧΡm»H+V™΅Q’&μ›‹γ^¬Sκ f*o˜O¨r<ψώOξζ/.τΖ§M›VτΨϊυλyψα‡yώωη‹š‚_ϊΧΗ6nήΑ}<₯οώU½„†Ν1 "QŒ”τ8„βε/.ηιηί*PΝKŽ™‹Οη%έ₯;tU:”ύϊŠ΅4o·ο›0Ά†ϊj@°U-μ ‘wQŸΨX؟kH aX°0Τ³xμΊΟήэ3ηΛ›Β‘Ό°ϊ φr©<ά[=n ‘ŠΡ€ΰŠknα7w=8${Ύxρβ"9,­­zAΖ΅Χ^K&γξ@~τ£ε”SNq=φ‹_‰{φ_Dwσφβ Υ²aσΎ~M!61gζŽš? Ύo•ΞpFύ`"™ζN—֏œ²ˆPΠh${Ά1Hόι`fΑΨ‚.κy?ΟΙ68¬:ΰ‚νS“Ϋ%Φn'œΞ’ΈΆ[ΛΥδ˜Jυ†©Ÿr&ώp ψβWΜέχ=:d‡nΡ’EοοΨ±ƒΎ>=σζ›oςΰƒ5!ίώφ·ρϋύΗβ‰$ίΌφV– TL4%N’·°ωΣ²gΘdμύœ|>/žw •₯€’;Ι¦zŠEΥΟÏΏLΣj{"«΄$ΜqGΝFUUβkόΒxRJ<Šp+™^Œl…ŸΓ*6‚jRΈ«v‚ητάω‘› V4R;ρ$<Ύ]=|ώςΰ#ϋ·ώΘ#t}ϋφνD£zyu,γξ»ο¦·Χ΅c'sηΞεόσΟw=φΘ“/σΜ‹+'Tƒ7RŸΣ2 κj* ΎsςρσΉπό“‘Ω ‰Žυ 5‚53ρ†jhΩΥΖ—Ώω‹ΒHζπi6s"RjΔ;6Ψβ'gO«“.f½f}U,ΖΆ€Ώ²ΜGa;#E°(¨Sά‘ξZD"ρϊK©½ˆκΖ#Q=AΦnάΖ}εΗ<ςδ+ϋEό’’\΅΄΄˜ΰ±Ηγ•WάΟοσωΈψβ‹©¬¬t=ώkn&Ϊ7ͺ|&λ₯oZ–To œs"Η.Κ[Τ³N=šίώμΊοΪH&ގꋬœHOo” /Ήΐσχz=\tαψύ^λϊΔβΆ_i•[ρ:C^7n= ƒj‘lŸ;‚ηβ.˜†@Ϊϋo›²rοUŸΝπ)§ƒP<άΠ3œχΩ«tπe?Χόωσ‹ΫΎ};Ι€½HφώηŠ~~Α‚E!βζm;Ήωφϋu-ΰ/Η_:! Ρ³…°?Νέ·\Ε ψ/=z+·\5‚?©Ύβνktϋ‘—€Ώ»ͺpSοœz4‹œ–‰“θΩΖΰ= έ!βpΠC8ΰq"‚Ý ²²–ί§δwαΊ©uό?'Ιω”–„E;XΓ»@€†Q3Ο’²aŠΗO_4ΖΧΎ‰―^uνΦ+ω°ΓsM¦€Σivμ( ±šššΈνΆΫŠžοΖo$‰Έ€δώŸaΓ&½z'4lBρ"3IϊΆ/'ήΌ‰uL›Ψ@@‰ΡΧϊΡ]―YΊ©ττυ³rmasΪš nΉώ2@’μΩJ6Ρ1κ[D3θO;P  ŠΏBN(ΑR"πιTŒί IDATέ:œŒUˆΊ„Ή–κšϋΕJ ώH5υc’qΪiψ‚εττFωΗ/sό™—π—Ÿ€ϋφΐkξάΉ FΩΆΝέ‹ΎβŠ+ 4Cn…ΓanΎωfw-°}ZφY-k`‡™&ΡΉžž­OΠ³ετξx–t^{D’κ! PQ^bχ~ώzη΅x<*™x'±Ά&W¨·θξΐοUπϋ ΜΓΨA@)΄4)ΖΩ.Η¨θ58Gυ¨{SN ¬FGΎžzφuΎτυΈδŠΩσOΤς™wV$FΩ΄i“ιεWTT0fΜζΜ™ΓάΉs‹β^x! .t=vλο`γ½άΞ©Ηg(­2ύ{!ες‹ΞeΑaS‡L7’[oΈœSΗ"³)ϊZ_™΅‘o’˜Ϊ/©|ͺ“F_'O1ΰρ ³όNRΨeK’CυŒ”Ν1ΤCBE±σJεπiԎž‡Η£wiΩΉ—οίp―ΌΉ’DβΰLS=ztΡ/UU9ε”S¨――gδΘ‘ >œ††κλλ‹:zVη»ίύ.§Ÿ~zvL¦ωκΥ7ρψ_~†0 έhΌ ΄t*}­/1gΪράsλUtχτ ‡¨―­DΛΔιΫΎάμ  ΧSH—qbί@˜X€·°l½ΙΆ*O› Χ Β•ξ¦&ε;Z‘JI €šQ“.©!ΘjY~ωΫϋΉυŽΏq°ΧθΡ£)//w=VWWΗOϊS‚Αΰ{ρβŜyζ™,[Ά¬ΰΨ«oβ/-ηΌ³–ΰ Υΰ Χλ›9qΤ΄t”M'\9‘ςΪZ Ilί{$;Φ’?‘εζXΧΕͺ’wguZ6'€)ZΪQ$ξwcaw’ͺGL§qβΡύ±8oΌ½†λ~v7;Iδ  (Κ π₯/}‰gŸ}–ξξBυΫΧέΖΙΗ/€΄4D j²Ρwh(šMχλȏ©Α]Φ.§… ‘ΣˆΪšΝάK— ±§~ ³„RΒ𱇣IΙΛ―7qε5·pΡε?>dΔ½WUέ„%K–°tιRΧcmν]ότV½*Yυ•β―˜Pœ†rP—ήŠΌ ύ/v0ΡΘd LNθΕ‹>™ΦςΔ7k΅#Pϋk†νςπτsopΕ·ΙSΟ½Α‘\‘Pˆ &μχχ’Ι$›6mbωςεάuΧ]|λ[ί’©©θ~JnΊι&ΧΚ!)%χ?΄œΥλτ°.X5Ε*$©CKEΠ₯­/€ά/εΞHηˆ[iΠΫf’ΦS$’Y š›<,˜€ΘK} \F]γL²ι${Φ£e’μήφ'ΏoΊ‡C<)»VχΪpόxœ¦¦&V―^MSSkΦ¬‘­­ώώ~ϊϊϊˆF£$“Ižyζή|σMΧs >œύθG\zι₯Ηvονΰχ~Œλϊ">Ÿ—pέ|ϊZžs%Τ ‘0ω2„/θ'SY©‚9N₯Ql7ΒA<ͺΰςOM"ςκέΌ>ύωήΏzŸ\·οΚac˜:ο s;WΟn65=†ͺ¦Ξ?—·šΆρ_—]wH`̘1455QRRRplλ֭̚5ΛΜ FEQxΰ8묳Šjyσζ±zυjΧγ/>r+³¦GΚ,ύ»ί ΩΫšΧ“R3BcΝr ΦήNδŽ›ΗςΑ΅°„…R³ΦqΎά$’5Ν½\tΓ{΄u&­œqπšΥtai5šΙJ£.MΊΨ’Όα—€?TƘ©‹(tχθ8{€|8•uΠ΄,»·½ΝΡ gqΚ GR6l˜+ρ^yεϊϊϊτPJΚA₯OΣ4Ώώϊ’ιbΏίΟ5Χ\Sτϋ—_u“ρ ώς‰Υ?D'nΐŠ Ϋ¨Ίσ–χ'2τΗ ξc—[2ΘΆ­Ζμ6!ν… VM$„B}γLΑ265·ς•oώŒΦ:β5jβ1E₯{ίV’=»ωΖ₯Ÿ $:d 0Π€wήygΏΟΧΤΤΔCιΫΜ‰ΝΝΝΌψβ‹όωΟζ'?ω >ϊhQ†{»iΏ6j<Α*|‘¬γεά,„έ£’ƒ |Ζ Ε«/–qΆ’Σά|€UXjΖφu%aδ’₯ΤΜFχ€Ÿ?Δπ1zUΜE—_G{G7O.Ο~ςLTEeΤδcΩ±ξYΪZV2jΚ.ϊμΩάψΛ{ Cκ€’φ|0αškαŽ;ξ ££ƒήή^Ί»»ιιι) Ϋr·άΗœΎ˜ΪšJU“ŒF” “xRc δθ»|O ‡“žOwφ¦CΦΠψ`-¬ιH ͺFŽΣ;Zόψηw›Ι›Ϋοyˆ½{;@EΝXΒευτvΆΠί³‹ΣN<’‰γίwX΅j՝sνΪ΅<ω䓬X±‚7ΦΦ6$βtvυrΓΝΤΓBooxXΡόΙ’9#Η’ν—˜Ιjμl+hj΅’6Νe3ϋβΆ”―3BP;r-;Ϋxδ‰—ΝC±X‚oώƒρ!…ΊΖΩ ¨΄lx‘Ϊšr>φΡ₯φ.#ay½ή‚0•J™ψΏΥω{?ΧςV°q‹Žͺώς‰eέ{'-άPCE·ƒΩ¬dλ‚V9›lheΩυ=ϋ–“ ΣΣΜm,―Φ;_=ώΤ+ΔvmρτsoπΒ+οpμ’Ή”TŽ€¬r$½ΫΩΉω>uή©όυΑεlΨΌύ =θ™3g²gΟ6mΪĞ={Ψ΅k›7o¦ΉΉ™΅kΧΊξ΄y?VGW/mν]L7ޱΜE”~λΦY)P‘,Šlj)`ώ7Š1ΐ.φζΐ Ύh’ώ’- ’Κ²Y5λΆ i…πƒοδ˜#η * ΅sθοΩEwΫͺ‡OεϊkΏΜYyΕA{Π[·neΙ’%΄··ΣΡΡA6›εƒ°ΚK#”—›@’½.qΌ]ϊE‰—BαίώAΒ±d–ζ]γηž/Ζ/Ηηώhnνgφα΅ I0TFGg7ϋŠn΄νλδφί?ΔEŸω‚%ΥTΦN’cΧjΪvΌΗ”iKωΨG—rƒΟ”έΩΩIgg砟SU…H8H8$πS^‘ΆŠ†ϊͺ*Λ©(+!ΰχϋLIK§ΔIΊ{ϊΨΧΡM{»> ͺ§7J,ž KΠOrΩΆτΨΓ™6y Z&AΊ―Υ 1‹aφEu| +]ΒC,L€~skΤ ;zc xΨΚ›ΆG™5ΉάΜ‘ͺ‘ŒTΥKOœh4V$––<ψη8eι‘ŒUOύΨωτvl₯·sΡVΎzΙ<ϋβŠόκͺ¬(eΖ”qL?ŠργF0nΜHΖ4Φ3ΌΎ†`ΐΠnΩZ6©'odV€š¦«V³‘ŠπψŠEυ‘i-»φ²½eΫvμfΓζ¬ΫΈΦ]mDΒAŽ[t—}ρ\„€Xϋ*½“ΈCυKι€ζbe< ˜2ΘΏρΒ;νΞƒ,ΔέΰQτΟlί³I½Ν2™LAω³u΅ξjcΩ?žε‹ΞΗλQ9ιXΆzŒ};ήeτΜΣΉδσηpνυwt’ϋ|^Ξ:υΞ8i“Ζ7RS]NeE τAމN²έkθKυ eΖόίάƒu™η'5̎d†¬¨>ͺΤM©ΰΘ9£Pύ'ΡέΫOo_?>―Jέ°*΄lŠθΧIυluœΣZeaΤψH­hμ/dQά<σVΑΆy[¬΄€P₯ξIk΄μξg”±EΜμ$™L―§―Ο3 1ξΈηaΞ=k)#j •ΦRV=†ώΞ­τ΅7sΚ#XφΘσ¬:ΖΑεŸΟY§C0θGACΣRdbντν\kk՚λυ“AYΜ>Λ|g$©εKd²Ω8Ω”ήδ1GLO¨–j9ˆξΪd΄v•EΞ/ΰC°‹ £”š3κΜκ Ό»=AkaψΈ ςvΓ?Π[Šι^dVcKK7°Ώ―ƒ’’0₯‘π „ωώ wθι$E₯Ίa:(^vo~™Š² yΞI]±φg•–„8nΡa<ό‡λy{ωοψΟ³—Pγ€Ί7Σ·σz·>IlοΫdϊwc­qΚaν:&―ΩΥ«ΧηξUΨΌ@3Ϊ]J§«λtλHt¬Νχυ-?ΫA‘δ SυλΉ} Ν&ύz(ZF:±x‡τ[Ώξhšί<Έ•έΆœEϊ€ο½Ca€\bθ+93ΡΣ—fϊψ2"!―eΌ«0beX²δDήX±š½mƒ1}Ρͺ’²θˆY(Š XRCχލ€γ=Ԍ˜JYi Ο8φΤ/Z8‹«Ύϊ)Ότ”—…‰ξ]MϞwHGwγρE(©ŸCΈf*ώ‘¨ή ŠΔ¬!›κGKu “r₯ΦΒ5 8w8XwBΫΤ΅,t؊ΧX‰_θψε[οΉ™9€τη‹Aή^ΧΕݏξ k^1Β{m¨ Π|Θm{%™˜6ΎΤΦZ‚x¬›Κš‘±`./ΌςnQ`ΘΜΜ­έΜ ΗΝ§¦Ί―/H&§Ώ»Υγeς΄ΩΌωΞZvοigXu?ώξ—ψΚ?Ζ΄Ιcˆχ΄ΠΎe9ΙΎ™‘΄nε#ΐ¬DQ½$S)|δ*ΛK)‰„πFκˆwnp1ƒ'Q¨¦uΨω`“V;ρ΅΄O'~ΗΟ©•€t8“₯]ώw*£ρϋΗvπž}ˆ”όΚ‰ Ζ Χ|Ξκ Ξ›VnŠFoMˏv0nβlfϜΜ+―5‹œJ~wΥF>~Ξ‰(Bΰυ‡‰vn§Ώ»…ΊΖ™”••2q|#·ύό›L8•Ϋ^₯wοJ@#FΝψ”Ž@‚X"ɍ7ίΗ§/ω!ΛyΪa•>g²>€Κ&έYDΈU Pv'άe¬”E=²@Zm©[ΙWa‰­k5Ž‹νoοNrυoΦ’ΙΪ>3θΫ_hΎˆQ'‹g˜:Μ “›θJƈE;˜0i:‹ΝgεšΝtuχMevtφ0¬Ί‚ιSΗαυ…I'£$£ν$γΜž³€#ζΟBKχΣΧΎΆζΙ&{πGj¨¨?ŒŠsŠΎh?/ƒσ?w ΟΌ°‚”Ρ. |μ¬γ)- £ϊJΘΔΪΠ21k©R¬ρBρΈ\η»ν©*$ΎVΰψIηΎ€₯#RΡΧmnευ5M€ώΈVα }Ω‚>/Pχ$ϊLSJ$δ1Ηΐκσ€’ρnz;[>|ηŸ{&αpXlLxr“<}Ξ―"τύhu³¨1_ lτΫΗ2DŸ.žΙτ³sγsdέ¨–QrΑH£fœeξˆύΩ­βζΫ(ΪΤΉF¬γέηοD Θ$ΊθkyΞ1qk?œΒ ο&υ'ΠVεc'~Ξ§(€˜ͺί‘ r“ΔVmια“ί]α)zkΨχŠ¦Η‡ψ6ŸFŸLM*­Γ““Ζ”θc\τΉωr3ƒ… Φ·φ]«ιΪ³Xο.b½»‰w·ί³ƒhG3ν;W²oϋ;h™„M“εuS –ΤςςkMœϋ™«yκΉ·φ«²§―?†ίλcαΌi(ž Z&CΓb(4—.θ?Ÿά‘v¬aβ‹!Ÿμ~&«ργίod}‘τ? όKΉ2@ΒΠ5'εήΨ΅/ΑΔΖ0ε₯>Σ δηsΐsnŽ Τ2€½$’νΔc€ϊ;HΖτ‰ΫΆAΝB ŒΡ°RΛǏ~qk •AeWJvμlγΔcηQ^Α%Ω݌̦]r–—Γ£8žΟ%žΎςΨ[θHΟC 'Ύ›γ—{ο₯χΪΉυ­ΞΈ?mΰ8zFϋS™ωk#*0ןŸh5†ΌHΛώΑ¬ΝW‘Faˆν™iš”k©Ω€©ιaQ²Ώd_ _ς„‚J5oίΕφ ΩLPΥΟwΝ ”#(”t;FΰTωΝΈG+ρ΅βK7βn ψΙT–›ΪL2] Ÿ@―ξpνO6cΨ’ M΅ˆ'²LΙOƒόˆΧV „eΈ§1]L‘ϊT1ςcεBc–$ϋΫ7e!Ρώ+ή]@LπϊΫkΉΰœ₯”•FPΌ!2‰n΄Tί~Cφ€~/…yNͺ5W„O:ςZ!–ΰ ;;‰Ÿ?ϋݏlηΑv;/:m„οΝ“rΑb`Œ ΄%h¬ Q]α7EXmΉ9BΦxΟ4ΖΘis¦°~\ΙΝ 6>'΅4BN:ιdώΌμΧ<ΑPLΑΆ»9ηŒcυΘΕ Ω·έP΅CiΎ`t)„tΝ+δΣεξ*ߊ 7Γ™ΩΣ z{]_ϋ₯λήΔϋ°TuLψ3peξ»Rκϋ¦Ž+%θS,3‚₯eζ1ΛlA°ΑΙBΨ5Ζ$-!ΩT”Pω&NΗCΎt@Z`σ֝L›4šIγQ½a²Ιn²Ι[Mž”N„pΩ<¦χξNx«Γ˜¬KΚW“|'ά›Χ{:|ω'MΦ‘ρV(@;T φ`,λۏ§Œ-1ENΒζ- †KλγΧGŽA§„Ξe}Lϊ€YlΫΎΗlΘ΄Ώλν•ωΔ9KρϋΌH-CΊ—-ξJnaρΪ₯kΛ|Y«w&x€”>ν½<ρSι,ΧάΎŽλ]§£ž ¬κ39Π:¬χΠη ™¦`g[‚κ2 Γ‚ˆ€© ωΡς˜~B~Κ¨0\R!½Y‘@Η “’Š΅S^;ΊΊ:ž~ξ­A'x»­X<ΙΘα5̞>™M‘μέ>2˜—ήœWO΄—z{˜ηB|W)/υζΟύ—§[Ήσ ΨνΐΟُ>2Ί?KΎ€c7ρŸžheν–n»-­ »}3iΕK,Ι¨ΖRCΘ,ν[_fΑά©Ά‡μΕf²lέa­ ΜΧψΩ^2kόf.†—HM’Iž=–œ½Kώ_j†δfέ%ί‘ΰ‘šζBό¬ν™ΌάΤΑξqμΦϋ΅κŸΩ ·ΈK[€?<ΦΚ潆Σ₯E3`Pg†KΚά1»¬I{˜˜{Θ©Ύέτ΅­γΛ_8›Κς’ύΎ`EΤT•λΏ›Žι΅ š,xiΖuε^ωZ ic άZ˜Kέ [©υž5Kν‘[6ΠκXfm Ρ΄©›―ύr•³έ θe^ί7h²_λŸν¦΄ ¨E4iH™dλΞ~&)ΡF –(ΐj,сRάA4ΛΟ ³Žν£qάT―_mΪ―‹-‰„ψΩχΏD$$ΦΎšl²{υ/ŠΤΈDn. = n(¦,+ΐαG4οŒς…λή₯³Χ­χ 7?=ŒvZO `–υΖYvμŽ1kb™^;@)Š₯ž+sH=τΛy9§,‡δ˜CΛYŽ9v)βsσ 7ͺ*,»λ{LžΠH&ΦFΌ}5ΘΜ Δ—=Ÿ%Ά‘9.%ešζΎ'°ΰδ”όένqΎςσUlΫγ Έ˜ύλwP@Lp–αΔέΡ4[wφ3eT  jj€ά$Ήœ—o9os­ " ™T”`ιpfϜƟ–=;ΰΕό>N8v.ίϋ&OI:ήNlοΫωΜ`ά`ΰΜ|,_d†š%›W\κ₯Ka‡@+ ώ•ΏZΓ»]=ώWOβRθρ~2@Ξ½ˆ^IlΆΥξκM³qG”ρ#Γ”†}ΆΠPΙVwύσΪΐό φΤddΜ„YμέΧΕͺu[]/jρ‘3ωξŸβ›—]@Θ―Ϋϋ±ΆwΙ&{φ\:<~=c, ˆγVHZ€rXΐR―0ήΆ]Q.ϋωͺbΔί\€ήΨ‹5΄Mθu„ζˆŠήώ kš{˜ά‘4β5M@ޠАvm`Qύ¦δγd HΗ: •5°ΰπΉttφ²fΓ6σB&ŒmΰΗWž―^t3§Ž#Ω³•hΛs€’;mι`g P~­•!΄ nΣšζ’-Чsνφή~lεζ>έ{lΫν*ܝ$ϊ?K4ΑΑ_'’o0΅m UΎrΑ8ƍ,Υ[Λ)U½@‘’(Η0A%UUl•ΘΦc΅“NΕͺ₯­½›[ZV]ΞΔq#ϊ=h™~ϊw½FΊ· δ+\œ½b* SQ\ΪRχvΩ"ΎΎΊ“―ή΄ͺ˜Γ—Nž:Δ: °ΠΘD٘@ψδi#9ζ°ό>Υθ3hDŠΎoP'¬’-„ήŸPθ} !PΤ|ΑHa¨|%5γρω‚EAΛ¦IG[Ivm<ˆ·˜+uτO—ΎhΤΰ‚f³<·‹ή½DJ+Fόc†δj½vΰ~ Τyΰ¨Ω•όη)”„½xr5ˆ©(UQM41Η:3ΪA(†ΦΘϊTΌ^―YG`»EΧΖb莳”ΈUχϋl±–ΊΒ‚i8™’³'ɏξΩΘί_*ΪGΉ ψ8πδΑ$’z`‹λd‚–=qή^ΧŌρ%”†Ό&/,‘†“(μE‹?`υT!πyU!‹lΖ€ˆΣηVχWμΨ Dw$k\GŽTnN;¬iξεβšx}uΡ9Θ;€Οlβj½”l9°Λ“\ιι7φ©Œi­h…£>@’ GΞ@{ͺ]sψ=–ωΉBpȗ͢ˁ“GΈk…D2ΓΟνββšθκKΝa‘WeΏr(nC=τOнΐ2`<0Εy°icΆχRS§«~ς‡|€—~ϋΞ$ΏWΑ£*68ΩΦPQ8ΥώPΔ­‹U}ήςUQχš¦ρζΪNΎ{ϋzξ}’u “,3ˆΏυPη}syΠ‹>g s+P™?­‚³ΈΪŠΊ`qφ‘7q6}γο°Η„‹Ύ[{GŠyrhQΐώΨψœΊί±'Ζ―—mει7ΫˆΖ‹ΆΦIΏ.c€‚Ξ7Θ­/ίĘZε\‘ Κ’ω5|β”‘όžF°2„ͺθΆΏ$θuj}θν@AλΦb„'3άτ—-όυΩ]τΗ³ƒωNΧ£§vωϊW<5€©ΐU’E1Fψδ©#8nή0ΒA^jhKd @iΘƒΟ£XΖ¨ iΝ ζ€ΉEƒ™¬FO4Νc―ξαζϋ›ιιT˜ο~¬}Ώρ―b€œIψ8pŽ‘΅ΦUYκε”#j9|Z9G•θσ 9ͺ*¨*ρ:΄ΌΣΞ ›>ψ#p‹Ωνν_δ -Z“)uΫzyρέx~{:ν-œ>žΨΙΌŸDψW2@n•’Χž€{Χ2ύCa#,˜VΞ±sk¨©Pςς{φΓι·3‡Σ%rΡΐ<Π#ΩΫ‘δ©7χςΒ»¬nξ£»Έgo* τ­[ηc΄oΏΧrλLτ Η Ά(‚ _aκθΞ?a8',fφ'²v‡0΄Vώd²ΛίΪΗύΛw±ΊΉ—x"K:;$Ζy}Οήί•ύƒΔeΐΰΏ iHkΡ¬ N^8ŒωS+)/ρβυ(x=ΒΦΈκ@Β¬θ‚t&K:£‘Jktχ₯Y±Ύ›Η_ΫΛΛMϋ{ΪηΡ 8–=κώAc€ά ΰΡρ™ϋσΕΊ*?SFE˜02Lc]ˆšr?₯‘ ‡HP%πΰσθ؁j™¦©i’TF#;|ξ[*IDAT‘H$²DγϊβYz£iφu'Ω±'Ξ¦–(λ·G½w†ΊV—£χιν <θ*XΧRΰ;ΐ\ ΐ~Φ1zTAIΘC$€ zψό>ՏτHBΣτI©΄Α©,ύρ,}± }±Œ³ΫΖΠC=žψπΜραώ;0@nMG―~9Κ#+> ΧΩe„q― wεXύA~¨N [UΜCΟ8.εύ΄ZYCŸV ΠΑ‡λ3oΘΠ§ΏZ<νw°^-Ζožj\CθίQ ΔAΖ(AopΉ˜ή!#hh Υπ!r“­Σ­νAs՜YγήEOoΏŒ^ŒΩχEŠώ?,0}C•e”’°ζSIτiZ½†—ށžΙlα}FηήΟυΏ"ƒ`Œ¦«εIENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-warning-256x256.png000066400000000000000000001252741524161175200273600ustar00rootroot00000000000000‰PNG  IHDR\r¨f pHYs  šœ IDATxΪμwœ]UΥχΏ{ί~οάι-3Ι€χJPZD|D ς Ύ*v_E…G^TιE)%”€ %$‘CBϋ$™L/·χsξ=gŸrοI€$ΜΞg>™Ή§ήsφZ{­ίZλ·`p ŽΑ18Ηΰƒcp ŽΑ18Ηΰƒcp ŽΑ18Ηΰ烏ΰ Υΐ Q ”?qγ³ΰ7ŽI}@7Π΄?Ζg}@3°Ψ5ψˆΐΰψhG%p 0 Œ|€4ΫgΌ_a|&-Ώ{½w ΘXώΟώiCQdŒίΧΛ€ ΐjΰί@Λΰ«TƒcοG?Uΐ±ΐαΐLCΨχη± x xxΨ $ŒŸδΰ«TƒΓ} U}ͺ!μ‡ζϊ<Ί eππΆa-l1,‡Α1¨>Φcž!δGM†¨:ΘΏσnCl^4”Γ³ƒSap|\Ζ)ΐ]ΐv Σ0‹΅ιOxۍgrΚΰτΣeΖ ΛΗXΠϋϋs‹ρΜʌg88]€j43€OŸ@Gθ?΄ϊ)+ρSυ ϋ)/ ω)‰ψ‰†}„ƒ>‚‰Ο§ΏώtZ#‘ΜΠ›HΣΥ›¦«'EGWŠΆ$=):»S΄vκΘc-°x xΓpΗ ΨoΗ‰ΐηΓ€iθ!·dDB’αC’ ­0ͺ!ΖΈ¦šκ’Δ">EΘ₯H R !τψ ρφ5ΝXz5Œ±g4LF?™ΚΠΣ—¦»7MgwŠMΝ=¬ΪΨΙΪ­]lΪΡΓϊmέτ%2δsΝoK{§§Ϊ ؟Ζ?@OΐΩ'¨½ΰχ ‚~I,βgτΠ㆕0}lΣΖ–QU$φν_Ύ§/ΝΆo½ίΖ[«ΫXΉ±“Υ›:ιιM“HeH₯54mŸ]=!ιjΰζΑ©7¨>Šαʁ―?έWB ϋ¨― ΡPaΜP}UŸ<ͺ”Q 1‚y@= d*Ú-]Ό»ΆU;ys[wφ°½₯ξήτΎT—7­θ‰JƒcP| ‚?ψ π-φ@UσsΘψrfOͺ0ΜψCͺΒ„‚ύ[έ…hšf˜υω^«fΩ.ŒΏACXŽ,ΫΜ}τί3ΚωυkκΧΞ7Ι [wυ°iG+7vςΚ;-Όυ~;­ϋ$7(όxxyP *€bœf˜ϊG‘§βξΥJττ*N;v“G–RQ θ—H)Šr‘ηujΚο2χ’5εukΖ^šρΉ0~׌#%‚ŒE1hΖοšrΌzM«RΐΣμΟd4ϊ’Ϊ:“Ό½¦G^ΨΞΏ^ΫIοήγ-θΉ7 NΩA°/ΖΰZ`ϊ@W|ΏO Jf/ηΣΗaήΜJ’ώ~­ξͺ° C3€Μύ)+»Χj>Πa―+ ,JC1hΆλk-ƒμθμNράλ»ΈΩ-Ό³Άž4©τ€ƒπ&π]Γ"ƒ  _ΓŒ~ΜθIjΚƒLUʜ©•œ|x- Υ‘~˜σ&JOn 6ά\Ήχ· eZ ¦b0-†bΒ¦ζ-mζΕ·ZxoCΝ-}{sKOίV‘1 ŽAwΜώΈ` '8d\'QΗμIŒ#œΗŸW}vΝ"μς zMšΛ7Ρ\]λθM€ysK—οαΡ—Άσφϋν{sn– NρAΰ6ΡύΟυ9Α ‡UσεS‡3qd)ѐΟΣ§· ½ιW EθχΧ~ίZ ‡ΪsS™ŒFo"Γ»kΫΉkα&.ή1ΠKoG /g0©hPXΖΐ₯@]Ώž€ς?ΗZΛω1œQ ±œΩξΤΉως*X—±­ό‡‘yZ^ξΒΊ­]άψΠz-έI{w’L±Γΐ/ «`P|L‡D―ΐ»8’ΏmŠqόΜ>sάšκ£€@ς,\€°}&Ϊ~_Έ B±άΑ–=ά·h Ο,ΫΙʍΉΨbΰ 蕉™AπρυΐΧ€Λϊ{ΰΔ%|ξΔ‘5΅Š!Υa„Π-Θύ.….μ¦i+m^°ΆΟ}VΉdWMkΘΠq5α)SNqΤ¬)Γ.ηή‡)~ήցfXIBsήϊΦ]=Όπζnξ]΄e 8Αeΐ †‹0¨ςq†aώMξΟA5AΎ»` sgU3ό{!„«πΙ,σ–u-ϋ`΅Β#·Η, ©―a -XΚIΉ›b\ς‚4e‘*ˆ}‰`ΙP@ΛΈ^»³'Ε3Λvqν=ο³ego/σαώsPœΓάœŠNˆYΤ¨( °ΰΔ‘όη'›}†°BŸUΖ€Χ•‚ΜΫμkΑ&ΐ  ‘ΈEφ­₯α–O`όn(MΛ¨€u ,UΓ¦>rFnx`w?΅™=νύΚ4μπ1Ι&“οxp?z±NQ£2ΰψΓjψβόa «‹ζL{ΘVΤBŸ³tΑ'g ˆ}rλBΨ–χlAMΨ/ύxΥVΧφ‘BΠ””( ΝΥ*ΩάάΓ-l`αΛ;Ψέ–θΟΦŸ^/ήYTϋγ(Ξ~žάSΤ8eNgίΘ”1₯Šio5υ₯4„@HS X¬ƒώξ¦L §ύ šfqQφB0­‚Œ«wςζκ6ώϊΔ&z~[N›.B''iTޘ \ƒ^£_Τ¨― ρ£/γΠ‰2gΪΫ€”„Δ'²«΄0]‚(%<(φΧbΑΠwfΰ ΑtOrxν\‰T†ΧWΆςσW°nkwNώ4π=#TΘ8Έ•"γϊ‘€ΰΣΗ6πΝύ"ΰ—¦YoQΊ ʟσύ \uDΥ^CdΑΉ'>€WΉόݏΠΐ’ ¬ΈΆίΞ1πΐ~}Χjxn+]=EgοΎ <~° Šο ϋ>~ΰ;ΐ@I12Œ‹ΞΗiΗ5ΰ“RY½³Ψš2W/§ ΏT„ρ±a x[Β8LΌ@GθΕ>~ (h5_°\Σ4il[οΛεψ}ΎvX―™΅’Δ€ά(λwΟ†_³ <7A|’γ©fϊΨ2Άνκ-6ZPœmΈΛ8ˆr& `(zŠη³sΠ/ψκι#9eN=₯%~›――O )³Bλ³ΉN Α- '@š†Θ…χ!2/<RΣRΊ…ΉΆZα—]‘…ξ«΅#TΈ–E3Έ‹ F Tk£½+Ι?žΩΒuχ―λίανθ)γ›ΐώ3¦/ζbv7Ό„ϋ₯ρŒjŒκ¦½M₯”†|ω"cQΈϊϊnΚ@ζV{φ_/\~8ΒGξinJCsS$το-±)£\Zsšύο­οΰϋΧΌΕΪⱁ׍…ζνAπя#ΠYc+ ϊ>ΑΌCkψΞYc( ϋs‹£vα͚θΒ υY\™G˜XΉ—–ž―Iμ§!ΐΌ‚Ά}`Š@CΣ2V&Ή$Ϋ=€3?Ήώ]ώχΆbk φ ³>/Δ>ΊρytΪθ‚…φ΅AώσSΓωΚi# ωUίΠd4ό}„/·gw2έ`›Οš‹`Iκo.€ΥO—Ÿ[XπσgΏ]Q„zΏκηv~>Ÿά{υΕb#&Ž“RNš]KmEˆλ;θ, F€―+8€#ΎψΎ/ώRŒc;}l)?Ή‚Υ›:ΩΆ» ‰|θBoq¦ *€g\ ό˜ό­8qv ?ϊxκ«ΓΆΙbfς™oN Υ΄wσυ…₯@Z°ό‘sJ% °χo­=€ΟΒ3ΰ&δfΗp“θΛ|œz†Ωa\W>‹ ˜B° ’°° (κΌ–οdirΠo«Λ’Η‘5!>}̐ga.τ θ=Tόx=$# ωϋgΝΚ7>;FOκq€w¦―oυχ­ΎΎ5θπυ}RΝΝjŸ»ΘnΚόι€U'°†Οπm­€"Βc5А“³„MΈe^ A?Vzμ;0¬Γ|žύ bE؏V•€Ο'˜;³†XΔΟ«+φβ'”θM^ώ:¨>Έ{}8ΎΠŽ•₯AΎ}Φhώγ˜Εw7Γ{ΩU֌ ‡oφΈ~έΟύ(+·Υδ΅*λJ₯ HW{iω_Ώ“SΐΊz«ΨEΦΥΙ}nq9Mω]Zά#αͺΘ©€|ŠΑλΘΒ؁β‰ώˆ±F *?ϋ!‡Œ+gXm„ΧWΆΣΟ` zζιŠ;p (€rΰ†©•wŒlˆrΡ—Ζ2sb₯K’Žβ3M}ιΗWώ–ΒˆXό{εΰΘ a’²fΎ•PX„Ϋpe"l‰<ͺ{}»ύ3εw܏qό šπΉ H‹2q΅:ŠΗ μΦ€0‚ώΊ.IHγšβ6©‚wΧ΅³sOΑ’’&ΰPτΘTο Ψϋα7„“Ε/Ώ>‰ΖΊ˜Σw7ζ„ξσϋ_ήUx-y¦‹ ΤίUYδ2­Šf &Ύtύ;·bη²χ°E φΦ7ΐ¨ͺD₯ωrΊ"’‚Z'!,Όύˆτ›@8<ŠΊΚ0'ΝeΕϊ6νθ)t’±θ4ςχ°Ÿg  ΰίŘύ“FΕωνχ¦ JO°O+£iΎ«ΓΤ·―ϊ6kΒ=2 ,UΕψψΒβ»›&Ύ° ³ΘΦ μγ”\g¨Žΰ6?ηvh43 ‘m>2@χ _nύ Ί»‘y3kxoC·₯ND―&άo‡8„θBψΡρ‡Φπ­ΟΖη“.‰:†Ωe%·­ήφ"ž,›o6Δ§Z.ΚCQH”\‚ό‘7ϊo£@HΣΠ„@δ5šμω; ΉοkMžι돦RˆX3s Σ§C2€/Β―Xš–-E& “ξCK'Ρ³v2hZΪ•)H½ χ$ όχμ¬.μM€ωω+xπΉ’Κ‹_ŽTύ!ΰ6ΰ¬B;~fξΎψ‰&‚Ÿ)ΠΨ*ψ ΐ―ω.sΗΘ<MHa\Ϛθ ΄6ψP›{²Β^|½@aΛboκάϊΊ£σVeR”p ?RM0R/\‰ ”ΰ •"}!ϋπ™T7ιD'ιΎV½{Hχ΅“Nv’It’eξηλΩΨDU©t†Ώ<ΈžkοYSΜΓό;p:γΠ  PΔψtΞΌγΜ9η“Mψ|N0Ο£W„ί£ˆ'Gπ!D~l  šωψΝH€΄™τVίVšQ ½μ–PC?ΫGπΒ ΌŽυJ²!’°K‘’Jκ%Z3`|(ώpΎ` !|Z£„ ΰ ΔπGͺΖ •$TΪD0>Œ@΄„t_»ͺΘrž„4—ŠY#³Š_ΛY‡3Ζ•‹ψyι­–BSu z[Ή§@αρmτͺΎόΒbgΝor­Π³"Τ ΄ξoΰ,»6 ϊϊj‹°…¬„ %W}‘γΠCn2οDw¦ΤJΑχd/P0X(ςƒ~y―λTVΩ2›Xύ,ό‘2Ϋχΐ…ΘgIhŽ*Βΐ,Ρ­Œ&"Υρ…*Hχ΅’₯ϋp2. ’XE>)˜9ΎœήΎ4o½ίN&Ώ.9 ½yι~Υ™hsζ’§ν*œ>―/~’©(π—]ιΨ߈νƒ'»nβgS}­Ψ‚w¦ B …TΆ(ΜμήX―ΟΝl׏TO%Z;{yp:¦΅­‹Ξ:»zhimgOk-{:hλθ’―/I2•B A0 ^‘’Ό„Ίš Jγ1bΡ0₯ρ(ρ’(ΡH¨ λμΪAχŽWIvο4*Rγ"+€LΖ!u+οXΕ­l(Ζ§:ˈ *Ϋ8 x²γzκ1υœsΚp~0OZΠwα X|}²I=ξiΌͺ―>#qH©ψs( gf‘MXMςb|z±W―ΚΑϋγ Xθωά]|g·ρ"ΌrηΑΚ†Ÿ€ ¨-ϋΔK<ϋΒλ¬Z»™ν;ZΨ±s]έΕ‡Ξ}>IUE)ΓkΦPΛΘαυLΫΔδ #˜8Ά©€"ΨNoΛJ[ΡR=6P,.1™ΚAxέ?Φςϋ{Χ£NžT昈ž81"ίNs¦Uς½/ŽUΛ^Γ/…4ΝχB Ψ§„υtdίJύε­dΩ–BΛ)£ώyύνxŸfZΈZ/ΩμM¨J}^ΰ‚ jyξ0R=‰hν!ΚΩ~π³λΈοαηϊ%πŌp(HyYŒ!uU7gη|ξDF χnυ˜κήIoλzw―ЁΎ"”€Ϊ έά?‘Μpυέ«Ήυ‘…ns=z^ˊA —UΎŒž8α9&Œ(α²―M6άΌ@Φμφ@ϋ-©ΐR1Σσ}ΒFt*’άJo‰ύλδ?Ύ\ά.ΰωMόβ„^C­LΞhn s66 ƒ{0bΤ?σ·žΉ'΄ŒEΚ…«iοΦy@Γ$ RZΘε£OUV[ώφ?ΎόF’Ι‡z©cψξ9ƒωσf \΄`†To+[^$ΥΣ\€; ™Φ€ε wv§ψώ΅oσόλ» ΰM`ΠσQ ίώ^O,Ώ #JΈθ?Ητϋl`ŸΆeιΆrΎ‘Μ>©€³š8–=½W©ΓΈ*¬xBι`g Ά―φj£PI‘œy mžš₯kAβ³₯ΜZ6_ϊA@ϊτί…Mκ~zށ‘]¨aΈ(j9²fyxBX“jμY}–ͺI‹R€π…ˆΦΞΘ}ΆφN~ω›;ΨΈyΗ‡6ΑΆ7·πΰγ/rΧ}‹¨*£Ό¬DI,’αΚρΘ@”TΟ.ΠREBα:$s¦Vςκ{­μhΙυ«G'­ύߏ³Έψa>{wX]˜ο|a,ρl“ZΖ+Υl=œ©ΐRΈδ»±ϋd“yςζΣΌBχξ₯!|΅vK ‘ΟͺεϋXθhΦCπ%CˆΤL%R=™HΥ$Β•γUŒ!T:Έ„ “κΡ“c„W¨Ο£˜ΙB$jf!˜τ›ΩΙι©š˜{Ž«Χnα–Ώ=FGgχ‡>ΩΊΊ{yφΕ7ylΡR6oέI]M9΅5*‘”?RM¨t8™T7™D{φ"5ς‘U±°Ÿc©βΉΧw³§#™Ο„˜…ށθίGp&π{γ\₯ |η¬q ―ΉšρI>L?Fι―[fŸ"άlΎΏ½ΚΟ€—Ή‘ςIGΈa­>0Ȁνώ8(€π[`^Ύœ<”γgΧͺΎΎ!!Rš+3W₯ΠΗDϋUαTΜϋœΉ/U‚PΤ€ž,šoβVΠΝB1•†E˜ϋΉ{—³ήp tIΓBe#mΦ΄wτ°eΫ.Ά5·ΠΪΦE:£F,¦Ί_€ $»v*ψ„¦ §Α¬α#\9>g]••–°zν&–½ΉŠύeμiνδ―χ?CwO/S&Œ  +Ψ@¨lΙd’]Ώ―½€hTCŒdZcΙς=ωn‘ ¨D'»Iμ ΰkΐΕωv˜wh gί¨§ψZ)›D6Μ‡JtαΙΞk~«υ`ρύeΌ“.Έ€ΕΝΘVZZƒ!\(Ε•κAiξο"μR)α7ͺ…WΪχ€ –'R=U­ξ}πY~{ύ=\}ύ½άpϋΓάσΠ³<ϋοΧΨΆc7£F4/‰˜ηˆΦ’μ܊–ξ6ί|f:ΊoΠ‹iV>=‘SΩcά†?R‰/Xšϋ{ΪδΡΌφζ*6mέΙώ4–ΌΆ’₯――dTS=ΓMγTψ‚Λ†“ιk%έΧΦo%0kB9wτ°jcgΎΛOΆKζ0ΰaθδ‰žΧΩε»_KeYΘΘ³Qv{* Δω8½”–ͺ?7`€|B3ICsJΓπ­νΓ‹Δ½*Π Χ+­θOyovuΞZΩ’α'βTηφHτ%ψΦΕΧπθS‹ιιuG’'ŽΞ Ώ½©“Fε>Kυξ‘mέBW@M-Κ Ό£KΉ%D–’FβCQw·΄qϋ=Oπ‡›ώIk›.΅ΥεŒΡΐ葍4©¦ͺ’”²xIξΌ=½ v·΄±{O;«ΦlbεšΝH4‘ͺ’”KΎEΞ;λd[΄0Eη–—θmYYΰ ιJυηdδ€2œφΓ— υΠΠ©Ε–Œ ¬Fοΰγ:baί={,γ‡ΗθΌUH·v>i'κt!F@‰ΓB½–΄Y&Η€‘€΅€³Y¨ύώΜ§/κŒB―ΚMΨ%”;Φ²O† /ύ·ύ}aΑ„–!uUΌπΘ©¬0Wη―λzIϋΧ+9ϊπ©ΉΏϋΪ7ΠΉuqaš­‚CΓΞ.€iYχʏ~‹K“-m”Ϊj†‘頚–d.eY AπG« Ζ‡ŒEΘΦ";Vρφς΅|γ’kXΎjΓ€ΎΙιŸ<Š›―ΉP'’έ;h} ™ώ}¬xΐβwZψςε―κCψCτωk Eοέη9N˜]Λq‡ΦBpƞœ“Ντ3{ΤXš!ό8ύy,M:……ίYτ#|ςΆš5ο$!‘ΉΘΡ2Μ›§^GΣ5ψ₯‘ŸάCΟ±ΥNS˜tnΏη žωΝ½¨·—―α‹gž¨[ό‘r’ΫΡ=ϋ`±œ S¨΅L-“BΛ€τόϋ¬πΫ`Ξ½2:}šqlΊ―DΗ&zv½KͺGΟ£ρ‡βnV]Mη}a>}}Iή{#}}Ι~}“χVo`Ξ‘$//EI²s[Ρ+­¦i «‹’iΚ8Έh?PΐB`˜' ]ζόΣFβχKÎ%‘‘6χp­Κsσηqz·ν2WAˆ²ϊ[Vuikc₯Δϊ XΪR…₯­Η M˜~Ύ›oλmJgG¨l„ΎκYβw~|--ύ0­£yW+±H˜#›’³Š±ZϊZίW¬¨½ka&ω>4[ށπ§ϊΥΦέ2‰›θm]ƒ–N"|€?jsEsšΑΤI#Y»~Ϋvτ/ωξ΅·V3fΤP&Œ–3Ωό‘*R=»Hη…˘<ͺ”7W·±eg^ΐŽ‘8π ΰ«δI|φYc¨© Ή ζ–BςσνI―_a~Έ—σͺΕ—ίοη”f³½Ή…7ί]SτsΚ—Ν;Ϊδ:Ζ‡»{ΉqΕ€£ϊίgαβtφxώC€MθY΄ϋ½¨ώΔ\/(Ÿ=±‘QCcŽΊy”ψ ιτήw‘κιν£«»‡Oœp8Ώχφωc€zχIuΉΥ*{ °΄‰ψΠ£ •5Ω*,‹ ­«&$­%\9N§ϋF etΛ@(ˆΌέŽTοn[Dkώ°£όΔρ‡Ρ—H²ψΥβYΊήxg G̰͞ƚ\ψΨͺ ΡΆΆ  d΅γQ?ρO/Ν[1 Έθάίΐδiε5qdœO3„€ίη4ι…Wχ—N?žμ½f&_6‡ΐIΚa °ύZŠ[aŸδ–•_XΨ„ν !‚9 FΰΦ#ΐ]轨΅e F΄ώPΕέΉκγνkχΩ \ωώ&™:–q£‡χοCψB$:6α0΄…P A ή‚`ΩJ†―τ–φ―έΥέKGG]έ½€R)BΑ€ͺs-{Ϊyγβ,t&ΓΦν»9ε„Γ…τœŠ“κΩiτ"πRšΑ`~6aDœΧWΆζλ;CΟ©YΈ?ηŒAΟχwAΏδ«gŒdς˜2CΠ°­Γg·rπOZς°₯”ΝbιfϊKck €΄eχα™Ih]ω‘Φa[ψΡrOzdΑΜUΘχΨ‹ι%©™F€zRξ«Φlβ¬―^ΖΪυ[χι‹ΦPΛ’§B$Κ‰aΗ–—HvlrٝV„’‘Gγ©l;»v·ρδΏ–ςΤsKyoΥFΆοl‘«»‡LF‘K)ρϋ|”•Ζ3j(‡NΟqGNηˆC'…,ˆ‰₯( -ΥGχΞ·tr|‡YΖcηfτϊ„α'ˆV+[Ϊ;Ί8ηΓ ―ΌSΤs’Rςη_—3Γμώ•hίHϋ†§ςr ŠρͺY/°zS'g\όЁHvŒήί_-€{Q^gŒ/γ€#λU~9+i‡πy’ύ9e!₯#,ˆάS8ώ]R„s«΄ΙZγLŠ/…šDXsœιΔΚβX'€Χjο\i%₯Mσ0+5}ςeξΎί7œiοθ"Ξ0χ¨™Ή)ˆΥΣ»g•š‡αΦύ'>ŒpΕ…ΘπΡ§^ζΜσΖ}‹χVodWK½½ ιLNd2R©4]=lάΌƒΕ―.ηžŸαšξγ­εkρIAI,J4ց¬M ύγbCHvl![R_huλk}Ÿ@΄_Π$, …‚L?‚'Ÿ]FG˜Š¦ilήΆ‹Σ?y€iψ#€Ίw’IvUA/θθNςΖͺ|%ΗLFΟάοΐ ΐOΌΞτKΎώΉQDΒ>₯Ή†pβ\vιZ€ƒ£ϊNΊψβ"gšΪͺϊμυ9άΑ%ΓOZΛƒ‘ ,­$Ν}ΥZ7ΐ,/znmŽ \5™@¬67zϋϊψΩ―nfσΦζ$”³bεzζ3“ϊΪ*‹+4Ψƒl\9ΚO¬~¦Q €ίηSZΖ—.Έ‚Ξ€išΖκ΅›yθρxτ©—ΩΆc7ρ’ Cj!χb„«Ζ“Iv›νΐ„kbrξΎφ b΅ψ&dUW[Ι°†|όΕ’ξo{s γΗ cΚ„Ήη„–!ΡΎ™ό„ιBq€ΤT„xξ΅]tt§ςEΨ^fΥ μ+ΰώ0Υk‡O[Ο€ΡeˆΌZγοV»³˜ ΐ ϊΖf]X•KΆ‹/v¦_©2ίfύk©˜πΒ.ΪΨ~­ΊΠT g¨Σ EW65fr˜ή5ΧψόεkΉόκΫ?°DŽΎD’φŽnN9a>Ÿ>U|‘2£Nΐ=yEψΓΔ,,ΐ]έ½\rōΌΏnΛ>Ή§Άφ.–Ύώ ŸYΒλo―fΦτρ”Εc–Η' Δ‡"}A];LwΑ3Ω*M²«™ qL™=”ŽΞn–Z]φΖ*Ύvξ©z‡jΐ,Υ]’L²€)ήTuYˆν»{ysu[>™ΔY§έο!χΡ\™ψ«* pΔ΄ͺ\μΦ+„„‹ΎΤkΛ³―Qφ=ΞΟθh”§jΒ;„₯i¦_™»aό•εΤw)δΡ”Ύ“.ν8lώ²΅΅ΆcΥΧά‘πPωHd ¦|~ΕοξΰƒO>»„η^zΓ\³d€pΥDΌθΜΝΤdύσ ›ΆσώΪΝϋόΎvξnεΑΗώΝ1Ÿϊ6χύοs*W‚pεβC6Xšσ¦‘IvΠ΅ν΅έ—\τ­ΟΣ4΄Άθ°ΰ]Xdξ _‚Σ\²ΏtJ•₯Α|oΘϋƒŸ*Όv8ξΠZ’aވ¨U°²ަ(΅ϊ‡0ϋΫhφ] BMο0,Ήφ–5!Ι(t]NΩΦ3Ό4We€l±tr4uΠM€ΑαJεܚu[xϊΉeΈθμκαΟ·=¨δbυbClίΥ=uΉ§'AwοΧ»­½“σύ_σΓΛΟ1 εάΞx₯#ζ[\o%μΪFΟ.΅ŒΊ,ηβo‘θ{Ήεξ'TΐΆv†Ε²Μ·p©s‘±&Β‚σ]ͺΉΐώ j€―xm¬« 1etBšμ7–ΥO³NΝ5ΚTOΨj4σλ™Š&„M-ηΠΌ…ίzڜRZFX„_|ΝjΑ€3€˜»˜t‚iVŽτ\sΈR9μΏΉ•k,z~<ω’²Κ‡+Η]…pQ`«¬„²xΙ~·όυQ>•ΛxΫVςˆΤm8B˜]k/Μ·έ³σ R½­Κ¦“ηΚμC&u«Χlζ©η^Sά‘`Ό©θ`›U |ε΄T—η΅ΎbΘήGΎ”xΧSΖ–SUt·Vΐtwυ0[PYWHαρΥr–…Σ]sL7gB(KΈζzٝίCS΅˜ θ³σϊ£ΰ Šω_6(uΥ~γχYτό«|˜γgΏΊ‘Φ63ΆˆΦ*sΦyιYpζw>¬ž‰γ†(χΈτυχψςwtψνας1„+ΗΊΏS tn}IΩRYQΚΞ8ήΰJΘ?Ι ©έΏΓU“TΊxΟin…ύ\πΩQω.WbΘήGͺp©ΧΖhΘΗ‘S+σjΐL!™χͺ‘G(έj5—*¬ϋ‚ΝEΘ5zv\4'θBβ ΐΒ€k­u ΛΙogιή‰ "š«Ύεβ„J›”οuΧ}Oμ“΄ί~ωά»Zωω•·(O)V;Σfθ [– ΏίΟΎqΥUeΚ}YΏ•s/ΈΒ:FjΑodΊ/;ϊ_ιήVzl€ŸŸ9ε(꫊SBobσ6³/ ?Rπ…MpΩ Wΰ“GΦΡPΞwΉKΩΛ\ž½U?Λ·ρ‰εT–‡<͝,†ζ&ψš‹eo{_V“_Γ;]U­ΉΗ¦*άύ}”Μή`~ ΖβNαΟ݊ߞ$Z7CΩχύ΅›yζίξκŸχ<ΈˆW^}ΧbΑψ(r¨Bύ…–’―MP2m»α2¦MMΌ$Šίο#πSQS]ΐΊ*κ«©«© ’Io_‚»ZΩ΄e+ΧlβΝwήgΩλοΡΦΡ5 {=jφξΊώ§”—™j_ϋzΊ6›­ψ2θ° >μX…nmwK;:Tͺ0CΧθ\Ύyώ§υ€5M£§e][R:―Θ’dΗ{λ;ψϊUo°m—'gΐ6 ‰6Ω›<€s€^η8|j³&WΊ2υ£ΫMΎ|~ς))mΒl«%ΟΡ‡I%—@Ji^³ŠP*!Ω9kNaΆ΄ΗRζ˜Έ—[{ϋΉ‘kκ—4Ξ±$Τΐέ­\π£ίέέϋ‘)€M[v0uhƏ1έι“θΨF–Υ -…–κ&X:LYc|>Ιθ:c3§gΤπκj*(/‹SVZBUE)Γk™2qΗ9ƒO8Ÿ4g™E#!–ΌΆ’(Ϊρ`ΐΟ©'‘χ-“€―m=.Tχι°*Kƒ¬XίΑJoΡ°½έψ‡κ|½8Αό;jF•«―cx3V εώ·―Y 9 `0u©> ~m) Dš₯₯˜=Τ§9Ν~‡‘)\° aηP·‡ΚFβSVP;ο]ΘΞ]mO½ΎD’?ήt?νέ@°No4j]ν»ΆΣ±ω΄=υςύ]VcΪ€Ρόδ{_β­ίΖ™ŸžΫοϋ½λΎ'xΡ’Χ/„$œ«₯œ Ρ±ΙΒA ―žϋ©’ωΦς΅ -»τG‘ˆ ψΝ#’–Rx)§Μ©#τΥ !‹*py&ŒŒSS6ΕU³θ5!ŒΏEΏoMΣ4χ‰£9A€Βm.5DΤ€κϋk¨5¬.Ό¦³W˜—ΨΎ‹ΙŸϋΔ&T>Ϊ°τ{ΨΣΪΑ―½‹ύaΌςκ»<πθsJ ΄HυD%R‚Tw3­ο?LoΛr2ιήα§ωcόI™ά€šΛ3‚ΊšJnϊݏΈζŠoSUYZτ½vχτρΫλο₯Λb5’uψcυFˆΧ#?@ːμR»M8²(|b{σ6X:ω…š¬`γVΤά±y³jΥΛwΘlC&?@§gy­ώ'QOcmΔQο/₯΄˜κ^4_*ΟΎ—οοV`-Iu―Nή~₯ Wί_˜YΫ>@…7ο *©½’rμ †ΛG*lξOανεkΨ_Ζβ₯οpζ§ηQV3VΉZ¦to‹c‚§Ίw’h_GΊ―TΟnR=Ν€ΊšIvn‘―m½{VΣΫΊ–dΧ6½{ΠΊ° {Θ~Ζ”±L8Š—Ό­X!yΉMΫ™>yLΞmB’i©ŽΝz¦ £„X#“J.mqa|Ό΄τ]6n)\{1ηΠILΞΥψItl6Ϊ°‹‚K”°δζ”–_°h™'_@)π  ˆ1€λ¨­ 1’!κbρZbκΈ₯ίbs„§ΙθLϋdrΫςΕ[—Ωλ λλΘΰΘήӌ4_aΈΈŽDι²ς›χ――r`”HΥxeΫΣΟ-ε‘'^d­ν\zεκΔ¨ž’[ιαΟt’DΗ&ϊZWΣΫ²Šή=«θk[G²k;ιΎ62‰Rέ;ιk]KΧφetlώ7[^΄­Βϊ ;ώ˜™άsΣeD#‘’οχώβΟd2ζ;•6A£’°Ή‚ϊ΅R=͊Υ ψ™9mLQΧ[ϊ†Ϊύ؊ε†ήα―OYOUY^λγtC6?pP œθ΅qDCŒͺςγ[jΉmα0Σ΅~φ₯wMΘ±‘ΈΞgmΟY7•‘–‹ΓZΞ©‰‚~«ΓZynΦ’T$„τp!4’΅3Έ±>::»ΉώΦιξιc ½Β“Ο.QBmΡϊY*βlˆθHzt}Ηι^’[ιΨςέ;ίrΈ“Ǐδο7^¦mEŒνΝ-άύΐ"Α‘Bε# ˆG˜]Tώn‹ψύ> ψYxWΝHτ…βŠΈ ) Μo©HE$δγτγ†δ»δ‰†l~ΰ ΐ³σ„ί'˜2¦ά=_Dy͎ΪKˆ„»Α”{‘j“μΉ4Όά„W’₯΅•֏h©#Ώ_ΨB}Β•KC=wέΜ±ΧΗSZΚσ/ΏΑώ8Ί{zΉξζϋ0ώ½ύ‘% N`Τφ\TFδ’€ˆ…+ΖΊaΟΚH'Ϊ•IX[SN(X8_wT&§όΦ­}N;χ;}nRL6χ₯8ΟΣ4( 2¦)κ0οΝWxϋ@ΩΊzΟ9αΒ)§@άϋπiξ6‚ŠjΛEΈ u—fSFšft―–YBhHD/±Œ=mόζΊΏ‘ά‹—τxqΙΫ<ώτb#β’‚αŠq@ΠKd£1ššHδ±`τ΅­₯kΗλfŒά8ΧΉ ζ3cJqfω{«7ςκ›+-ΰ\ ΎP™e~ Kλv#š‘kψ‘ίOey\Ι+π»χ΄.‡iq8,!™ΏΊΥwξneηΕ(„ή]]u-oNΣ,V€^ία“Ε‰’Ίμωιw+5ˆ†}̚PŽΟ³ ACV?ΰjϋ„ƒ>š’yWc%Άι’>«Ω¬Bι™ωg˜vžΗζA ”σeρMqς0τ’,η&ο€ζ}ZNΑθϋ…+'*'Ο½τ:wόύqΔρμΏ_埏όKω,V7Λφ,€ *\ޏ–Χ HυμR25§OS:ί²§ƒ;[¨ϋ’αhκ* :yEWΪ—χΉTΨ PŸΡά™Υ„ΌSƒΓ„ΣΌφojˆω- Ήf$oαΥ0Sn­₯VΠΠ™' TπΠΖ4δ5w4·χ 9ο1λ{9!―~~†Kΰd Qώˁ\šN±―LβΆφ.ώϋΧ·Έϊν£}‰$ΎνAZφ˜Δ!ώH%‘²‘ΕYE-F—Ÿξ]¦ΟDΒaƌ, +Λ‹+κνK¨…^n³4ΛΥPWΪΞξΊzzϋ­ΠŠ―8ŒΫyά©GΦH^’|θ•FϋV–¨.3ΠΝuω'γ%H^O@σ~0šρ/cšŽΙγjtχDΞmq^Τ֌*{~͚=θV¬©VŸ0}ίHΥ$Βe*Gή²ΧWpΙό…ƒe¬Y·™λnώ‡Rπ…\AΏB+ “OΥΐΝrςηr5vv(ΤχήμΣzνŽΞž’АJγ1“Θd–GΊΈ(,¨Ρ€±M% ­δ“λC(’Ψ―˜'ŽςΪXU’,°iT\‰•²` ψ§ryeWJ‰=mV³¬nd nεΒ&š‹ΊΛZf±OΎ%ί2έ¬ΝebjNƒ#T>ŠhυD•ή΅‡ŸΏ›?ς:}=½α^–―4©Α€/dτ4Δa gYΨ6Φ$"μήΣ^ΤΉ’ᐠžγ>ό‘jeNμniwЏ» ½c°ΥθυΖy#n–ƒO Žœ–7χα(Cvχ‰8άΥ4‚¦ϊˆ»£φqΌq-ΗεŸ_1 g.]‘(ΤίωL¬’ά’Ν4λ6΄š’ΊC”/”H$Ήφ/χρΒ+oq0Ž φ{K˜B₯#ΜΤ[Ηsσθ”„ιBY?φ…*mΦ„ΖΊ ۊΊ―X,bαhΤτΞΒs"oP˜›w8;—1aŒΚ˜œIvαέΘΗYͺ +`Ž“fηmZrψΎR x4ύπIhΝΛ†«ΩVΔβR m+ΌfΧ†Yβ&‘ί΅·εο*DŸšKκΛγpRyK xdΝB΄9 ΒLmυK)r¨e}άyίόαΖp°ŽΕΛήεξϋŸ΄νi„όΤUG<}uΜ~=yRkθO³ϋπš»Τ4ΝjΦTcMέ/#€π—ΥFΦB η-hv ’X#6߁π)m<0Όΰ‡ΎΘΕΏΈžƒyd2nΌγa#σ.»r—*ε²ΪΛ‚V”0UX8ΏϊnQ„%±V°PƒLͺ;§¬­J ¦Μ•d2Ι«Eφ œ=s‚f’=@ΗόXˆζΤ€΄ΔϘ‘±Ιnΐa^†Φ†ρK“;_α›ΰŸ·ΙT(F`[κωβšrμE5Ecβ n΄\ZφΌΒŠ_Ψ|Ζ"2B*†ΟΓV ¨7ή^Νw.ΎΊθp8†N$α΅·VςΠcΟηΒnB‚e#Š,w‡ΞΝΠ³}‘2Η3½ηEEέאΊ*†ΤUZήsΖθ$μŽΩXηΔΞέm¬X½±ΰ5€”ŒV—;6“ξΣ)ъ±}E! ‹‚ςKfŒ-μφGœζ©†Dσ&ϊˆώΊΡ–£άŠΒ•δ³€ _T²ƒδΏGΤXsΉ¦¦›šπQ1ς‡Ο»nΓV.Έθ7΄Άw”‚‹ΕψώχΏΟO<Α%—\ΐnΌΝ[M:+¨Œ@ΌΡΕ]”E˜ΑRοδkaΨιξιγρE‹‹SυUΉvηZͺOI(ΚΝ ΔΡŽν±§‹»Ζa3Ζ)!ΐL²›Lͺۜ^B. -‡κ~Ÿ`Μ°’Ιn±  2ί>£‡–\Α5­ˆΈ»#ξ5sŸ g*―σοb΄m~M,\R~νX‚›‘~Κ‡I R­άξ–6~tΩu¬X΅ώ ώsΞ9‡7ήxƒ«―Ύ)% .`ΛΆόώ†{•}#•ŒnΗn˜‘w’P VG€J€όύŸOΣΦήUΔϋL?R©ζ³RŽYΛϊ IDAT‘C₯Γ B»ι’žΓ¬ιγZ˜Š2ΙN4£ΡHαΒφ|ۜ]¦‡ΥFˆ{γ’,A…ΐ)^ό>AUEΠ&Dš"XφˆzΦό6Ϋ„ ‹Φuk«­b M8E­ΎπOe.‚šLx΅·€ [~Κ'TΪθ8φ[χ·JGί{̚5‹wήy‡;οΌ“ΚΚJΎυ­o1nά8^xα…ά>7ήω/,~Λ|+Ύ‘κ©Eψϋ–Ι(!6D HνΪέΚ_ρdQ€)‘`€γQΙs›l. |KκΆΕ―./Κό˜5cΌ…ͺL#Υ³KUtL vγ+¨©j%~Κή(€‰^j+Cύ~[…Ψ+Ζ›ΡΌWχό&Έz”k2‘M3˜‰:nh’Έ=-Αωr«Β©1p©3 λΜσ.α‰g^9h„ήησ1kΦ,ξΈγ–-[Fee%ϊӟ;v,Χ]wλ1?ώεŸ .oΐ«w΅μiΔ†ސAdΌηΑg–Ÿ|£¦Ίœ;ΤrΞ ΙνΧ0­Ζ­V&ΛMw>ZΤ5F4Υ3ql“zξE~Ő٫ΗΥV©©H†‹QΗzm¨«ceΙ΅ΙF¦wZd·<Ή†¦ΣςͺχTPM3Α?Χz!ν”ΐ9³ΏrδρKΤέ=½|γ‡Ώζُ¨Ÿί1jkkΉώϊλyϊι§9ύτΣωυ―Νόωσωζ7ΏIKK‹ηqΛW㦻V>‹ΦNχ@|Τχ,iΐoΓSήyoΏμG›τsΞ5υ|vˆΠ(ΘΡ³ά»[0Εz…τ°ζ΄΄ΌςG©y,Α’sMβΧ·p8˜Fgg'Η{,εεε,^Ό˜ŸόηΌύφۏK₯ά~χ£lάlςκK”pε8+@-ψ –4²τΡΪήΙΉό’ήΎDQχ\Qη;η³ ρLΟξρςGλΔΥ$ž›ξ|Di,βιϋ}Μ;z‘P 7‡“χι³w³ͺG6Δς‘…N¨Θ ΤW…σΕhΆ–^VpkA°=eX©γΒU™hΆυZX;ΥΟf p>δ_Ή‘#½΄ ¨ŒΚΗŒΥ:Tυχ~r 7έρπoκΧΤΤpώωησ‡?ό††Ί»»ΉτR½+όμΩ³9ζ˜c<3g›6mbβΔ‰9+ΰξ>©†KG(€9άψ_KπΥBŸ+Ύυ·ύ=~ς½sˆ„Ν•2ΡΉ…t’C™1B‰9LYdV­ΩΔΒg—•ύW‹pΖ§ŽQ,Φξ]+<&Ό–¬μL8¬6’ $―,ηS3π¨)ψ‘°/―(kύςlŠρ”€|ΡCδ]ί5WKΔΫT³ς‘ jǜD0R‘₯ΥW–ή>>εKΈχ‘wεΕbόράύμΨ±ƒ›oΎ™sΟ=—3Ξ8€{ο½—gžy†RΎϊΥ―› B(bΦ¬YάsΟ=ΌτKƒAŽ8βˆάφλnΊOmŒΜuB²cB'Ττ™Hw"‘δ‘Ηώ]τw™1e Ÿ³φΤ4ϊZΧ*α?0š±’Ή©J₯Ήο‘±aΣφ’sΒ1‡PSeVy&»w)ΐbo&¬As|T%ΰΟΫ;pΖ@@ΥDρX€PP"ςš’ΰz_¬VԐΈVΧXΟ§yΥ¨ ]>ΖϋΪγμ`a«IέΈO }jfwKόπ*ž}α΅Vψ'MšΔ-·άΒ’E‹˜={6Χ]wηwŸϋάηxςΙ'szι₯—’J₯8σΜ39ϊθ£˜3g7άpΛ–-γ”SNαςΛ/稣ŽβΦ[M_½­½‹ωνmΚ5ΓγlΜ<φ7d*‡ν;[θν+QJ ΰηΒožEyΉΙύ—κΩE²«Ωvύ±J†(seέΖm\σ—βS΅vα—Tόgη[ή+ΌηbWιΩΝgψ Yvw[ς\i˜·πηJ/LυŽ(dκS¨Χfn!44$=‚ …·€ΝόEd / eΜ܌R"|Κj'RV?ΥnC°fνf.ώολψχΛo°Β?qβDξΎϋn¦M›Ζε—_Ξ΅Χ^K[[Ι€3kqΩ²eάqΗœώω\yε•,_Ύœ3Ξ8ƒX,ΖΥW_Νο~χ;ΆmΫF:νLΡ½χ‘E|ώτ9iξa9₯­Aη–sB’§f;W™’(~qΎpΖ œrΒΐZ£―u΅Ψ­%R3Eοi\1•Nσ‹~WTz1ΐ§CΣPN%ΪέΑΏ"=ύόσUuy+βbέ)/0l ΐ$ΐ•d­Ό4HΎkΝtkι₯αΥβΛώ₯52–NΩT]ΝeοάvMΛϋ(•ή€Yπ‡βԎ<Ξ~‘ΰK^]ΞωίΊό€ώP(Δ%—\Β΄iΣΈΰ‚ ψΩΟ~Ζ]»\… ――Ϋn»ζζffΝšΕ—Ύτ%–-[ΖθΡ£ΉπΒ ΩΌy³«πgΗO―ψ3έΉgˆΦ)ωχ^¦[Ey©£ΨΖmLŸ<†Λ~tΎΙβ+ ΥΥL_Ϋz“αIˆΥΟΚυdΘΊx—^yKΡαEΏίΗEί:KY=z[VA&Y„Λ;@(P¨…SyΈ†,χ[x«”—Rρ|Ν!,"?€N>κ>7?= Ύ -γΪ[Π›9Ψ‘Μ»“_Σ R>”ϊ±'*©sΈ|ψ_œσ΅ΛXωώΖNθkjjr“©ΆΆ–³Ο>›%K–pΛ-·uόΛ/ΏΜγλ$¦;vμ`ώόω¬]»Ά¨cW―έğ­½Dj¦‚ΑΑ—½―L²Σπ₯ΝqιΏœ—ž»ΊͺŒ{nώo*+J-ξ`†Ξm/+Βο`gϊη#ΟsΫέΕ³~εœS5άt}ν$:· ²\φg₯/’u˜mΕl¬ΙΫ΄΄Ώ @§Σh,κ·pˆ½œ‚j’’S`ο$,€ 4Ί‚*ˆμiΏ€rθ,jGΝΕˆ(;uwχς§›οη;?ΎšφŒΘ³¦¦†«―ΎšoΌ‘aΓτUwĈ477}žT*ΕψGZ[[©ζΗ?ώqΡΗ¦ΣξώηSΌΏΦΜΖΎ‘Κ ~“Ι€ItnS8g͘ΐmΌ„ς£•ΉΩΘύ·]‘δόtn}9—’›]ύ#5j”lΕͺ ός··+œώωFc}5_<γD#σOΏίDηV2J„Aζ™kΒ'θ‡rΘ•3dYτ(3~\G8(|^a₯Ϋ±8Τ&υ΅k«>W_(½ƒνΩ»6@Έ‰ OηΧχ‡!|AbeΓ¨h˜B0Zαx 6mγwϊ;χ>ψ4βRςυ―H$ΒΣO?ΝψΗ\ΟΡGMSS«W―v€ƒG}43fΜ Nσβ‹/rύχ³lΩ2ώφ·ΏqΑ°`ΑώϊΧΏ:Ž΅^χŠ+  rα…²fέfξΊο ~z‘Ύ’ τ2άDϋFƒψS_ mk •Βg©ψΤΙG1eΒ(xτ9֬ߊίοcΖ”±œuΖ‰F^}ξmYE²c‹*UŒ¨§·_ώφφ’Q€Ο6—)Gζ&\&ΥCΟwΝIœ7|θ‚Α“)„j ­Ν«²ςάZ¬­1x—$‚€_πωωMŒG r<ιBˆ\–’”2Gθan)„εσs‘ε}“)€ϊ™AΌαΛ ¨4·KŸυϊη2›Σ-Νk&½Ρ²OΏO$Tώ+o |Θ’₯ 9Δ6kμO>»„wΝνΌ·zςΈμ²ΛψωΟΚ5k˜3g===<υΤSqΔ¬[·ŽόΰτφφrόρΗsΖg0zτhΗ9–,YΒ‚ θμμdεΚ•TVVrρΕσ›ίόΖαϟ??gqlέΊ•‘#G’H$ˆEΓ<ύΐ˜4~dΞEλm[KOσ[Ζͺ―;X=%G)ց» ©“μάJΗΦ— “]J#‘lδ'πYΜΕ―.η”ύόƎΚΒ{’’–ϊ±'˜ΒoW^sίψΑ•”π—––RQQΟ§ϊΜώσŸY»v-£GζΏώλΏθμμδͺ«’§§‡‘#GςΐπψγsΡE1zτhΊ»»ΩΈq#+V¬`͚5d2fϞ͍7ήȞ={ψε/ ΐχΎχ=υb¨p8Μ±ΗΛσΟ?ΟΒ… I$\|ρΕ466’HθH|Ww/?Λ’’<\6 _0¬SΙmtn]\„Yl†’έΝϊ1™”ΓΆ~=΅Έ_ΟυχΏϊŽ"ό™d'=»ήΆMRχU>Yp‘1»+RΧπ”g/₯8*°hΘΟ΄ρεΔcχU<»b ³½±°΄]Ά[Ω/,dφ»ε`ιΤ"}˜½8³ϋIΛωυ^f™ˆ”ϋ°X*ΒΜ2B }~ΚjΗ1tβI„cUϊ½XΈΨSι ―ΏΉ’sΏώί<φΤK€ιBπηϟΟ7Ύρ Ύώυ―³`ΑŽ<ςHvμΨΑΦ­:uVgg'>ŸωσηsΤQGqΗwπΚ+―°vνZ***RάάΜ›oΎΙ}χέΗ 7άΐΥW_ΝώπώώχΏ³gΟζΝ›Η¨Q£XΎ|9χί?σηΟgμΨ±Ίπ%“όκWΏβͺ«BΑ•W^Ι…^Θc=ζΈΧ΅Ά2qά&Œ1 i|‘Jνλ-"‘I΄“h[ – m˜Ik2ΙNΊΆ/ΣRK9 !αͺ Jӏϋz–wή[WΤ³ύιΎdI.―έΎαiIKG`KSΈ Ήw>LήRu—〄Ώ<Έήλvΰ ΉX`¦α8Z­T–ωΒ)MTW†uwcu»έmπ …άη¦"‘Y‰”’Xy#ՍS‰”ΦεŽ'w.Α›ο¬ζŽ{ηή_Φ¬Yόζ7ΏaφμΩD£jrΝϊυλ9ϋμ³yι₯—hjjβα‡fϊτιά{ο½,X°€ͺͺ*b±™L†.Z[[Θs0δ–[nαμ³ΟζΉηžcξάΉ|ε+_α†n ‘HJ₯())α§?ύ)wέu6䷚F6 αιώ@u₯ ?u7ΏAoλZKqyΎP%ώXΠsR΄L‚dχ.’[ΘeΝiΆοΖχ(sš%όΏύΣ=\~υŸο§NžΓMΏ»ˆ …φ»wΟ*ΊΆ½’˜ξzΤΚΐ”\2ψ44£4ΦΝ Φ<ӏ³ΟΒz­ήDš™η>K:γzΜΓxm―]€€Oΰχ ΕΤΧ,ό{ΕΤμΩm}…8Dsγ4ΛΝͺi6}θΖ1˜Η₯ˆΔki7—Ζqσ—Φ9nrηV.ύΥ_ψ―o_qΐ<ηΦ[oeι₯̝;—|1cƍF9ν΄ΣH&“Œ1‚Λ/Ώœx\7―7nάΘ]wέE2™δΤSOΝευοή½›7²yσfφμΩγ:‰όη?˜:UOŒzψα‡Y²d αp˜E‹1dΘΈβŠ‚ΒzηήλnΎ_œ«&"ύΧπt_ ½»—Σ½})]ΖOwσ›†π{PSYζj&‘²2ύΗό# ήγ€qΓΉμGηι?ΩϋH΄Ρ³σm§ŸοlhPd°Ty 3t—ΖόύvΌ@F >C؟¬ζπυέοXxριy¦VE‘ Zή‡γ–Τ£[]’Ί‘G0lβΙΔ+‡λn…νT½o!§žυnϋΫ£μΨΩr@‚ hkkγΌσΞγΩgŸeϊτιœύΩϋξ8©Κ³νλ9SvΚvΆ²ΛW€χ‚ΑŠŠ‚=5―1‰1ρ%&ϊΩς»±DΕ^"Φ!‚ € Ho»μ.Ϋ{™^ΞσύqΜ6§Ν² ¨Οο‡ΘΜ™3gΞyξvέχ}έW_Γ‡#ΰΣO?ΕάΉsΑ²,Ξ8γ L:UψμK/½„ςςrΈέnόα€Νf3ύ½ρ\CCƒB|τΡGρ‹_ό_|1Ν#κ”R|τιμήW!K :²OJΨ”RΩՊT΅­BD ά]+ϋώAύϋβOΏΡžκvβΡ{ƒτ•ΰw,όM߁ϊTw„²†Υ¨Ι.΅AzV ζs&#ΟI+«•‘ΧK’θ!€‰*Xώ2«―NI ~ƒ΄μ(|² †ƒ±Ψdw4‰α@YΌιάυ/’±©ΝTΨeω|νt8ΖSO=…]»λΠW­Z…Υ«9oζ‚ .^οξξΌ™3gβ΄ΣN3υ)))BΘπΟώSxύ“O>Α–-[zτ;ͺk›°θ£a*`Kν›ΐΟGSu QŽ£†Κ&Ψ~€ύίΫάωϋ«πϋ›.EfF*)vΨνV8Rμ˜4n>]τ¦O%³_ΑŽCΌ"!Ω ½Ή€:EB&ͺ•Υ€nGο);4j, 7‚))κ-ΏjeΆͺ7‚hYr’p+5­β‘žT„Ώwf_τ~Ї ‹5ρ>”Α#OΏ…σζύ ·μ>!~Π A˜;w.f͚‡ΓeΛ–αν·ί†έnΗ‚ b8(·mΫ6~³Λοϋᅬ΅kΧ"==·άr‹¬»Ok-\Έ—]v>ωδ,Z΄¨Χ~Ϋk‹–bη^±Ž€±:’98‘^ΎŸ~SM|xΆ(p ώφ—_α‹<…žΈV,}ηa¬όπqŒ5TuΧΒΧΈΥΐ#eΘMŒ―Q_bDώ ω1.Ν]X‘ΡΩΛθ|@Σε³Xˆ©‰=ͺUΐΊ=r1§²Rc’ήρ§Έ RLΕαΞFρI§£dΨYHΝμ+„?ΒΣ ίΗ Ώϋ?ΌΎh©81φΊ†ΑτιΣ±uλV:t}τV¬Xζζf̞=ϋίΡέݍSO=³gΟVρή¬4ˆ«‘ο½χ„ι»ξΛ²˜;w.&L˜`m- rrrpΓ 7 ₯₯7άp–-[†›nΊ >_οUBF"QόΏ‡^‘{ιύ`sεhZ@£ι“jŠ‚‚Hw B]‰θω …ΈψΌΈvή,L{’ό»(Ÿ’δ—d ”Κ@Vή¦{9zΫZρυ ‚^z^K¦“W< ―ο'ι: zbhŸH$”_\M¬λη= J ˜Œ£ΞCfξ`0{v°|Υט=χ6<χΚΏQWίbx‰™™™˜9s&nΉεӝh½΅ƏΕ‹cύϊυθΫ·/Φ]‹•+W’ΎΎiiiXΎ|9ϊχο/πρ=ύτΣp:Εjn·Ϋλ»σηΟΗ–-[ ‘Ÿ/?·oߎwί}πΤSO†ŠυλΧ£¬¬ hiiΑλ―ΏŽξξn,X°^x!ΪΪΪzύ7³u^ώΧΙ“Oχ8 %+C π6lA ύ ‰H›{=ΨΎέ΅λAو©­­ίφJ5E\{©*N€€€.% QRΠΣ^ ¦¨ίnqPIsŽœK>Ρ€fτΕI/CNΏ1°Zεΐg,CeunΎνοψݟŸ@m}³)«Ωe—αέwίΕηŸŽ… βΡG…έn?.”SNΑ‹/ΎˆmΫΆaƌxπΑqκ©§bζΜ™˜={6.½τRμΨ±πΒ /`εΚ•¨¬¬DΏ~ύ°`WΥ6{φlΌύφΫxωeΨfςδΙΨΌy3/^Œ›oΎYύΒα0^~ωe΄ΆΆb€IΈρΖQVV†U«Vaμή½Ο?<~ωΛ_bςδΙxβ‰'ŽιοβΉwΡΨά&ψ”› ŽμaIlYͺς–ZE^ ώ¦νθ(_а·ŽΖFΉ™4Ə‹!ά]‹Ξς%π7mγΗ“›}αοΑΌJ-Βj!HV#j}b>€χΥήZ’Š+Ο/Jo"”η3BA#ώ[Q ―ˆΧˆΕBŒPΒ+―ˆ1Βq²βψ{©ωΘ+‰¬ΌΑ’BL©iΔλρΪ’OαυϊMmΐ©S§βα‡ΖδΙ“eΦΰ]σηΟG0,X€1cΖ ‰ΰσΟ?άz«ΥŠΧ_Χ^{-ͺ««1zτh„Γaδζζ’»»έέ݈F£8‹‚[Ώέs‹°Η(ηΘΧ>•Ο|’Ο   ΐ9±@žIDν677Π…XωΜ‹h°lΔΛεσγtwT=n—SRΚ¬]¬‚±¬¦E%|i±ςΪoyτ;¬έΦͺu;―W dΚˆιΉ4,MlΦIΦΠκγWf¨„ͺSuBP4d š…ΜάΑŽDπΪΫKp˟Α3/ΎoJψ ρΙ'Ÿ`γƍ8ν΄Σ„.Ίθ"¬[·ΗL"‘/^Œ––ΈέnœsΞ9ͺnoό›Ν†1cΖΰΉηžCkk+ΣΣqξΉηβƒ>@FFξΏ~¬^½οΌσƎ‹»ξΊ 6› ηŸ>8€σΞ;ΡhΟ<σ < qσΝ7# ΊΊΗMψγςρ²/±}ηA±’ޱΑΡηd³*DχܚvΔ‹ˆ·aO5B]ΥwΧ€xuŒΆΊπζu3nqO¨Β]o6–L ω”c,ΛR‘Φi²Χΰ؏w&>ͺ˜α'ΐιΞ(D^ρhUtΫϋqΩ΅wβ‰.’΅œj­΄΄4ά{ィ©©Αœ9†S•0i$|φΩg;vμQoφΌΌ<œ{ξΉxζ™g°rεJΌϊκ«ΈπΒ ρέwί ₯³χέw22Τ›4«««yێΊΊ:,\Θ ­¨¨ΐίώφ7x<ž„Ο<όπØ5k:„μμlόχΏΕ£>ŠςςrΌόςΛΈζškŽΉ›o΄šZΪρΪ’₯K(ΐμ©}auζ;U¨€¨&ƒ’χš'Μ.R©tζ±Fˆ£υ…τ€&]ΪΔ» "έmÈ!°ZΔ@^σΟ—ΰBδΧ—–ιBΡ «γ'ν—0”“π:JΦ=(οΰpςιHq¦Λ`›ζΦόν±ΧπΠ“o ΅½Λt>βΔ‰xϋν·eύεfΌ…3Ο<‡ΒαΓ‡“~”.— 7άpž|ςI,X°&LΐΠ‘C1~όx\yε•p8xύχ1gΞδζζ" α«―Ύ’#ή‡ŸššŠηž{ί}χΎύφ[Μ›7C† A4ΕκΥ«UοΓαΓ‡ρΩgŸ!##cǎ…ΝfΓ7ί|ƒ·ήz {χξύAd?vο?Œ3gL@Ώ’|^Θ, ŒaoC‚A₯œ ζC!#DI0Ou=5eAATK5α~gƒˆΏόύwVΤ ₯3¬eύ(3λ„΄<€«οΨ>ξΡ’ά’ͺ…&€ι~°T©σ/ΊΣE<aρ²/1ηͺXςί―’·6M=γp+--Εϋᅬ›oΎ9©Ο]qΕΨ΄i^zι%dggcώόω=z4Əό‡+‰½νΆΫP\\Œ>ψ Γ`ώόω2dˆ,CπτΣO£¨¨kΦ¬ΑΏώΕΥ²ϋ|>a8η­·ή*€ΤVEEn½υVL›6 ^x!Άnύα/ϋΛί"$™`K-‚Ν•νόΊ/΅Fœžd¨Κ-Ρ9oςH―X+€ρ‡4#χ/Σ¦CΝD£Ρ(«‹κS" ΝϋN΅‘R‘ˆͺŸ‡Š3 kλ›ΡΡιιΡ&λθθHŠ GΊ²²²πόσΟγΞ;ο4<655Ϋ·oΗ{gΰφΫoΗ°aΓπα‡bώύψξ»οpΩe—a͚5°X,ΈύφΫρψ㏣££'Ÿ|2ζΝ Έψβ‹QQQ«―ΎΛ—/Η\ ‹Ρ—/_Ž΅kΧ"%%>ψ ξ5lΪ΄©ΗΏX―{ΛρόλΛBWΑ-¬‘ΡΑΤ ΙIjOMŸv&ΐHι‡(4Α‰Ζ(Ό~ΝΘ]Σ λyκ Ζ"cu'λq’d”Œ2‰BΠ!.ΏGά°‹—^x† *ξ1θ―iWυ~ Ϊ€m6yδάwί}ͺΐa|y½^ΤΦruθ;wξΔλ―ΏJΌyΗw€eYLœ86›Mβ[o½;wξΔβΕ‹pΫm·αΌσΞCHA•έέݍηž{@σζΝΓ΄iΣp"―ΎςWΦ ΔXpε5!ŒκΒGˆ™ω|4Ρ§GOBχδ@@C‘β™wωt@R@˜“($QŠH”κΊ"†RE‹ΖΙBdΎ!Hq€Βš WZΖi₯@uΩΧ²sυΝΓ%œ!Π–'«τβψ-[Ά`Ϟ=†ηΉώϋρμ³Ο wjλ»ξB(Œ3pΥUW©nΖ²²2<Θ¨ >oΌρvξά‰’’ >wήy'f̘!«ΓW/ΎψλΧsC4.\·Ϋ}Β*€φž~ιYν†=c€ŒΥΊρ9εv-wέΘυcY#q& Vj•ϊ=>€‚D£T/  )ΟzY€¨ΊdQω¦όβ͎‘·μJY}(œ©Ω(: #¦ΜΓΘ©W`δ/ζα€qη£OΑz Τ IDAT‘«@ΠΧŽΞ– ΆpΓΥ`πΐδ½€h4ͺΛhλυzqε•W’ΌΌάπ\7έtώϋί’°°Pυύ={φΰΙ'Ÿΐ1訑ϋ„€€pΕLGŽAGG^xα°, «ΥŠΕ‹ δZ«««K¨߈Mίξ‘ά# ŸμίΗˆΎ3g’Ž₯'ͺ†R7Ž6ΌFω1ΏnU’¦<'νD£!°AΞυ/”ΠJͺ₯EΟΑιΞΒΠQη pΐ8Xmbz/53ƒFœ…ό~£xeΞ‘"4ΧμB4"z8 Γΰoύu?‹ι†Ğ={0gΞΤΤ§g̘εΛ— 9Κυβ‹/’¦¦Γ† ΓυΧ_ŸπώάΉs1hΠ ¬[·Nπ–,Y"tΪιY~ιZ΅jJJJξΎy΅wxπ[KdCAmξBΨάω&Œ₯9%AˆΊ₯₯"¬o|°|ν Ηί—(όϊn„ \ ­Ί,ΖI{Pa€p”E0Μj^“€^jd`t©Δ)ΐ  ?ΞΤ ½A[t2R\’ΕτyšΠΥZ%;~μθ“pωΕg%€Ζcs΅•όό|μΫ·Σ¦M3Œ3Λ–-Γ€I“ή«­­¬σƒ>ˆœξ7ηζζβΟώ3ή|σMTVVβ»ξšmšššπΖo γτΣOΗ¬Y³ ―!˜RX'ΚZ²|=Φmά!Š%±pœΔbΒλ7 ©gζ(€™*σ!Ώή0νyΙΆ’Χ6τήΦ”g=Π₯εCQ¨0nΘ+΅ψ€a3žΥWφjUuB‘°PP™βΜ@Ÿ‚Rٍm¬ΪŽ˜Δ ώχχΧΐνv&u#[ZZ4­€έnΈσkkkqΦYgaεΚ•†η,--J‡• 磏>Βή½{…qZΏώυ―±yσf<ϊθ£XΌx1ζΝ›‡―ΏώZφΉύλ_ΨΉs'RRRpΫm·Πq}Oןοώ@PΨ6Wμ©} γ}\BηE­ΏPJŒ UZ₯°ξD{ Ύ˜%¨i­pνRz#~/ΖH‘β'J έ`I ©ϋgψ‹”I~pbwl^Ρ0τ/& ₯΅ϋžAUM¦O#όπ΄Œt4F,βΚ,ψΞ¬τμb‘ΘΘn·ΑαHΑ†M;Lo¬μμl\zι₯HOOWυ6oή,4ίψ|>¬X±₯₯₯6l˜ξySSS1sζLD£QΰR™™™8ύτΣ1rδHΜ™3ΈξΊλπΒ / ²²R«¨ͺͺΒ΅Χ^‹’’‘=ψ§΄:=^Bpκ΄1Β΄Ίςκ¬δšx’pωΥ­³²ε]―V­ͺtΫ ₯ Ÿ3λσύ―zγΓU<„OΧ7b_e·ΦGvA₯@ΟΝοξ(XJurϊ:?ZΓώΫ©θ_zJΒλO>χφ¨ΐ»ΐ. I! JN:UφΝ5;π΅ zŸa.œ5Γ†φ7ύθ[[[ΡΪͺήPa΅ZβωΦΦV\~ωεXΌx±αΉ322πΘ#ΰή{ο•Qt/\ΈΥΥΥ°Z­X±bf̘/ΎψB΅|WΧ/Y²)))ψνoϋ“τ^]΄;φˆ€,c±Γ•?‰M@f\w*dͺRhVH›“δ V ιΒ¨²κZuCMYΦS΅ΓOb&F+Ρ‘3OεYš{ŠœΕζυEŸbω*ΡZ>ππ+²jΑ΄¬ΎΘΜ"μ_+;GNŸL\3\ΨLφπwtt ³SΥ[‚ΕbAqq±ͺEΎτKρK/Φ X,<πΐxόρΗ֞ŽŽ<ςΘ#ΈrδΙ“'›ΊΦ{ξΉ«V­Β‚ z•γ„ρΊΌψη+!·©=΅/¬<ƒPSίϊSͺ] Υ°žΘHUΒιι4Ίύ(ν]m“¦ˆς²œ΄¨R_lΜL©"UηHS u§η";o?Œ{­μp5žY(οH’Ηά%₯§ŒEPA'ΪκχΛΎqήΕg›.ςz½ΊΕΕŚΦφΦ[oΕέwί­kΉγλό#ή}χ]a€Ζ«―ΎŠ7"''7ήx£ώΣ[»wοΖ9ηœƒέ»wγ§ΊV}Ήλ6IAΖ GV©&ϊO5Μΐε6¨8TξσxٚS@ΤSΒυ{ύQttGτ@UO@ 4¨GΌΎˆj& ž FεŒ?vGͺΕ°,ώϊΐσ‰Ώ$Γ‡Ÿ¬F³„©Χbs οΐ‰…Γ’΅n/’α€μ™>|οL‚•\&@K ggg#++Kυ=–eΡ§OSΒ sζΜΑΗŒ!C†€eYάsΟ=€k―½γƍΓΟΛxyΊύxρΝOΰυ‰Πζʅ՝kJΈ%°΄§L ΗΏyšΝ&u‰λH£_³C—Ε–ž(€ύΠΘΓ¬j-€Πp›¨2Ά£oΙhΩkΊ•GκTO]^Q‹e+6€eΕοΟΞ/…Ý…xn5θkGGσaHλ°†Ÿ47]7ΗΤchhhΠtεσσσU9Ύϊj΄··cΑ‚¦ΐ±σ|ωε—చ8%ΧΟˌπ-VΩ, cCJz±`Œši’c‰ΆZΏϊUy*±5y΅‘μϋU ~DbšŸ 󲜴¨…1HkgκύϋΠ~5¨CɐSX «έ!Ρθ^,_΅1γΕΧƒφNp2[Š 9}‡ƒ«pζΖͺ­`c tΣusΠ· Χπ1TVVj_dgg#;›£¨v»έ˜1cΆn݊E‹izF«¨¨{φμΑΔ‰±pαB΄΅΅aΚ”)ΈςΚ+–n“λž‡_‘ν2«3‡ΟFι3I›rΛ%yZ₯›6θ1ώφΜ¨m ¦­bΌ,'­@³?ΆΊΡΔT‰xΓΈbψ“‹Ž—ύ{ΗCΨ½OΏΤ6 αΙgΙξKnΡ€8ΕΪ{6Aύαod5;+Ώϋυ军©¬¬L΅9ΰΈπKJJpξΉηβ7ήΐΊuλd Ί=]‡Ÿώ9JKKρΜ3ΟΰΐΘΝΝύY²MϊΖVlήΆO νi …AZ`RlJέI ‘ƒX¨Ξ β_4yωΧXυ>­‘`Ί2lFhVΉ4Ά!gλ‘EhBk€T˜£sΊ³αTτE"Q|½y§ ΥΥZΛVnΐ·ΫχΙ^λ?μtΒΠήx>Ό½υΜS'aάθ“ C=κ«ϋξ»οΏ>.ΏόrSθέwί55!ΗιtβΡGΕΧ_sΟ=Ο>ϋμΟ’ΔΪw° έ&¦ά3Ή–€jεΏz¨ŒZj=Š‘±-Ψ#6£hͺ•Ά ./ `/₯‰QBnίΛΎξλν¦oΘ=φ*§,ψ―q₯ε"·h„LαΤ\'#WΘΞJΗυW―Ϋ-ΨΠΠ€.Νχ‹‹‹U …”kχξέ7nΎϊjœrΚ)†MDK—.Ř1c°fΝTUUύ,ΡIh4¦b ΕzJ€jπQ}’»„sYŽ?W_ ¨–έBKGΈG2lF¬Πz#fΡν‹j,’Q@Y ;W^ ΣΠ؊¦fσ³ψͺjκρζ»Ken[~q°ΪΕz‚ Ώ­΅ς4Ωy3OΑ„±ϊΔ’=eŠF£8pΰΎϊjŒ=Z¨¬¨¨ΐ©§žšPΦ …°iΣ&LŸ>]tяͺfx―RΙhqQ©NμOŒ’Œ k64 qόŸΥQD#š ^QUmžpd،¨Φ=j} RHς?–ΥΡΌqχ?ώξ_}›ΤgYŠO?[‡#ΥbŸΥζ@~ρ²Ύƒφ¦2„ƒς2Ι'ώC Ω΅mΫ6άqΗ˜0a‚0`CιY\~ωεX²„x±zυjάx㍘6mZ‚bψy%·R]˜6Iτώ؈ΰ3EϊΝ?C;tFV\ΩΪ(j(έ`Kέ‘Λ°™ςΈ΅ΞP=s½Fdsδ¦D~#©Α­uΊ24σήύΙ“jV©Η²•pλsΑXB™;πΆς^@;:›Λ[2„/6*Θλƒ?ώζ <΅Puόvμ؁kΉΖΤ5444ΰξ»ο&υ{ΣM7αΩgŸΕΝ;ΙTΕ—„ΐιHΣ™—Σ§3)vμvμ6RR$o·Αbaΐ²œueY± –eŽDFG… G†ΈΓCθφωu³<=Ywάzl6«°ϋ"ώ~ ‡žϋ/‚„ ? ŠcŒ’€ &!³§ ‘ze 1K‘Ε…³g Iο8‘S45ΏnvsΝdε—ΒζHηΊ+ΞΓP^™˜%1Σ\C)Εc=†‡zέέέ¦¦ΦΦV¬Y³ζ{΅–ƒ£€8…9θΧ7ωyΩΘHKEZš i©.€₯Ίαt€ΐεrΐ‘b—WΒMP –e † …Πνυ# Αλ ’ΫλC{‡­ν]hiν@M}3*Τ£ͺΊΝ=Ε>ϋ¬_ΰΖkΞ—Θ‹°§ZHί©#D7φRςkΗθ1Jud„κΰlΒοˆD)v–{Œdχ¨ΐbU{Γˆ‘½3ˆμL‡Κ½εά!Β·R1$!€ΝζyΡh mν]=ΪΑP{μUΌφά."+(ΪφΓίέΔ}/Cύαθ?βQάΈυ¦ΉψΣέΟ$­vμΨqΒΔμ'—ΐψ1ΓpΚ/FcΔ°AΘι“ —ˁ» 6«v»­g'¦Ϊ‚Α0 \.\N²3ΣΥμ.ΨX αH‘pαPm]¨<€έϋcσΆ}Ψ²}Ί Ή\4ϋ[΅ «Ύϊ塇#°Ω¬˜8vnΊφB\pΞT…'C¨³`£u9“TeΗ$fˆό’ΤM5!?‰λHƒ­]αΙn2 ΰ+§)߈Ε(j;Lz#υ;©ˆ$Z’ώφ„NΦίS&„ΫΕ‘€ΨιΘλ7-5߁ηE}Ω 4„X@€ΊψΥ5bϋŽύKκβτ`jέpςΙ' „ήεt _Q† νΙγ‡cφYS0dP?ωs $+a“τΈW=ΙFΑΐΘ€lŒQy‹Θς― ΰΧ@s{|8 ?ΐŒ ΐXΐXμΒμHm”\ά7cGΕΨ‘CqΧ―C8§Ϋ‹ŒτTYΌ/~žE¨£aO•βΔY‚wΕω0MD€0ΈΙpIο·+ˆ―ΎkΥ»€― Αœ¬Ψ¨¦ ₯=„@0 §Γͺ’Rn 'Xq£¨ό –}αDSK;^}k ώπ›+u“Χo4<­ˆ= "!/Zjv Ώd‚°χΟ £G¦ζθc/"£ φ"ρ‰yαsul”€R^ψωtšjŒ>) BΖ† υK”•Ε‹ΡpHφΓ!ΘΟλƒκΪΖ£š7ίY†K.8₯ƒϋŽ**§h$ͺ=M `ΑRŠΆΪέΘΚ? W¦š˜wιΩψ`ρjΎ¦ΊD Η΄\·0Ώƍ.ΕΝΧ^„3gLPέ‚DβώΖΒΔB]ˆϊ[ ΄rρ;ο†KϊS5 ’tƒΉkWꆄ οA Φdά›8φ-φ ΦζA°m©δ©ευ]ΈCO$ˆ6˜gή' ϊΦ_ˆ#¨†W`lύΥΦͺΝΝfδΥ86Ή;y—BυŠ*λύS°ξ4Vω‹~o‡¬Ν8ω€½&@χ=τ’μίι}ϊ#-»ŸlsΦ•o[[› Ώϋυε°σS|δΆt…Γaάύ(--•‘{φΦ**ΜΑ·]ƒE οΕ{/? ώψ¦ Όε ϋ›αmά oέFxλ6!Π²_£(όΌβ6•ώa!ηΛ“πθI­·τσ=ρ’πS1}^HMα'¨»RψΝΙ=-α‡ͺυ§ΠΰΖQΤώktιΡ‹*·Α‡Γu>½Ί,ΐ=U°A TθφEΡΠ”iWY ΚΖΛ‚ΕΔJ$D,&Q¦LΥk‚΄cχ!|Έx΅μζχ;ιtY…b°» M‡dΫγŒS'ΧΠΡΡΑα]]XΊt)† ‚x^―χ(;ΜΧ­ΏΊ«>~ ~&Œ¦zώXΔΛ^xͺVΐ[·aO5’ΑΡ•N4‘΄h$I4΅_-Φ7~ͺӊ e–š%ϋЊϋ₯ω{jβ·Bόεώu’αu'*•uί΅!¨? xƒΩ-ŒX@uΨ±ΏΣ0₯―ˆΏέΥ./›9|p― ΤS/ΌO·¨)-V; MeΡTω O"zsύΕp9ƒΨ½{7ώσŸΰ’‹.:f…?γF•bΣΚ—ρΰέΏFA^X,Hη%±±0"ΎtΧm€§ς3„:φƒFƒ 4 "±΄ρ ΪۊIK”‚o‚!šΒ Δ¨†ΥWUbw‘©Aμ―τ0”Œ?¬Μ( SΝΊθ„"6 Ίξ?› ΒQ[φuθ1yyY5΅,I<ϋ(Έ’`Υ9Wž0&Κ†ΥΚπ|ό\ i!‰ŸΗ·8)ΨΌmZZ;ze³†Β„Ba̘6ρa ΞΤ>θn?"‘1P°HΝ*βύŠςQVQƒ½*πυΧ_γ7ήΠΪΣuόωχW㩇ώ€œμ ΘηEΔBˆtΧΒίόBε`#^τl;!aλOΘ…U₯‡ΐΗ fΩ$ΞcΤ)BU¬$5;« ?Ηφ«jύγͺ€j†¨‚&~–δγͺzήXvνIΐ_xλX(hp΅Ϊ,2Σ¬(ΚsςΒ-W C„\‘A0ΰAɐIⱄA§Η›@τq4«ΆΎΣ&FnN¦πV›]mGΈ›LΈ¬@jV1¬v§ €§όb4}Έυυϊδ =Yy9YΈνΧσpߝ7βτSΖΛ„žυΓί² ΑΆ}ˆxkAc!r―Χήʏ΄H¨ΎμE$BO$ Zrθ6«cυ͍zΒ¦sεΐŸTq%ώρΚοy/ ͺΒJ©{OΝέk΅ίυΥφ|όeƒήG όX)€rχh…ΑP γOΞ§ή.I ?>ˆπωΟΤτΈΣϊJ"ΕnΓϊM;ψ±OGΏ’ΡN=e¬3Oqf ΰiF$ΤΝ]εͺ³Še”b·‘€_>[Υ»@ίΌ‹ΟΔ³όη=ι©’ˆςΚ2Π~ήΊ\:ŒFΔ{¨bΕυ…„H,΄τ Ζ-°JΛ.ηu ¦v±>υ1'άjVTq^V³…6ρϋΤZz‰Δμ³&Σ‘ρ¦έ0€Rέ"cλOU<ΪήXVC5šž` ΐuΙμG¦{ψ%­7Z‚¨o ‚₯r|I©Y₯Z·ΆBΞT:€?ƍ9©W…ξ?ŸAYyD€,Θ) b± 4ζ]Ν tΚτŒ0urο“ύ‹ °δGράcw`Π€"Ι|xξO4ЎφςΟΰkΪ!τˆI0J@’;-'Θ1ώί,ΛΖ„n3鱉€Τ”Πσξ]Ο@ Iή©€ΠKγiΧόλtAGωθψτ £iν cΝΆ–Ιfo*€—5ΥK±·ά“`-¨ t–“tw5£³UL·Y­\6η,‘žΏ·Φέ.”m¦ΤΜ"€e—Θb˜†ƒ_Κ>γt¦ΰΏ€ηέrJŠσρΧ?^‡-«_Ε”‰#${‚Σ<‘@ΊΆ‘£rb(Ί”Š”X¬\ ­%₯Š@ —YuVΤ…ŽκZ0ύΒΎH†Λy‚ͺ φT(ύRίx­ΏF½?SxPΝ^©~U–όͺΉDsξ_Ό¬ΪθY©σωζ&ψƒ±Ιfo…7gμ"yͺzœeQ: )6‹@±D$ƒ@Αγ ‘ΌώαrΪ9…CΧΆ¨05uM8Xv€Χ@k['œŽŒ3Œ‚Τ¬b΄7μŠ”bΡ« τ‹DΦ_ιfΗΔo’κΧ”θϊ³Ši?Ι7ύ]7Ί‚ˆΕXά΅p:½š•²{<Y½©’άT‹δΑJ ΘΙL@-"›¬LnddΓαJ^3r>b³lβΛΡύ«0ϋμ©HOsσΰ€vG:ΈšΣ'Ο<όGάρ»+‘›Α1“8G {ΡQ½ώΆh¨c…3s2œGF K CoEά ŒΥ !hQ$s‚.U R!λrΣmͺI¬oωπŒΆ±•ΧΤ0&2δ>υ§bσΡ‰ύΥ”&ˆτxΈ‡aκšαΦύ―μGm³&0ή ΰI©Xz(Kΰj©Ύι cΤΠ ΈR}₯ γ–Ρίέ†Μμ"8έ™‚Ζξ[˜6ΖbϋΞƒ½¦Ϋ0aμ0τ-Θαa`³;ΡέVΙά@4μ…+­φ”T‘gώŒιγ±θΓ‹!sϋdβšy³πζ χ`Xi ²ΟύΆhΘoσ>tT―ρƒP^€ c±Α‘Q‚Œ’ipd !V± ~4·tΐνq«#!Oh,"^%Ψ₯RߟTjPΏ‰…R­–Ϊdίΰ5SΒO…<Ώ:Ι‡agUΛ}υάzΦx%ΥαΓ ζ,ΏΪOάq¨ ―,©B$ͺy’Mž9^  —W–Δx…Β™bΑ€"nΰ'#­ ’Eγ€ϋv΅Χ‘ο€Ρ`xΆ ‚α'D}C WΦφŠ†Β‚8ϋτΙ`ξ‚μŽt}νψjF6ΚF‘Φ§?OiΖύ< ŸX/³W_> όε&\6ηLX-YΚ π4μ‚§a;Bέυ ސ#£?2ϊN€«O)kŠ$*"Β¦Ψ±§ /½ω ž\ψ>ƌŠ‚Ό>B&ƒ±₯"ά]­˜MΧ™ΛγλΥκχ@π5Κ\ώΈb~¦4Š}ΈΌ=1Ω‘n‘ί€hύ@ίυ§FeΑΠ-€zυΣ*l?Ψ©'‹/‚+ΒρR°ΐmT'a6΅‡0id¬Fšϋ—ybΩ !@,AΐׁœΒRαX»έ†ΙγG Ό²ΆW: ¬’“& ηH4β€›3]-eάf!‘@ά…°9Σύ\T˜‹CεΥ8\U‡_Lρg\6ηLτ]u:MqF IDAT^ΊjΠrψ ήJ‡Εί ΐbs!«t€ζ ƒΕž&|Nzo֬ߎ»ώο%<υβ‡X»α;44΅aοJ\7Άψπμiˆ†:Α†»5mŠkYscσΝ •ΩZC5‘N —Ώ§ν²DαϊkχTϋυΎ›•άI­aŸΤπΦθA0r₯"?‘~\©7ώΫΰͺdΑΏήPapνΑ“T‘Β(…#Ε‚ώ….‘«TCπΟ‚€―6[ ³ „‡ηp€ΰά³§’Ό’•Gκ{E lάΌ ΏΊζB!F·Ϊˆ†ύϊZγn žzdŽΒ›Υ‚ώύ 1uHόΏ;GA^X­‘Y‹R°‘Z«Ύ†§a(Κ¬:c±Αέ§} kJˆΔΣαhΘΆν<„λϋw<σΏqΈͺ r΄Άw‘ /cF Ά΅Ν•ƒ`‡™Β/’ˆ‘‰ŠH½ΦŸ^ΚP³ž€πQjω‰δ>« ώ”šΠwDΓϊk3ύ˜rρ©z?ΏΊϋŸ˜R\Ύ© ‹Ώ­ό{&Θ?…€ώ€υf{WcOΚˆ9Δ`%‘rθξl€+­\©Y²΄Ψ¬³¦ Ž`Α*DcGΗ!θσcYL™4RIR³ŠΠΡt4γ ]cτΎ‚’ΘΟΛΖ°ώ`#sχ£aΊ[ ₯βKDC]qΏ†§Η³ΐ™Y‚μ’ipe–τGˆ‘Α“ΟΏ;ξy Mκ³b1υ ­8ζTΈέ<ƒcγp3z»τ79K―fIγ9qV@P 1Μ’ώς);κ©>‰Ϋ―Ω©Ξj~‘pυλvόiΆόšσjΈ-xΌ(zσ šυG] ΐσ})Έζ Ρͺ.B”Βε΄ €Π%‰}‰Ό)H‘ΰnF ]m5HMΟ…“ŸΜ/&ŽΒΠΑύPu€­=€―ύ‡Žΰ¬Σ&‘OV<ύΘ Ε•Oλaαϋ"ώv€υ‹Ν!dτž]{Ρ^σ-όGψΊˆq~Z!²Š'!=,6§Μ$„ ©© Ο½φ1ξ}δ5¬Ϋ΄Σπš›ZΪQί“Ζ C »ao=(RΩp€—˜sρ% ”›σΤ©Η΄AήO ξ^S>v—ΜΤΔ5Τ„?&ρ—{ύεYztχ–ͺσ~Ή½o}¦ μΏΰΝ£y²–^Ψ;ΐ5 ¨*ωP˜ΕΙƒR’ "δFχB ΓMIŠ’΅±)ŽT€eδΙτΐ’Ύ8ϋŒΙΘΚLΓΞ=e=fζXfύ8ηΜΙ‚²Ϊέt7I‚,‚έMpgΐbsHAΐΣ€†²Οαο86->,VrLGFί±°;2 ΛGσ‚ϋήΗ«qϋέΟ`ω› ωο₯kΧΎr\yΙΩBV€06PΚς^€žϋ―&Θj™΅ ‚Ή4—Ξ¬>MBπ‘ͺΤδΒ―οφ~‰5T˜ E$ 'l5κχ7Q?πOνB»GΨΏ„ΟΘ}― `0Υ’y―?ŠΌ>δ8D‘‚rτZ€Rt4W€±X‘–Y AΗ œΞŒ]ŠK/<ΡhΛͺΑ²ΙkβΊϊfŒUŠ~Eyάw3 oG΅p­±H]Mϋ yφ΅!Π݈φΪmθjά ΧΛNέYQPzμlΑJΗ₯ϋUαWΏ―Ώ³ ]ή€―ΩΑλ`φ™“…ΧlΞ>yŽ€²aM¦ΜΕϋϊ΄ξΊΒ)£yŒ’>ήΌπJ―-όΚζ'j‚* D§ΝWξϊSΓ{£χ»Τ‡ηrλγ/λρο5ΊxΧ{Αρη±VQ‰œ§v> Ž1hτΠ Ψ,D(κ@Ε΄ δ˜Ά|pΊ³`OqΙPs—ӁιSΗΰΚΉη  ?lŒab±’±˜aŽ:cαt€`κδQ°X,B5`wG΅¬Œˆ:π6!δmF,βη=IœŸή9%“‘ž?„aδιNT©Η‹o.Αm}΅υΝGηvν.ΓΜΣ&ρυ όΓLΙ@Έ«JaΐΊLΖψ˜Ε·‰}ό”R‰P&/ψbŽŸh‚}‚P D!ϊΏK­PQ‘hί5£«Ω~υχ=Ύξ|nΊΌQ=ώ―0Iόi>Ϊεπ€Σ΅ΈlfΖ Λ% 0RAη…Œ«$Bρ #΄sκte ―xϊ«Ν!ώ"OΑ54Ά‘Ό’ΝmhhjEW—^Ÿ1–C8v€₯ΊPŸƒ’‚ θί₯CϊΑΒ£ςMϋΡTΉ‘«_౩ηΒ]όZ€Έ³‘Υw ά™Ε DZ ΐίΡιΑ{­Ζ{―ΖαͺΊ^ƒΰƌŒΥ?›MlVς5|‹PW…A>Šˆ–NJ *tΗ>™ι œ4!^6ς©ΟςSβ)ν€­½‰EJq‘N¬·'nDCΰe B!Φπ7‘I*ρ˜—Wβ™#¦νΡ~ ΰ<ήϋ>ͺeν₯½π€©Π¨ X±± £†€ΓbΣN-qE,Rjΰ`ΐƒΪΓί’΅~?ϊœ€‚’1ͺ£° ϊζϋ=‰‚•d , lV«`ρ₯p>‹ΐΣzX 2K4;%’½Ι.…¬’qBJOΉ–―ώO>–WχϊDά=*ρΚΫΛpλ  ±”3wΒέ΅‘ŒΩu$E/ ζ „ύm*ΰLYύDΟBΪΡGtB 58N+ίO5ŒΔ ?Zi?³Ω‰ž m³ΛΎnΤώ/k½$cιΕύΈ ΐ5rT}–—Rά/Ut›‰V8•cD°††αi=‚ζΪέ°ΩpΊ³ΆρΏi,†‚i³Αf³Βf΅πΥ†ZqξΑϋ:kΡV·+‘TλβϟβΘ@ώΠ3―Eκ‘ΤΧ·ΰw?‹Όπ>ZΪ: O₯ Mm8ϋ΄‰ΘΜps{±‚0D| ½μδQ•RC<ΠΌΠ«‘σDiΏŒΕXλͺX}wœA;žοΉλ―}~ξύ{M=–Χ-x; ΰΧ½΅‡,½Ό'χΈ^λΝζφN*ICͺΫ*p‰DLΘ”” ³΅Ν΅» x@cQP6ΚέXε"τ= Έ‘d±ˆA;Ϊκv£ωΘUYJΩ?IαΠ3`KI‘@Cs;ή|χ3άς§Ηq Ϋ™΅VK['ςr2ρ‹ρΓΕPΚζBΔΧ &Ζ«:%ώDςŽ‘¦’yK μƒˆς‹{‚j2ωΞ  Ωι\“œŽX«ΪR]TQ2IΨHψ΅­c[wΏΈΟ¨ηrU½΅ŽEεΘbp‚ͺkμ° \6³ ΓπΌ"Θh:#ρ˜ο ΔnCΞς3„ΐ–βBŠΓ »#6››φ·D pn.E‹… ϋ‹ zAiDUιΔ± %0xΒ5°Xm‚rψΰγΥxύέΟ„Α"Ηkef€bν'Oc`I_ΡOμ< _γV]ŽZ[ΘΥΊ΅ηj >UEχγŠW«©GθtΫzΥΑΊΔJΏ˜„ή+¦γΝ°†ΒoFρ <*Uw-άkΔχχ ΈΤ_―-λ1؏0GKΉμ―θFE­CJΤ)—Π1j΄k‘°±ˆξVQqΔSͺπˆBM@h’wτ(PpɜC “,!@mCΛq~θμςβήG^ΗΏ^Έ[x͞9φ2ΔB †τHθ΅η" p»!κΒ/ΈφDΓ‚RΘ{zκ#°χθ€γΜΝI0+ό¬ζ½Ϋ~°ΣHψ)/[½Ί˜c°λ<©‰`„Y|΅΅αpT7UO Κ]2jˆii₯m¨lΊbJ ?Μ‚PVζRκ»³”ηOuΝΌYθ_\€οc-]ΉkΧ'ϋ‰ξΎS4-Έ”ΏͺŒΦ’:τ[ΚχΝY|υϋ/₯ €tΚj©’ρωi\•‘iΉύT’ο':֟˜Py¬jφόΑξΕ0£χ$/[½Ί,Η`/²ΰˆ ζΘP; ΓAFš ύς’²XΘrξ2Ά\I7a`(X 16VάA2ŠENΔΏƒJK;%₯Ις&&±Τ7μFzήPΑt»°Ω,X»~ϋχ’Άξ8€kηΝ‚ΝΚ9vŒΥ6F4Πj¨Π’³ΰ= i)yˆv^_λΕΰNΪ—_>κ³ϋ˜>(5π₯τ8ΏηΥO`Ε¦&½³Τ€λΉi>ΐ1”τ0]K=VΤϊ0aD&œv«€HόHύ?Βp5<1VOδ Ša₯άy©bnR°Ε2eަ‹Θ†›ˆνΛ±ˆ6G:R\Ω@XT˜‹m;’Ύ±υ{ œŽL›4RŒρœY{j@ΩΘqΊ ­rγx”k?NΩEu¬&‘{e&iΖΤ­>+Πz‰nΏN’a©/…1=―Έ4:V{ρθΏ‘ΫΣ3¨ΟψπX<-Λ1ά _ΈQΛ `Y Ά)€‰#2eq©ΜBΗΑ>™%WWbΨ ₯ ˆzŠQρΎ'™B€™‰ ϋې–3 Γ1ωΈ\Έ)XΉvKJ“:ώjhΑΣΗ‹ NŒ„±"β­;ΖO ^“؈ ½&Ί€‚šrΜ›¨6φΔ³ρη ΦΨi§¬9ŒœRΒ―~L ΑSο•cλέ¦ΆZή›¦'š ΰj4xΌΈ\· @‚ςΤ E( D,ίUpďΡST6ΨDω½PŒ:£1ΐ•Q$Ό^:€Ϋv@UMγqWέHOscΪ€‘³cKEΤί 6κοΕGkN,•ΤάzΎΤκΗ1‹ΊΎ uΚρέΤδΤbΠΟSt ŠVnnΖ³0ΊΩsT«ύb9Ζϋ±ΐ@c΅nQSkCJR‘κΆΙ›…Β--ΜQτ«» =D-ˆD$PQ&q–^Ρj@(Q†άA@,βƒ+½P-Œ>.YΣγNΕ£Yίν*ΓE³§ρŒΔ”χ„»kaΔσ§ΝD ΐX~¬™βoΨΣcΕ‘4πPωΏυ! V.Έ*_@υύΊΌ~”5WϊlͺΨG{Ό₯U ~άφδNΓΊΥ‘oxκXξΛq؏_ƒ« θ£φf0ΜΒλbΤΠTΖ"ΑίΦR–W"§S•žC£’Hž΅½±7“{ͺ 4ዉba€Έ2‹…π!';Ρh›Ύέ{ά@4Γ‘šFΜ›sΊ ΠΦ”LD-ό€Qν8ݜ§Iδ"¦BΖi&ΫH€#ΜT z¨)οͺuύ<ά#v΅YwΝ Ώ4+`τ»΄γ~J)~σθT5κVσ–Έ€DW~MΌ+£Ί-šΫCp»¬θΟ‡@ΌΩ eΐρ¬ΐη'­τ“a˜‚ZU‘6vc#"E#δoƒ;«?¬v—p₯ƒϋaέΖhiλ<ξJ βH(Βπ“Smξ|ΫχkΖη’"N% ^jγ£R]}‹/mΰ‘†-τfP~uTVΊύΤΔ5S˜κ0j#~κύΓψlS“‘kσ[ίλ}b9Nϋρ €1†ipθˆƒϊΉž"”2$Ρ§”'!LΒΤ!™2lj’WΙ SΡΧ——"KΐΈ‚aΈΒ{™rˆψΫ‘–[*|Ζι°Γεp`υΊ­ί ΈuΗA\7–0ڌ06P6‚ˆΏ jœρvΫDΕ ?F*πΙΥQΉ+dκ!G)ψ§χH[{`σQ‚›',Υβχ‹―»Ϊπχʍ\%ώ€ύ±(ΐVσ€jZ­ZF N…Λa•Yp(bxμ’•΄Ÿ$=X °ςRν-ρdΔ2`(kψΟΔ"~Xm8Rs…ο+*ΜΑΞ=ε¨m:ξ ΐ”βτic„ϋfuf!ΨqˆBIlΦ€ωGͺΜ „θŽ…@R]p/yΑ—~+1°Δ ’μ=MΟAοwjϋ@[Wϋμ^Τ΅κNΎnβ]Άγ±G,Ηq?vπhζ|h0TB,Ϊ»B1$V YκHYΈΓ+8oΏZ―€!Ρ)(R²k‡βΨsς™a;άΩa±Ϊ9‡©XΉfσq)₯¨«oΑΩ§M‚ bab‘c–ˆιΆΈ•η›_€-»¦\}% φx3YΆΪ4^ͺVίΙ§0dυ•ΗύΪ…Ώrv”uύθ_ΨxΌφˆΗwν7Wπ­Z:Βp;-()pI… ;bL;5(νώƒ<§―¦€hΏΜΚΛ½F‚9PEͺ+Έ‘peφ”ΥΰE8PVƒεΥΗέ πtϋΡΏ8Ǟ$(BΚFz‘. 1οO( JHB95• ϊΤΤy©)«/Ž1—;›&ψL˜~Φπ\όw…Q­?7άσΩγΉ?u/8:qΝ΅τ«F¨τH΄ͺφφc)+™w―½G©tκ΄Ζ6_Twγ¨ΔQώ‘Ηϋ@Y!Vτ΅W"δm‘ŒE§ΈϋŽ_"ΥνΔχ±Άξ<ˆpD€—b,v“‚-Ϋ₯WΌŸPʏΪζοGάβKhΉ(…Κ(.5A3Ϋ‡!ZηΈ²‰ο#Ξ,—_όyRjFψ΅'QJριϊΌψ±a“ΨN^6πcWA7hΧvΡ€χ–Χ’Zš&Q‘^±\[ΪΤcπ\yα–VpΛ-•khXI‹=·iΨ°žζύ2Ίη’‚>Έγ·W|/  Ό’‘H²ΙΉρ{&\N˜©ψocεΚh₯ tΦ’λ«vΦ0ΎW’‘κ7igεGiΛ<xŸB’€u7ˆη§&Σ}Π)φΩ]ή…'ή)‡άΞΛDπ§ .½qΏžφcxγΣ#hο ‚R*Ήϊˆ.ΡͺWΆ²κ=\*έ Ζόnΰ½)Δεk=„ ·Qv!σ.>Ǟtό5n("ωΉό†₯¬Θ}/(*©u—{5J¬œJ£ΖΐMυyο‡ x?[ Χ3 ωθΚ«}ΘΞ°‘8Ο!n"₯@Θ&·2B±Pb?€ͺΠSͺQ=(-MŽ^bIYƒ€d%” Α•YΒ) €Ί蓝Ž_l9&7sϊ/Faι;!§O†Μϊ{ͺΧ¨‚qΙτψ›r=α§*^1θŸ7ώ~bBIΙςjˆΥ‹¬nΨ±β›&όώΙέfNω―π³H\‹‘Γ1–β@U7ϊdΨQ”ˏcδ|Pa bHb©/d„/@Z―Β+ c’v,Δ*ΕψΏ£Α.€€ζΒζΘΎg`I!Άο:„κΊή£z+ξ›‹?ύζr<ρΐ- ΩoέFDƒν2—Σ{‹―υ67 ‘ς\½4<#2KkRψ…Ρeζ= $ν)θσΧ²ψΛzάΚADb†η~ \©/~VΪk―4‘ςXŒbw™™ιv”ΈδVW6lTΩύG%S~¨MΈάƒˆ+u.A…2€:(½―iΉ'σƒC›Ν†μΜt|Ύφ[D"G5δY™iψεόYxθξqώΜ)P|ω›w"Ψ~π8<>‘ΐ3qϊ­΄ͺPŸ—OΧβ'ѝ' ’ˆυ…k§TχzV~Σ„ϋ^9Θ°Ογp}’?+ύπ€ϊια8{Κ»•nEΏ·‚ά#±‰ š•„’Š.@Ρ{€aˆΪg!’Hω (‹ΐοΧ-x°ΌΛkz|Γζ̞†ΗοΏW\rςr³χ‰…·αΫφχκC"ŠφBcά=“eS(dU‡ hΊI6E©΄ή‰ΝDζWΌΌ[_ψΏΪή‚ί=ΉΫŒείŽ―ϋ‡&l?Dό ά€ ½ΗΌ―’ξŠ\ŒUXγ/›˜MϊD€άΊ‹$κ @΅ IzEC]pfΓjγΘOlV Fž</[‡`(œΤM*)ΞΓ³ώτ›ΛQά7 #NK‘,"ώFxެEΔWojλ'’„Pαϊγ¬=D28“ΘžƒxY4‘œKΩC”/A“χT˚ώΈ|„π³,Ε‡«λpΗ?M±=UσΰvσQΠ~¨ Ό>0@ζ3¦ΐΎJ/όΑ(FN‡΄\ ξAκ P ΕΡGρeΐƒH ŒBŠlzhAΔα±\™„Χ³2Σ0jψ lψf7Ό>γ‘―ωΉYψΥU³ρΪS‹Q'Lpχ#Vψ›ΆΓΧΈ 4„r3‘ύI$Ν0"Ρσ&T€I“GΝύOFθcξB} /9 ƒyΔ_DY<ωn9žώΰ°™οp~ιΎQ€ΐrΣι…•u~Τ60iD–θΒK7:QqχAuCερDΚ‘©XΚ*€…H•O4Ψ »»lΞLAœ”`ϊδ‘θθς’¦Y•; /'s/˜zΌδ ΨνV™Δ"~š·ΓΧΌ±@‹ΰ~S™€*Σ~$Α]§* #Qq@%›Π3k­Z˜μω’˜Ο§ΞXCαgYΌzשּׁ5sΪo\Κ{?ΨEpb¬Aΰj¦‡8fh:Ώ¨in» œ #κΙϐ΄…ε ό©₯€žα™…-άp<†€†‡ ¬Ν‘ŽΒsΑXl’`ΐβpe6lήƒςŠ:tzΌΘΟΝΒθαƒ0ql)υ/„#Ş 0ΑΆύπ·ξζ'K-8‘ΈθTE詊RΠ Ž>; .πDΦ=iZθγƒF’žl$)Ξκf>(₯π’ψλΒ}ψ|³)O~€ q ιΌj < ψ΅0_CϊΉqΝΉΕ()tΒF:IX%Wά"©ΠbιΏ™Έ T6ͺ6ΜDrž”Τ|δ ™‹Ν!{"Β€dˆL8Α%βΞd uΐΧ°Qs/l…žΈέ=|΅^α$…—&SΠ#wχΦ@ƒΟSJqπH7ώςΒ^μ―ςšω‚p„75'‚PYN ΰπ€ΣτΥ;°έΑž 2Σμθ›λΰ01Θη&V¨b$γΑ`ψ‰# ~υΟ²?U°¦€Βjsƒ0α|†”Β).&Aψ£VZwΓWΏlΔχƒ{X‰]“TαPΠdO(3W”e‘μ c.AυŠβ>k·΅β//μEE½)vξmΞΒ1βω³ΰV7ΈιΒΕξ2(₯(¦ΘΛkπJC€C+‰)| >y( (‘›«4B ³ a_#!°9³9o‚Qίέ3όMίΑί²_ƒ€]ϊΌyΗBΐ…οβSœΝͺXκ£τ(&'>q4· ώ^ψίXV{_ُ.―©ΤύGΠx" Α‰Ήά^p•™ƒOŸ˜ƒygχ…Ϋiβ|.,`T)ΕγΟ0q€fκ*ΕΗiΝ†(Ζ¬Ζ«5ξŒ€83A¬)‚ΕBD­ˆ…»ˆΫηB­ώϊήRρσ¨wB…kOB≬ΪOύ{ΝΊόζ8γ―{ύ<πΪ,έ`šΙω]pE>ΎMNT ½ρσa’«qΨ€TΜ›Y„ΑύDΗ ?F"€‡Ϊ1ΎzΈ`‘bŒφ|B†KςΒjx/!” ΥΒ ε D‘"«°Z­€ΥB`³φF—ΆΪ¨/%₯’4.督«ύ},„ ήR|4@ΙΏzcΉ• RŠ·>«Ζίί<„Š:Sρ~€Gάv" Π‰lx€Y\z†"{{Pίΐθ!ι°ΫΩψ+9uΞ@Χ 2IDATΈZ_d$>ρBSθΕΖ#Š¨r>ΓΞπρ?ύ8hFή…T/±½t^ω<3«£;Œ?όc7ή_UoΐΤμ†6p₯½―œθOγΗ΄Γ†XΙ„†+#Պ?\9₯%n DηkγP/'fDΛnͺ²Ρg οώ§Ήlͺ†4&ޞΠB― u€=Ι$lb9#5ξ―κ‹aہNάύβ~Τ΅˜ζe¨δMُAh~Œ&f=Έ<¬©ίvώτ|̚š‡ά,/€ ³‹cΐ‰C ―πγ„?nα’.ͺƒ‡€ΓnAŠQρˆβi‘Hπ‘šφλvc‘;ΐ¦Žξ#Έψ²ͺΐ8θ§Υ(”€ό3¬€ΣΏ6kάύgπS\D•χ0’νιWΊϋ+ΎiΒ«λ°m§w_ΊΦΈ‡ό~χώ§΅2ΑU>ΒΏ©eeΘoοΜb£ͺβ0ώ»sηNg:ιNW ₯XQΐΊ‹ BŒKβ†/&Ύγƒϊ‰†ΔΔπΰƒΔ(>A%Δ€. μQYZllKW`Κt–Ξr―ηtzgk;C[J{ΎδφN'™Ϋι½η|翝οOϋj/o½°’ -~2υEΚΠΚN2Cδ_nϊq:žέ6¦Κ†¬%ψˆ5_Ίkš†i[ΪΟ^cί7ݜΏ:ΖDΌ Ϊ‚€\ρΏ•―Q°tΡμ/Δ˜Δ“VςΖξfVp§9μۍ…εΰ˜y㐍ΚάN §=X7Π[*0₯΅η€b‘…φ“.‹eYΔ&ΧϊCμ?ΤΓΡί‹Ϊ}xΈ²ό¬―εw€O„³ΎΊCcϋΦ*vvT³ΎΥO™ΗH·΄{ ςlrκε^gΚoOM94χ2C|0³ ΗέςίΣxdMxŠΚ˜¦™ϊ\0ηΜε‡;9ϊΫ0IΣ*τ‹†€/–λXξP|Ό\ˆ[Pbh¬oρρΤζ*vvΤPζ5DΪ0ε€—;rθxέNά.nj=«³eω΄”Τ—xΗΔBŸG9²H&ΥδΓ¦₯FœTL@13‡sδ—A~<5ΒΕΫ³‘δΞeξB4θZΞƒ_ΐžήφϊAέ‘Q^ζdΧ#5ΌςL#΅.YG½λΙίεΔς9‹ή¦›]-8ibΫΝnς¨Z)Νίτέs™ώΉΓ¦+h¦Θf:‘β·ξN?ρ‡nFωϊX‡; ΔH$‹ΊώA`Bͺ{ΩC@Ζ’lΎΦs‡;αΕmu΄5—αvιθϊ€Hˆ#-Vΰq9dν<Θ¬bžιά»υ`e‘@fP.Ÿ>W“=Ÿ…HšD’ φŽsΰhΗNŽjζΫqQΙw˜PC]ΐLxψ¨‘Θz‰φU₯lίZΓm~Φ6{₯ Ι”%PεsαΤ΅«ωލ!©ΰn`π&’}ΩΖΉΊ¨αΤ¨τΤU•P_]Bk£—ΦmM^Φ4•Rζ1ς$α²SƒS&w~wbϊIno•;ΫŠ$θξΣέ’g ΜΏa†nN0x#Κ­ΫρBλρgΒίΐ―ΐ„$œ‚"€»Žΰ1Dΐπ%f!FRhό@Χ5 ݁ө±’Ek£—UunΦ4yΉ―ΩΛͺz—Γι°ν,΄'τ4ΫO‘ΛΟEφŸΙ²έ€iK@$š o8BW_ˆώ0½CΊϋCάĈ',βI“dΊS?>bΐaD`ο$Π£†œ"€Ε7’’πuD™iσBώqΓ©QWUBm…‹JΏA…Χ ¦ΒE₯Ο ΌΜΐοΥ)uλΈ]N QLηpaιVΥ„χwξ6ί4/§hь\\nΣΠ1]&†n’&M30Ό©Έ9ΘX(…Π φ5g°δg«X΅q•Υ§ C˜8Ά€ΡΩ<Ϊ€κύΑ©Ο­t­skΎ@ΩϊF€ΕW€ΟΏ‘$ΫDΧ5\.Cwa˜&†n 4aψΨύ©ŸΗΛήfςπ]ρvό‰)Œ™C$fσΒκ­μόθ4sg3iX„ζ3½uΗ»Φ—ΟέTΆώSpšx.ˆΕ™©ξΫ―Ÿ™‘iρ^k: %t„ΠAθ€Ž+!ƒΥoTS΅λ#^yσ ¦oB$xLζM/ΰΕ£ψΛς1l{u9'šΟpηΓoΰMΟ―o)bxΆοφΒ-‹κΛKt@לAaχΜΎ*·Λ@ „†R: %4€(š¦Σ`χ‡΅Ό΄v3Mν~<©E<τΧL[ς³ϊOjšόdΞςΪ3·°`#;ϐœœΒνΧεά³ψ‘5ΐΦΆ•MΥ€Ω9ƒ<ΕΉtM „pœ @ ”pŸŽ‘ΕΛ5tuχ`Y6O?ΏέτςΘ=3Ψπβ/œ™Ζ²‡^€1”ΐΤnfMŸΐ†Κ0<Ι”LĘa‰Ε΅GC%υε%BΛKχjΐŒρ…πϋΜxW(Jhρ  !Ρ@θԞτ°κ΅·Ύtu7Wν¦²ϊ3²Œβ•'αOπςΚ†=膛‘Ή©œn ’t~―Ι΄ρi3 K«”VXZ•bκ’x`ͺ‘iΨJ9Ξγ%—Nφ’ΖςΫ'ΧŽ|m’=ύ·0!v8H^NgΪ:Ί‡ž(n· ‘lΣΖ₯’0g鐖‘ζ•”θŽ[rz­”@Jη‚@Ζ8–?VΞή έ‡κ›xiΓ~43@Σ5{1ebΊιgχG Œ5 iu£ŒšD’Ο€i@²ίgΈύ^#>M…@)2>5u—‘4e—SΉύ½KΛ?Φl&heβrΉωαβξ^:žη76p€ρsξ(„Œt Ÿ['9`Ίd HvΉ4t]Γ– ”r&Ž7₯€]‡\\wλ“l}ϋƒKcΆsͺε,OΏ°άtGoΛ'jΒλυςηGοbHr{|d‹xKS]Ι iŽ©$Jnz1R'pΦqΰΠΡo…ΥΆ‡ύ΅ΫX22ΖgEv”§ΊQ2ŠξF©/pΓΑ2MBQK"₯D?΅Τœ‰ΊΖ‘ΖΞΟί…6ΐŸΚήDχηΗ‹υ νΎ/μ¨ψ© uΕBκι΅d_ΨB& 'Ꮼ!ΰU<ώ«Ÿ8(§œp΅―γq»ψ}ό><ξ8Aͺωψο~Ž&B8€₯d΄PXΆ’½+&O·…λΪBΡ‘Cς«k₯ώΘqΆΌ{„…%£ΉrvΧΐ4 b1 €‚‘9LΊl$γF %73‰€Χ…BΩ‘ρ³³?y–‘™>€έr)A"-!;θκΥA£n]IkΡͺwZƒα‘αΎ>R€°rΥfζL-ΰξ[治汘E^Nχέqσ§ζγΣBv:„D‚’LΙ7Z62z )-Δ9²’$Ά’(ή―m£­#ΆhֈίφυM΄w΄sωθLςσ²θμκα+ΆP˜λ₯tαLnZ<“­«`ιΌ1 ’Ψα 2ΑΆb(ΫBJ[ΪH+B¬―Σqξ,P2>ζ•’ϊ“6€zYE“N;b3‡dz§x{Xxύ5„£‚³ΑN.;œ’Ι™5m<{jO²δξgΩςn7,ψ>ΒξDΔ³N©…PqŽΩ μσο;χ΄ςχM5ΐξΐq‘Ξθ΅£†'@δWMΘbξ΄αώ+δΠη.n½οΊ{ϊ8έdpξpFε £(iƒ’η{§eΆγάBI%mϊϊ’όξωCœF«//ω°l}£Πϋθ;Ί­ ^Sεeωΐκ%’ CH²²σxηύ£΄wtπΙΑ&.˜Ž—9αύ+%Κ:’GΩT}ϊMΰΑ²υ’~¬RΤ½^"€εoU·œ<ΠΠNΜ²PJaŸλo¨–ξ]x>ڞή>Bέη2uI %c ,„Š‘€DΙxιίͺnf冦“ΐςώtϊ JVΡHέΊYeϋjt,ΞHs»²zqB‹’aτΪIθΊΖŠ'n£h`λvnSr€”BI€rw3ΏYy°Η²U)°ο’Β€|¦(*έQ ΌΊpFfΦ’βl’άψά:¦i0hp!^―›Xηg(E©87V*~βm[ΖyŠp$Κ jbEES3p3°γ[ η}πάΨα‰ žΙ΄ρIπ€$Ί0 ύ[Ώ,NvνmeεMμmθΨό8v—f‡_Ÿ)FάΈCΛ€;&&M˜2zσ―DANβ8|¬‹χjΫψοΎ6ͺχχ/:ςμ§P‡ΧΝΤG,έp4bqϊΧLΏΟΘOΰ&5©»’΄#tt[Mg;’;€·ΚΊu³Ί‹–n·ΎšυwQΗητW‰€ι@Šσ-΄mΐ‰―H{y1η`₯DO&¬σ IENDB`‚caronc-apprise-182f859/apprise/assets/themes/default/apprise-warning-72x72.png000066400000000000000000000173511524161175200272040ustar00rootroot00000000000000‰PNG  IHDRHHUν³G pHYs  šœ›IDATxΪνœy|Υ•οΏχVυ*©΅X‹%Λ²εUήρŠρ‚ΑB€’8—!l’&!KΒ0 yla†%ΐ˜ΒξΒ°1±mΌΰ,ΌHΆd[’΅t«χͺϋώ¨₯«%ΩΑ&™χyνOΪέ]]uοOηόΞ9Ώ{nΑυ!ώNΧ E@‚€Π<Η@H  ˆ©`4ΠΜ¦Cl |G‹²@ΠlV;€φgΩΙU'_ζυ6ξCJ(κύŸ.‘R`šŠLΦ$™6‰' Sυ=ohV/Ϋ―­“|X ŒρΊNeY€Ίͺ5εA*K * PVδ£0¬ hhR`˜ŠDΚ ΟΝΠή•βPgŠ–φ$MβκLΣΗwΛ€₯ΐΆd€κ«³€ ηΓΑƒ?‘ŒγΖ3Ό:LmE˜Κ²‘€ζAq( "™28x8Εώφ»[zΩψa7kΆζ@G%uOΏv£t π Μ9οΤ1ΞZ8„I## ©QRδ„ŒcΉΌς ZΡM³Ώ-Αϋu³le+λΆwyξnnόGθxΰa` ŽSΜEgγΈΡ%”Ehš΄AΙΏœ}.ο}―€_G‘„ΒΔ±^^Ωΐ)”Κ‡L)ƒd*Λ›Ϊωυ£²Ώ-ι|•ώύΏ bΰΰkΞg-¬ζϋη"Τριšf&AHiƒ4ΐεl°„σΘσ&\ΫQωnζ€₯”ΩgB&έ±ΏzδC–­h%“uσ$p Π},Υ>!?œ)ΠΉώ’Ξ=i(~Ÿ†BJ€© 4M"„́#‰!dΞΒ\Λςόίώ.χ”οe_Η%θΧY4£‚ΊͺλΆw’H™€·lŽϊL,¨Α₯³†ργ%c^F·­F—]“h>‰OJ4M =“% σ]M 8 εyΝ· ₯,~ςψ™kQΛΊv·DωΑν›Ω±'ζυπu;ΩόT <œ ̞TΖe猠²,ˆOΣΡ4‰.š.ρiΊ&Π5λ½&΅€Ιq‘:κ0”m3 ‘zhW€>„˜ΚDi”‘ΔΜΔ1R] L—£&Ρή4WέΉ…?―owNΊ8ψθΣhπ°HX0­œοœYOY$`σD“Ÿξ#- t‰.5€t@‘ @YΑnΰHv ₯c j@σε[ e¦Ι&»Θ&ΪΙΔφ“ξnB)ÍvΙT†λοίΞsoΆ8?Z | θψ4z 8ΰ„Ιe|χ+#(- ΰΣ₯m9ŸΟz΅,H’k]ΣΠ5iρ’νfΚ¦c°ύ@ ύ…H=„™Ž’Œd7rΨ‡VΉˆ[ξύ=―½΅X<JασιΤΥVrό΄q,>ιx&―ΗΜΖΙτ$Ω±tl( €XošάαiπεΏ•€| ‘ΎˆKΟII‘©I€”H!Ρt ©Y€Ia’i:H‰”šM°‰—œ%Ας TM#P\O x8I6yΈQK=ΜΖέ?Ίξ^ΪwӍӋΣΥcoσAV―ίΞγΟ½Α+ogΔπ‘Œ9_A BϊΘΖ[Qό~)£"4гkάαΤ0πΪ'h±‘κ₯?ϊζΚKhš†”Vt²±Α±R"4+€ I‹¬…΄’Bΰ+J lΟΎτ.›·71fΜ(‚‘Αdb­(#•ΗWΚΜ 5<υβ;d Σ Vq+„Uΰf³-:ψέ²·hλθfφΜI–AjAΡύ„tΖΦ°₯±‡C)'Ρέ`'•Ηδb6™ψΧο40Ύ>‚Oאv΄ςιΆ+ιšνR–[iΊns‘ύΉ΄ΜquΩpεT~rΛ+<ςΤ+|eρ‰Άέœˆ3T3οΈ €&BC ;Ώ‘ΆΫέ€εRšH©‘I‰[ΓXΰH_²¨žϊѝ˜Άœq¨£“αuՌk™‰c¦»=$.@™ΘLaΡF6ΆLo+ΎμAͺ »™?)Β…_;τ;wο'‘LσўVφ4d””ΑHΆc€£€`δΪ:“liμA)Š€rΰ Ωΐρ’κς Ÿ3)š°MZ Ž[9<#m—“Φqš¨›IηΒΌ― ’?­iγ…—VΉM$RΔzœΌp…ΕƒHG›J!„ΐ_XCaν\ Ο$\>‘pΕ$‚₯£ώ"L#ΚF©6N˜XΖδ©'°αύέtuΗhάέ‚RŠs¦’ι~2½­(3‹‚Ι£"¬ΨΨξXΡxΰu`ίΗθΐ<€‹ΟAEiΠ*lKΡ€D: I\pœΟ4)Ρ4Ν=ήɚΑ!\?ΑQ\{λsμi>wα}­mΤΦT2mκ$@‘MΆ#υ0zω,žYώ.zc-+WofλŽ=Δβ†CaωXΠό™nΜL”!% ΖM:žΥ‰Ζβ¬Ϋψ!sgMdδθ2½0Σ=( ΰ“‚Ώl>L&«|Άΐχψ_h‘]„NWΒηfVZΆσΛ΅špΐωNJΛΚ<ΰH)¬¨ζ)΄@ »—pλέO“Jgς.n&Ϋ:Y0g*ƒΚ«ΘτD DxuM θVήΜͺ5[xmΕzή^³™5λ·3¨4Β豓Π%d‡PF’ΪA&„λXϋή‡d ƒ»φ󭯞 J‘‰νG)! aXo½ΧNk{`(° Ψγ*žφi@•°pf₯­œD_)+w‰RΣ>…ϊ\ »nu ΐΆ$ „Ž?2Œ{ώ#ΡήΔ€ζ»ρύ<Ώ|Y$P2#“ΐ―Λ~Ηνom珯­ζ’«nαΗ–γ+¨&T5έo&ΞW fβΈαlΨΌ“υ›v(†ΠΉœLώωάθšΐ^@ΈΨΌϊZΠ[ψͺ˜6”Ω“α³ P), qˆYΊ.§Ή©[―Vd’Ω‘MJΝΚƒΕμν.ηŽ{Ÿ¦³;zΔ±yk#§Ÿ:ͺκZ²½­T•JN9igιD.ψκη˜<~»šΩ₯7ždΥΪ-Œ[OCΓ²©NΜt>™‘#YΚϊM†IQa˜ΟŸ8ƒTΟ^T¦Χ­ν†V…xkC;§ύΐ‘,h 0`ςΨ~Ν*—μ*RαU$T–R ˜JΪ©#`WATXωOAε$xμ₯~άγ<ό~Ώ% φΔψΝoŸ&•KFαWέL¬ξfΖ°(γ«sή‚"^yπNΒ„τΖ“όψϊ»1 ƒPΕd”@–¦TS)`Εκ-ΦK°Τc#Φ8/:sΈ·¬:m +ΆΓ:C‡¨©ΪΕ ΄5α©©(Λ₯œBSΉH* η'BΪ!^(Ζͺ‡xύ­u >œ /Ό›oΎ™_|‘νΫ·sϋν·‡xμ™WΨ²mώΘ0τPΒL‘#ΚƐ‰&ξΎn1£GΤp°ύ0?υ'τ@)W„P&£k ‡ƒμόΘ PRω‘υsΣΛ)/ρ;X|ΙΖ# *› UWHI‘?_I°ωGaσŠ@ ΚΞdK±B „#΅J4_ˆLΈ₯/§iAKŽόιOΉσΞ;ωώχΏΟβΕ‹ihhΰίψ‹/v―ϋ/Χύ©Tš`ωD„ Έ|ηL.oγς Ώ`­e ^}sm%%( €Hǧ뀫Κ}e«Π^όPΏσ(e€Πθˆjn*Q)f&ξžBxΈhΜΠBJ‹άuΝy€ίΉRΠQ ΛJ”ϋρJΑ rϊ‹0ΛΝDN.Vα)!-N‘ΑSin‡λ~u?­sΛ”)Sπω¬Α˜¦ΙφνΫ‰Ε\Տ+Έ‚aΓ†Y‹]έQϋΥ}ι#T1Αε4!z°œ;ΉΏ\YŒTR °½)NoάJ'&6Τ[n™μ°ΥuΑρΚ\"ΣL0 $βΗ§ης!pέΚy5s»ΛM^³8ΘG€j Q³œ+χlά’_,O™2ōX†aπμ³Οrύχ»ίϚ5‹3Ξ8M³\jΩWπΦ;οˆ G•£H½€`Ω~ϋπ‹φ5͊™I`€Ίρηυ•›θμ²€?iΑtŒL/f&ξFXg" +‘2:β ‘Ξ ¨Χ5AI‘/Θ•B ° ε~ž' {BΎ―€β!ΣIΘ*ΎsΕMΌ½zsΏsτhΥͺU<τΠCtvvζV"―Ή†ΊΊ:β‰$~ΫC˜Κ$T9Εβ -Δ=ΎΑΊ–Ό)*ΰΒσO#Ω±-XΖώžRήYϋ>¦iιΤ瞱TΧL3λrώ’TΓπ"ηm(p ώ`@£0¬[~ͺϊ3}N#χ’›ςψΎ ©¦Ό~‡z‚œύ­Ÿ²zέցοǏG·£‹RŠ-[Ά°mΫ6–.]κSYYΙUW]εΎίτώN~ϋΰ2τ@ Ρ˜fŠΖ]ΝΌqσu—RH‘Žξ£¨f6ώξUΆ~°Η–RζS]!kF`Ότ$C+CήΏx‘£ATψ}’‚žΏx`˞³J₯Θ·₯@󅨨ŸCEύ\ή\³‹³–ό„;χ,pDyyΉϋΎ··—––ι4O?ύ4»vνrΏ»ψβ‹™9s¦kEO<χ Mϋ(‰W]8“ύα½ηZΞώ"&X6†qρ—Κ˜?ͺ‹ΨΎU˜™Jeωπ£fI«±αΧΧ_BΝ ΙΏΐςΦ š *vΖ‘Ί}d@“ ϋdήrT^Š£„§δ„Kj¨5ŒαcωλλψΥmΠz°ύ―/54δΤΥΥΕΩgŸΝ΄iΣ7n3fΜΘ[ν˜3gK–,α‘Gΰχ/ΏΝ??›“G ‡«,ΝYε–£ΝL/Ÿ?ΎŽ΅σ¦2mςΞ;sιΞχ0Rnιs$)U,š±½\χt€Y‚—ωQΙα₯ά|Ηη 3tμ‚‘Z6oίΛ½>Η[«ήϋΨ lγƍ£¨Θ%CΎψΕ/rςΙ'£λ:Ί#₯μΧδpγ7ςδ“O’ΙdhάΥΜS/ΌΞ€ρ# ”Τ“°X1n#KZXzύB€/H¦υ TΆχγ­€ AΐηΦπaiγΝ­Lζ›O^ϊc/ή•U‘ε°ΖwŽ%—\wLΰΤΧΧ»αΫ)PΓα0~ΏΏ8nš_]Ν 7άΰΎέ ―³jΝf|αJό‘:Ό©¦5ΰl’tOf6‘Χ-"ϊ­G“rΏKIO? †a΅ΎΉ‘άsέ¦ΌΊ‚βΑ€Q–i=±Γ0­'―ΊšΚΚΚΌ”‘££ƒU«VqΟ=χpΩe—1oή<„\}υΥ€Rψ|>.ΈΰF @O΄—ϋω=]ΡώHBυil0 Β‘¦©ˆ%άΆΗÎ΅d E*mφ#f„Ζ°±σ¨Ÿ°ˆO"“NpΪ‚±†yÇϋ`{φμ‘ΌΌœyσζqι₯—rχέw³j•₯Sίzλ­477Ϋ…h†‚‚.½τRχ·/ΏΎšWί|-XΏ°&-ΏίŠ­:jS–σή4έ1Wεά/=­k€3&‰€αν₯T5‚ν{β<φτΛd²%υθΰŠ‹Ύς‰ͺ¨¨Θε6›6ρΨL&ΓM7έΔK/½Δ=χάΓ5Χ\Γ /ΌηžvΛƒtG{mΏ0'Ή¨όF+•—³y|™¬’3κΤδthI₯šήDSζ}(Š*&pέΟση•λ˜2a“Ə%ΦΩΔόι5Œo¨gێί8zτhΚΚ¬z'›Νς—Ώόε¨Η/]Ί4/yμϋΨΣ|€ΌY~vεθΑR©.;š Œ‚RΚNΥ…²’­+ΏηΡ± ИL›–y©\B—ρΡήήY»₯χ=΄Œd2KyΝ’έ»ωΡeη“577³tιR½φZ.»μ2žzκ©ΏΉπ‘§^ΆDx©‘+ΝΜ>yύX*>lr ζ$Χ=mNμκIcšΉδ'PXΚΆΖ}$ν€λΝ·Χ³vΓVΟNw[#Uf”3O[ΐ²?Ύυ±Bθ3Ο<Γ£>J&“ιΧα!₯ RTΘΰΚ2JŠ‹¬„-ΙΠΥγPϋa:χx$ λuόX«J7’žΦΥ§aMε[–ςΆτYΗeM“χwυ8Γjz€ΐJΰŠΞž4]±΄΅ΔΘJΪΪσ³β›nYΚ¬ι2r6žηό/ΟαοΕήΐ’Ρh!α α³gLδΔ9S™>y$£‡•ΰ)Œl•M’TLe7:HιΗ ΐGϋ£lάήΚΚ5[ ό\{Υ…$Ϊ7“Mtτq/εI]l T.Τ+o‹Ÿ]Ÿ­~ߝλ Ϋk“½ν]ι‚Ξξ ε%”2Ιd“h}r“–ν<όΔΈό’s)―@WΫ–|ν4ξΊ™£χϋi’šͺrfNΗΩ§/δ σΗ“ιέO&֊™έIΆ]`ΨHyeJ7³L».¬¨›nrζ¬IH_£{%Ιley|bYΩ§CΝΜΟχ”«ͺs¨3ΕξχΌHx:ΌνΝΞ;Τ‘`T΅Žw0ΈjXΏΙή»τ9Ÿ:ŸϊΊιtό§Χςϊ›u|ΠΨ4 8u΅Uœv |ϋk'1ΌR’κj$Φόg·©ͺ?7τ5?Ý,NQΩ8f6f§ΘΗ<ά£”•δζO*Ώο΅΅mn0ΆτΥ€―4θ%Ϊ›ΕTŠt2FME˜ςAΕ}Β‘Α/o}PTžG„&Ξϊ|W w[DΒ!Ξ?ϋd~sΣ?σ³ΛO‘BίEΌ}+ΚΜβξU±'˜λ9TΉI+c@prΦΰX„Κ³:7ΡΝFυν ΅­μΩ?οw†½Γιaμ»ph_Ζ²ώQuE”EόhRP0QΎ*Ά}Ξχ΅dd}-'L kgp™dgs‚ύ­Φ_β€…3ωΙχΞgΙ™Σ¨wb$Ϊ •Ž  j*ώΒj²ΙNΜlΒΞιΌ%€κΧ΄©<}‡9 0έΖM₯ϊόFyu¬ΗΜI*η^J™|ΠεΞ§wy[†Ÿ N`nΦT#KŠt†Φ i3“ ‘a4M­±<C)Εξ½-œΉψDŠKΣ{θ}Š+†O\yωω|σΛ³¨C₯Ϊ WS4ψ8VΎΫΔΖ­»¨―ί§“‰Ψ=ςEњyVζ­βέΘε±&+οQn–KsMž ψ?ξdΗή@ΈkƒL?€ΆόΊΈ+š‘γFQς‘Iΐθaώ 3ˆ%5vοmΙ!ΪΥƒišœ8w†‘¦ΤΧΞ©'Νet5h™‹*ˆTM€ε°ΰͺkοβ7χ=ΓK―­ζΤEΗ3€vΩdF:ΦWάλ“ρͺώ5Vž[υΗΜ³Bαω }ΘY)“ƒ‡“\ϋΫνΞΆ«ΥΐΝ6vw4_N¦ΝΚ’Bu5V‡†R¨t'LΖΒσD0 “@ΐO€0Μ¬iPιv’=͈tώPƒj§ŠΤqϋ½ΟσύŸέΑΦ»I§3d2YΪwρΕ“Nΐ’‰•νΣέ‡;άIl59ΜΌΌGxŠV§‡ΪΫ" Š_?ς!›{œŠβF<{;Ž€}Έ« €i—7ŠΚ²΅ΩΝξ„ΓΕ— „²©(ιΔatiβσ‡¨1‡w·wρ―ΏΈ—–‹hΟόΧΟωό‚ιτΆ!έΣΤ4¦ΈτٟΡΟj0έm Άdfƒcδ\ΤΜX`Yڎ==\ψ‹υNύυΎ½άύk Tλs3YU™JLAμ†I ΚΔ4’€γ$bm€zΫ1Σ½–V'%RψΕ\πƒ»Ž*½}o;ί:ο „ *HuοΆ"[ΕΓ%ύ­K 0=‚žκg9 …[x,Ο±žΨΑΆέQ'΄_ Όϋq{ΧίnmKʚŠ Uƒ‚v Œ°Βκnφ{‰΅yΕ’&QF/'ΟdωkλŽxξα`Ή³§’Μ,™ήCύ€a@qΛΓ7δrŽslPj«Bvg«EΦNΏŽf“Ά΄Ϋo€ψU΅#gπλy‚!ƒΛΉύί.笓Ǔj[‡™‰˜Eηq ¦[6δ”AΫ’Qž"UΉr«ΐ$k˜ΌΆφWί΅Ϋ¨g[? €S f²jξGΝ1i © Y[Ÿ€Šέd.Β @*ƒšΚT`0οmiDΑ•—žΓonό'ΖVτνڊ‘κφ.l»`xΛ… fήΚλ ΘίdηD«¬a°|Υ~ςŸ[-š½ΐ»qόθ"ί'P8 ό8δβΤΉƒ9ε„Α…u{―˜ΣΥΪ˜IBP0œΒΒ%Υ΅Λjfr:ΎŽX‹υΡ@œ]Њ<Ν(_ ²\,–ΘςΨΛΝάϊD£Ϋ \ΰ£νρIφ¬Ύ€²Ÿϋ`oLvEΣΤV†‰θ–‹9ΦγΈVŽ$(#NΠlC$φYε…‘²»e9bτΚίΞ€:ŽW2tΊ%•R4ξ‹qλ<ΈάΥ¨:°Ά‰Ώπq'ϋIxΨœήt ‘76Επϋ%Γ«Γ. ‘r@a€i‚_ΪyLίJ1‹Ξ―·ΘΉ•ΚΏΩ*η~ΛV΄pΛc;YΉΡνjΫmς«Η2ΡΏuίό0¬ΪHXcΒΘbΞ?΅–aΥφCαΩy(tΒ­ŸŸΏ\τs©ώU½”>bόGϋcάυΜnήz―h<λόh₯m9{u‚ŸΦ­)žΞ½€ΠΗόγΚ8ο”‘”—l€ΊΆ§εMοΨμ%δ\ΉϋΉέΌψφχ€P»ϋ«>ιΔ>Ν{wœά‡}‹Š’°ΖΒιεœϋωZ†V… 4J }ύ–zθwΛ qΔΪKυΦ-mIώλ{yώΝb Γ»¦΅λ+ώ–I}·ΗΉΈη^η,ͺζδ™•„ƒΊ½σXv<[YΊ©¬ŽΨή„ΑŸΧ·ρό[­¬ΩΪΩWπ{kϋϊ½ŸΖd>«ϋ ΒΪΌ0ϋή3Η3s|)“GEQS@AHws(ηU‘0M0LΓPτ& φ΄τ²©±‡5[³~GΏ»KD7€ίcνdξψ΄&ςY߁ͺ ˜Ž΅δd`Rίς¦0€1xP€ˆŸ’†O—€³&±ΈAg4ΝΑΓ)’qc sgνσe;Pl²³γOυρίu3Φ^ΑXχ/›gƒ5«‰ύγ<’vɳюJν₯ͺƒΆΨυ™<ώwΑΣ°6μg1ΦNΏJ¬f"r]o]6ΝX7zKΩΟ„½Dυ™?ώ/“D2쐊IENDB`‚caronc-apprise-182f859/apprise/attachment/000077500000000000000000000000001524161175200205075ustar00rootroot00000000000000caronc-apprise-182f859/apprise/attachment/__init__.py000066400000000000000000000031661524161175200226260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Used for testing from ..manager_attachment import AttachmentManager from .base import AttachBase # Initalize our Attachment Manager Singleton A_MGR = AttachmentManager() __all__ = [ # Reference "AttachBase", "AttachmentManager", ] caronc-apprise-182f859/apprise/attachment/base.py000066400000000000000000000410521524161175200217750ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import contextlib import mimetypes import os import time from .. import exception from ..common import ContentLocation from ..locale import gettext_lazy as _ from ..url import URLBase from ..utils.parse import parse_bool class AttachBase(URLBase): """This is the base class for all supported attachment types.""" # For attachment type detection; this amount of data is read into memory # 128KB (131072B) max_detect_buffer_size = 131072 # Unknown mimetype unknown_mimetype = "application/octet-stream" # Our filename when we can't otherwise determine one unknown_filename = "apprise-attachment" # Our filename extension when we can't otherwise determine one unknown_filename_extension = ".obj" # The strict argument is a flag specifying whether the list of known MIME # types is limited to only the official types registered with IANA. When # strict is True, only the IANA types are supported; when strict is False # (the default), some additional non-standard but commonly used MIME types # are also recognized. strict = False # The maximum file-size we will accept for an attachment size. If this is # set to zero (0), then no check is performed # 1 MB = 1048576 bytes # 5 MB = 5242880 bytes # 1 GB = 1048576000 bytes max_file_size = 1048576000 # By default all attachments types are inaccessible. # Developers of items identified in the attachment plugin directory # are requried to set a location location = ContentLocation.INACCESSIBLE # Here is where we define all of the arguments we accept on the url # such as: schema://whatever/?overflow=upstream&format=text # These act the same way as tokens except they are optional and/or # have default values set if mandatory. This rule must be followed template_args = { "cache": { "name": _("Cache Age"), "type": "int", # We default to (600) which means we cache for 10 minutes "default": 600, }, "mime": { "name": _("Forced Mime Type"), "type": "string", }, "name": { "name": _("Forced File Name"), "type": "string", }, "verify": { "name": _("Verify SSL"), # SSL Certificate Authority Verification "type": "bool", # Provide a default "default": True, }, } def __init__(self, name=None, mimetype=None, cache=None, **kwargs): """Initialize some general logging and common server arguments that will keep things consistent when working with the configurations that inherit this class. Optionally provide a filename to over-ride name associated with the actual file retrieved (from where-ever). The mime-type is automatically detected, but you can over-ride this by explicitly stating what it should be. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. """ super().__init__(**kwargs) if not mimetypes.inited: # Ensure mimetypes has been initialized mimetypes.init() # Attach Filename (does not have to be the same as path) self._name = name # The mime type of the attached content. This is detected if not # otherwise specified. self._mimetype = mimetype # The detected_mimetype, this is only used as a fallback if the # mimetype wasn't forced by the user self.detected_mimetype = None # The detected filename by calling child class. A detected filename # is always used if no force naming was specified. self.detected_name = None # Absolute path to attachment self.download_path = None # Track open file pointers self.__pointers = set() # Set our cache flag; it can be True, False, None, or a (positive) # integer... nothing else if cache is not None: try: self.cache = cache if isinstance(cache, bool) else int(cache) except (TypeError, ValueError): err = f"An invalid cache value ({cache}) was specified." self.logger.warning(err) raise TypeError(err) from None # Some simple error checking if self.cache < 0: err = f"A negative cache value ({cache}) was specified." self.logger.warning(err) raise TypeError(err) else: self.cache = None # Validate mimetype if specified if self._mimetype and ( next( ( t for t in mimetypes.types_map.values() if self._mimetype == t ), None, ) is None ): err = f"An invalid mime-type ({mimetype}) was specified." self.logger.warning(err) raise TypeError(err) return @property def path(self): """Returns the absolute path to the filename. If this is not known or is know but has been considered expired (due to cache setting), then content is re-retrieved prior to returning. """ if not self.exists(): # we could not obtain our path return None return self.download_path @property def name(self): """Returns the filename.""" if self._name: # return our fixed content return self._name if not self.exists(): # we could not obtain our name return None if not self.detected_name: # If we get here, our download was successful but we don't have a # filename based on our content. ext = mimetypes.guess_extension(self.mimetype) self.detected_name = ( f"{self.unknown_filename}" f"{ext if ext else self.unknown_filename_extension}" ) return self.detected_name @property def mimetype(self): """Returns mime type (if one is present). Content is cached once determied to prevent overhead of future calls. """ if not self.exists(): # we could not obtain our attachment return None if self._mimetype: # return our pre-calculated cached content return self._mimetype if not self.detected_mimetype: # guess_type() returns: (type, encoding) and sets type to None # if it can't otherwise determine it. with contextlib.suppress(TypeError): # Directly reference _name and detected_name to prevent # recursion loop (as self.name calls this function) self.detected_mimetype, _ = mimetypes.guess_type( self._name if self._name else self.detected_name, strict=self.strict, ) # Return our mime type return ( self.detected_mimetype if self.detected_mimetype else self.unknown_mimetype ) def exists(self, retrieve_if_missing=True): """Simply returns true if the object has downloaded and stored the attachment AND the attachment has not expired.""" if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False cache = ( self.template_args["cache"]["default"] if self.cache is None else self.cache ) try: if ( self.download_path and os.path.isfile(self.download_path) and cache ): # We have enough reason to look further into our cached content # and verify it has not expired. if cache is True: # return our fixed content as is; we will always cache it return True # Verify our cache time to determine whether we will get our # content again. age_in_sec = time.time() - os.stat(self.download_path).st_mtime if age_in_sec <= cache: return True except OSError: # The file is not present pass return False if not retrieve_if_missing else self.download() def base64(self, encoding="ascii"): """Returns the attachment object as a base64 string otherwise None is returned if an error occurs. If encoding is set to None, then it is not encoded when returned """ if not self: # We could not access the attachment self.logger.error( f"Could not access attachment {self.url(privacy=True)}." ) raise exception.AppriseFileNotFound("Attachment Missing") try: with self.open() as f: # Prepare our Attachment in Base64 return ( base64.b64encode(f.read()).decode(encoding) if encoding else base64.b64encode(f.read()) ) except FileNotFoundError: # We no longer have a path to open raise exception.AppriseFileNotFound("Attachment Missing") from None except (TypeError, OSError) as e: self.logger.warning( "An I/O error occurred while reading {}.".format( self.name if self else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") raise exception.AppriseDiskIOError( "Attachment Access Error" ) from e def invalidate(self): """Release any temporary data that may be open by child classes. Externally fetched content should be automatically cleaned up when this function is called. This function should also reset the following entries to None: - detected_name : Should identify a human readable filename - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content """ # Close every tracked pointer without letting cleanup errors escape # from invalidate() or __del__(). while self.__pointers: with contextlib.suppress(OSError): self.__pointers.pop().close() self.detected_name = None self.download_path = None self.detected_mimetype = None return def download(self): """This function must be over-ridden by inheriting classes. Inherited classes MUST populate: - detected_name: Should identify a human readable filename - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content If a download fails, you should ensure these values are set to None. """ raise NotImplementedError( "download() is implimented by the child class." ) def open(self, mode="rb"): """Return our file pointer and track it (we'll auto close later)""" pointer = open(self.path, mode=mode) # noqa: SIM115 self.__pointers.add(pointer) return pointer def chunk(self, size=5242880): """A Generator that yield chunks of a file with the specified size. By default the chunk size is set to 5MB (5242880 bytes) """ with self.open() as file: while True: chunk = file.read(size) if not chunk: break yield chunk def __enter__(self): """Support with keyword.""" return self.open() def __exit__(self, value_type, value, traceback): """Stub to do nothing; but support exit of with statement gracefully.""" return @staticmethod def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True): """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url( url, verify_host=verify_host, sanitize=sanitize ) if not results: # We're done; we failed to parse our url return results # Allow overriding the default config mime type if "mime" in results["qsd"]: results["mimetype"] = ( results["qsd"].get("mime", "").strip().lower() ) # Allow overriding the default file name if "name" in results["qsd"]: # Strip any directory components to prevent path traversal # (e.g. ?name=/etc/passwd yields "passwd"). If the result is # empty after stripping, treat it as though ?name= was not # specified so the caller falls through to the next name source. _fname = os.path.basename(results["qsd"].get("name", "")).strip() if _fname: results["name"] = _fname # Our cache value if "cache" in results["qsd"]: # First try to get it's integer value try: results["cache"] = int(results["qsd"]["cache"]) except (ValueError, TypeError): # No problem, it just isn't an integer; now treat it as a bool # instead: results["cache"] = parse_bool(results["qsd"]["cache"]) return results def __len__(self): """Returns the filesize of the attachment.""" if not self: return 0 try: return os.path.getsize(self.path) if self.path else 0 except OSError: # OSError can occur if the file is inaccessible return 0 def __bool__(self): """Allows the Apprise object to be wrapped in an based 'if statement'. True is returned if our content was downloaded correctly. """ return bool(self.path) def __del__(self): """Perform any house cleaning.""" self.invalidate() caronc-apprise-182f859/apprise/attachment/file.py000066400000000000000000000115301524161175200220000ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import re from ..common import ContentLocation from ..locale import gettext_lazy as _ from ..utils.disk import path_decode from .base import AttachBase class AttachFile(AttachBase): """A wrapper for File based attachment sources.""" # The default descriptive name associated with the service service_name = _("Local File") # The default protocol protocol = "file" # Content is local to the same location as the apprise instance # being called (server-side) location = ContentLocation.LOCAL def __init__(self, path, **kwargs): """Initialize Local File Attachment Object.""" super().__init__(**kwargs) # Store path but mark it dirty since we have not performed any # verification at this point. self.dirty_path = path_decode(path) # Track our file as it was saved self.__original_path = os.path.normpath(path) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self._mimetype: # A mime-type was enforced params["mime"] = self._mimetype if self._name: # A name was enforced params["name"] = self._name return "file://{path}{params}".format( path=self.quote(self.__original_path), params=( "?{}".format(self.urlencode(params, safe="/")) if params else "" ), ) def download(self, **kwargs): """Perform retrieval of our data. For file base attachments, our data already exists, so we only need to validate it. """ if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False # Ensure any existing content set has been invalidated self.invalidate() try: if not os.path.isfile(self.dirty_path): return False except OSError: return False if ( self.max_file_size > 0 and os.path.getsize(self.dirty_path) > self.max_file_size ): # The content to attach is to large self.logger.error( "Content exceeds allowable maximum file length" f" ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False # We're good to go if we get here. Set our minimum requirements of # a call do download() before returning a success self.download_path = self.dirty_path self.detected_name = os.path.basename(self.download_path) # We don't need to set our self.detected_mimetype as it can be # pulled at the time it's needed based on the detected_name return True @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = AttachBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results match = re.match(r"file://(?P[^?]+)(\?.*)?", url, re.I) if not match: return None results["path"] = AttachFile.unquote(match.group("path")) return results caronc-apprise-182f859/apprise/attachment/http.py000066400000000000000000000347071524161175200220530ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import os import re from tempfile import NamedTemporaryFile import threading import requests from ..common import ContentLocation from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_PATH_SAFE_CHARS from .base import AttachBase class AttachHTTP(AttachBase): """A wrapper for HTTP based attachment sources.""" # The default descriptive name associated with the service service_name = _("Web Based") # The default protocol protocol = "http" # The default secure protocol secure_protocol = "https" # The number of bytes in memory to read from the remote source at a time chunk_size = 8192 # Web based requests are remote/external to our current location location = ContentLocation.HOSTED # thread safe loading _lock = threading.Lock() def __init__(self, headers=None, **kwargs): """Initialize HTTP Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.schema = "https" if self.secure else "http" self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "/" self.headers = {} if headers: # Store our extra headers self.headers.update(headers) # Where our content is written to upon a call to download. self._temp_file = None # Our Query String Dictionary; we use this to track arguments # specified that aren't otherwise part of this class self.qsd = { k: v for k, v in kwargs.get("qsd", {}).items() if k not in self.template_args } return def download(self, **kwargs): """Perform retrieval of the configuration based on the specified request.""" if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False # prepare header headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) auth = None if self.user: auth = (self.user, self.password) url = f"{self.schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath # Where our request object will temporarily live. r = None # Always call throttle before any remote server i/o is made self.throttle() with self._lock: if self.exists(retrieve_if_missing=False): # Due to locking; it's possible a concurrent thread already # handled the retrieval in which case we can safely move on self.logger.trace( "HTTP Attachment %s already retrieved", self._temp_file.name, ) return True # Ensure any existing content set has been invalidated self.invalidate() self.logger.debug( "HTTP Attachment Fetch URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) try: # Make our request with requests.get( url, headers=headers, auth=auth, params=self.qsd, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, stream=True, ) as r: # Handle Errors r.raise_for_status() # raise_for_status() only covers 4xx/5xx; when redirect # following is disabled any 3xx must be treated as a # failure so we do not silently stream a redirect stub. # Using a status-code range rather than r.is_redirect # catches 3xx responses that lack a Location header. if not self.redirects and 300 <= r.status_code < 400: self.logger.error( "HTTP redirect encountered but redirect " "following is disabled:" f" {self.url(privacy=True)}" ) return False # Get our file-size (if known) try: file_size = int(r.headers.get("Content-Length", "0")) except (TypeError, ValueError): # Handle edge case where Content-Length is a bad value file_size = 0 # Perform a little Q/A on file limitations and restrictions if ( self.max_file_size > 0 and file_size > self.max_file_size ): # The content retrieved is to large self.logger.error( "HTTP response exceeds allowable maximum file" f" length ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False # Detect config format based on mime if the format isn't # already enforced self.detected_mimetype = r.headers.get("Content-Type") d = r.headers.get("Content-Disposition", "") result = re.search( r"filename=['\"]?(?P[^'\"]+)['\"]?", d, re.I ) if result: self.detected_name = result.group("name").strip() # Create a temporary file to work with; delete must be set # to False or it isn't compatible with Microsoft Windows # instances. In lieu of this, __del__ will clean up the # file for us. self._temp_file = NamedTemporaryFile(delete=False) # noqa: SIM115 # Get our chunk size chunk_size = self.chunk_size # Track all bytes written to disk bytes_written = 0 # If we get here, we can now safely write our content to # disk for chunk in r.iter_content(chunk_size=chunk_size): # filter out keep-alive chunks if chunk: self._temp_file.write(chunk) bytes_written = self._temp_file.tell() # Prevent a case where Content-Length isn't # provided. In this case we don't want to fetch # beyond our limits if self.max_file_size > 0: if bytes_written > self.max_file_size: # The content retrieved is to large self.logger.error( "HTTP response exceeds allowable" " maximum file length" f" ({int(self.max_file_size / 1024)}" f"KB): {self.url(privacy=True)}" ) # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False elif ( bytes_written + chunk_size > self.max_file_size ): # Adjust out next read to accommodate up to # our limit +1. This will prevent us from # reading to much into our memory buffer self.max_file_size - bytes_written + 1 # Ensure our content is flushed to disk for post-processing self._temp_file.flush() # Set our minimum requirements for a successful download() # call self.download_path = self._temp_file.name if not self.detected_name: self.detected_name = os.path.basename(self.fullpath) except requests.RequestException as e: self.logger.error( "A Connection error occurred retrieving HTTP " f"configuration from {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False except OSError: # IOError is present for backwards compatibility with Python # versions older then 3.3. >= 3.3 throw OSError now. # Could not open and/or write the temporary file self.logger.error( "Could not write attachment to disk:" f" {self.url(privacy=True)}" ) # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False # Return our success return True def invalidate(self): """Close our temporary file.""" if self._temp_file: self.logger.trace("Attachment cleanup of %s", self._temp_file.name) # Do not let temporary-file cleanup errors escape from __del__(). with contextlib.suppress(OSError): self._temp_file.close() with contextlib.suppress(OSError): # Ensure our file is removed (if it exists) os.unlink(self._temp_file.name) # Reset our temporary file to prevent from entering # this block again self._temp_file = None super().invalidate() def __del__(self): """Tidy memory if open.""" self.invalidate() def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) # Prepare our cache value if self.cache is not None: if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Set our cache value params["cache"] = cache if self._mimetype: # A format was enforced params["mime"] = self._mimetype if self._name: # A name was enforced params["name"] = self._name # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Apply any remaining entries to our URL params.update(self.qsd) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=self.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=self.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, hostname=self.quote(self.host, safe=""), port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=self.quote(self.fullpath, safe=URL_PATH_SAFE_CHARS), params=self.urlencode(params, safe="/"), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = AttachBase.parse_url(url, sanitize=False) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set results["headers"] = results["qsd-"] results["headers"].update(results["qsd+"]) return results caronc-apprise-182f859/apprise/attachment/memory.py000066400000000000000000000153771524161175200224060ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import io import os import re import uuid from .. import exception from ..common import ContentLocation from ..locale import gettext_lazy as _ from .base import AttachBase class AttachMemory(AttachBase): """A wrapper for Memory based attachment sources.""" # The default descriptive name associated with the service service_name = _("Memory") # The default protocol protocol = "memory" # Content is local to the same location as the apprise instance # being called (server-side) location = ContentLocation.LOCAL def __init__( self, content=None, name=None, mimetype=None, encoding="utf-8", **kwargs, ): """Initialize Memory Based Attachment Object.""" # Create our BytesIO object self._data = io.BytesIO() if content is None: # Empty; do nothing pass elif isinstance(content, str): content = content.encode(encoding) if mimetype is None: mimetype = "text/plain" if not name: # Generate a unique filename name = str(uuid.uuid4()) + ".txt" elif not isinstance(content, bytes): raise TypeError( "Provided content for memory attachment is invalid" ) # Store our content if content: self._data.write(content) if mimetype is None: # Default mimetype mimetype = "application/octet-stream" if not name: # Generate a unique filename name = str(uuid.uuid4()) + ".dat" # Initialize our base object super().__init__(name=name, mimetype=mimetype, **kwargs) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "mime": self._mimetype, } return "memory://{name}?{params}".format( name=self.quote(self._name), params=self.urlencode(params, safe="/"), ) def open(self, *args, **kwargs): """Return our memory object.""" # Return our object self._data.seek(0, 0) return self._data def __enter__(self): """Support with clause.""" # Return our object self._data.seek(0, 0) return self._data def download(self, **kwargs): """Handle memory download() call.""" if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False if self.max_file_size > 0 and len(self) > self.max_file_size: # The content to attach is to large self.logger.error( "Content exceeds allowable maximum memory size" f" ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False return True def base64(self, encoding="ascii"): """We need to over-ride this since the base64 sub-library seems to close our file descriptor making it no longer referencable.""" if not self: # We could not access the attachment self.logger.error( f"Could not access attachment {self.url(privacy=True)}." ) raise exception.AppriseFileNotFound("Attachment Missing") self._data.seek(0, 0) return ( base64.b64encode(self._data.read()).decode(encoding) if encoding else base64.b64encode(self._data.read()) ) def invalidate(self): """Removes data.""" if not self._data.closed: self._data.truncate(0) return def exists(self): """Over-ride exists() call.""" size = len(self) return bool( self.location != ContentLocation.INACCESSIBLE and size > 0 and ( self.max_file_size <= 0 or (self.max_file_size > 0 and size <= self.max_file_size) ) ) @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = AttachBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results if "name" not in results: # Allow fall-back to be from URL match = re.match(r"memory://(?P[^?]+)(\?.*)?", url, re.I) if match: # Store our filename only (ignore any defined paths) results["name"] = os.path.basename( AttachMemory.unquote(match.group("path")) ) return results @property def path(self): """Return the filename.""" if not self.exists(): # we could not obtain our path return None return self._name def __len__(self): """Returns the size of he memory attachment.""" return self._data.getbuffer().nbytes def __bool__(self): """Allows the Apprise object to be wrapped in an based 'if statement'. True is returned if our content was downloaded correctly. """ return self.exists() caronc-apprise-182f859/apprise/cli.py000066400000000000000000001271371524161175200175130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import os from os.path import exists, isfile import platform import re import shutil import sys import textwrap import click from . import ( Apprise, AppriseAsset, AppriseConfig, NotificationManager, PersistentStore, __copyright__, __license__, __title__, __version__, ) from .common import ( NOTIFY_FORMATS, NOTIFY_TYPES, PERSISTENT_STORE_MODES, ContentLocation, NotifyFormat, NotifyType, PersistentStoreMode, PersistentStoreState, ) from .logger import logger from .utils.disk import bytes_to_str, dir_size, path_decode from .utils.parse import GET_SCHEMA_RE, parse_list # By default we allow looking 1 level down recursively in Apprise configuration # files. DEFAULT_RECURSION_DEPTH = 1 # Default number of days to prune persistent storage DEFAULT_STORAGE_PRUNE_DAYS = int( os.environ.get("APPRISE_STORAGE_PRUNE_DAYS", 30) ) # The default URL ID Length DEFAULT_STORAGE_UID_LENGTH = int( os.environ.get("APPRISE_STORAGE_UID_LENGTH", 8) ) # Defines the environment variable to parse if defined. This is ONLY # Referenced if: # - No Configuration Files were found/loaded/specified # - No URLs were provided directly into the CLI Call DEFAULT_ENV_APPRISE_URLS = "APPRISE_URLS" # Defines the override path for the configuration files read DEFAULT_ENV_APPRISE_CONFIG_PATH = "APPRISE_CONFIG_PATH" # Defines the override path for the plugins to load DEFAULT_ENV_APPRISE_PLUGIN_PATH = "APPRISE_PLUGIN_PATH" # Defines the override path for the persistent storage DEFAULT_ENV_APPRISE_STORAGE_PATH = "APPRISE_STORAGE_PATH" # Defines our click context settings adding -h to the additional options that # can be specified to get the help menu to come up CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} # Define our default configuration we use if nothing is otherwise specified DEFAULT_CONFIG_PATHS = ( # Legacy Path Support "~/.apprise", "~/.apprise.conf", "~/.apprise.yml", "~/.apprise.yaml", "~/.config/apprise", "~/.config/apprise.conf", "~/.config/apprise.yml", "~/.config/apprise.yaml", # Plugin Support Extended Directory Search Paths "~/.apprise/apprise", "~/.apprise/apprise.conf", "~/.apprise/apprise.yml", "~/.apprise/apprise.yaml", "~/.config/apprise/apprise", "~/.config/apprise/apprise.conf", "~/.config/apprise/apprise.yml", "~/.config/apprise/apprise.yaml", # Global Configuration File Support "/etc/apprise", "/etc/apprise.yml", "/etc/apprise.yaml", "/etc/apprise/apprise", "/etc/apprise/apprise.conf", "/etc/apprise/apprise.yml", "/etc/apprise/apprise.yaml", ) # Define our paths to search for plugins DEFAULT_PLUGIN_PATHS = ( "~/.apprise/plugins", "~/.config/apprise/plugins", # Global Plugin Support "/var/lib/apprise/plugins", ) # # General Options and Defaults # DEFAULT_NOTIFY_TYPE = NotifyType.INFO NOTIFY_TYPE_CHOICES: tuple[NotifyType, ...] = ( NotifyType.INFO, NotifyType.SUCCESS, NotifyType.WARNING, NotifyType.FAILURE, ) DEFAULT_NOTIFY_FORMAT = NotifyFormat.TEXT NOTIFY_FORMAT_CHOICES: tuple[NotifyFormat, ...] = ( NotifyFormat.TEXT, NotifyFormat.MARKDOWN, NotifyFormat.HTML, ) # # Persistent Storage # DEFAULT_STORAGE_PATH = "~/.local/share/apprise/cache" # Storage Mode DEFAULT_STORAGE_MODE = PersistentStoreMode.AUTO # Create an ordered list of options (first is default) PERSISTENT_STORE_MODE_CHOICES: tuple[PersistentStoreMode, ...] = ( PersistentStoreMode.AUTO, PersistentStoreMode.FLUSH, PersistentStoreMode.MEMORY, ) # Detect Windows if platform.system() == "Windows": # Default Config Search Path for Windows Users DEFAULT_CONFIG_PATHS = ( "%APPDATA%\\Apprise\\apprise", "%APPDATA%\\Apprise\\apprise.conf", "%APPDATA%\\Apprise\\apprise.yml", "%APPDATA%\\Apprise\\apprise.yaml", "%LOCALAPPDATA%\\Apprise\\apprise", "%LOCALAPPDATA%\\Apprise\\apprise.conf", "%LOCALAPPDATA%\\Apprise\\apprise.yml", "%LOCALAPPDATA%\\Apprise\\apprise.yaml", # # Global Support # # C:\ProgramData\Apprise "%ALLUSERSPROFILE%\\Apprise\\apprise", "%ALLUSERSPROFILE%\\Apprise\\apprise.conf", "%ALLUSERSPROFILE%\\Apprise\\apprise.yml", "%ALLUSERSPROFILE%\\Apprise\\apprise.yaml", # C:\Program Files\Apprise "%PROGRAMFILES%\\Apprise\\apprise", "%PROGRAMFILES%\\Apprise\\apprise.conf", "%PROGRAMFILES%\\Apprise\\apprise.yml", "%PROGRAMFILES%\\Apprise\\apprise.yaml", # C:\Program Files\Common Files "%COMMONPROGRAMFILES%\\Apprise\\apprise", "%COMMONPROGRAMFILES%\\Apprise\\apprise.conf", "%COMMONPROGRAMFILES%\\Apprise\\apprise.yml", "%COMMONPROGRAMFILES%\\Apprise\\apprise.yaml", ) # Default Plugin Search Path for Windows Users DEFAULT_PLUGIN_PATHS = ( "%APPDATA%\\Apprise\\plugins", "%LOCALAPPDATA%\\Apprise\\plugins", # # Global Support # # C:\ProgramData\Apprise\plugins "%ALLUSERSPROFILE%\\Apprise\\plugins", # C:\Program Files\Apprise\plugins "%PROGRAMFILES%\\Apprise\\plugins", # C:\Program Files\Common Files "%COMMONPROGRAMFILES%\\Apprise\\plugins", ) # # Persistent Storage # DEFAULT_STORAGE_PATH = "%APPDATA%/Apprise/cache" class PersistentStorageMode: """Persistent Storage Modes.""" # List all detected configuration loaded LIST = "list" # Prune persistent storage based on age PRUNE = "prune" # Reset all (regardless of age) CLEAR = "clear" # Define the types in a list for validation purposes PERSISTENT_STORAGE_MODES = ( PersistentStorageMode.LIST, PersistentStorageMode.PRUNE, PersistentStorageMode.CLEAR, ) if os.environ.get("APPRISE_STORAGE_PATH", "").strip(): # Override Default Storage Path DEFAULT_STORAGE_PATH = os.environ.get("APPRISE_STORAGE_PATH") def _log_runtime_env(): """Log Python, OS, encoding, and optional-dependency versions. Only runs when the logger is at DEBUG level or below. """ # Skip all work when DEBUG output is not enabled if not logger.isEnabledFor(logging.DEBUG): return import contextlib import locale # One-line environment summary logger.debug("Apprise: %s", __version__) logger.debug("Python: %s", platform.python_version()) logger.debug("Platform: %s", platform.platform()) logger.debug("Encoding: %s", locale.getpreferredencoding(False)) # Collect unique runtime-dep import names from all enabled plugins dep_names = set() for module in NotificationManager(): for plugin in module.get("plugin", ()): # Skip disabled plugins if not getattr(plugin, "enabled", True): continue fn = getattr(plugin, "runtime_deps", None) if not callable(fn): continue with contextlib.suppress(Exception): dep_names.update(fn()) # Nothing to report if not dep_names: return # packages_distributions() was added in Python 3.11; on older # Python we skip the dep listing but the env summary above is # still emitted. try: from importlib.metadata import ( PackageNotFoundError, packages_distributions, version as pkg_version, ) except ImportError: return # Resolve import names -> pip distribution names, then fetch # versions. packages_distributions() returns: # {import_name: [dist_name, ...]} try: dist_map = packages_distributions() except Exception: return # Build a sorted list of "name=version" pairs for resolved packages resolved = [] for name in sorted(dep_names): dist_names = dist_map.get(name, []) if not dist_names: # Import name not mapped to any installed distribution continue try: ver = pkg_version(dist_names[0]) except (PackageNotFoundError, Exception): # Package listed but not actually installed; skip silently continue resolved.append("{}={}".format(dist_names[0], ver)) if resolved: logger.debug("Runtime deps: %s", ", ".join(resolved)) def print_version_msg(): """Prints version message when -V or --version is specified.""" result = [] result.append(f"{__title__} v{__version__}") result.append(__copyright__) result.append(f"This code is licensed under the {__license__} License.") click.echo("\n".join(result)) class CustomHelpCommand(click.Command): def format_help(self, ctx, formatter): formatter.write_text("Usage:") formatter.write_text( " apprise [OPTIONS] [APPRISE_URL [APPRISE_URL2 [APPRISE_URL3]]]" ) formatter.write_text( " apprise storage [OPTIONS] [ACTION] [UID1 [UID2 [UID3]]]" ) # Custom help message formatter.write_text("") content = ( ( "Send a notification to all of the specified servers " "identified by their URLs" ), ( "the content provided within the title, body and " "notification-type." ), "", ( "For a list of all of the supported services and information" " on how to use " ), "them, check out https://github.com/caronc/apprise", ) for line in content: formatter.write_text(line) # Display options and arguments in the default format self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) # Custom 'Actions:' section after the 'Options:' formatter.write_text("") formatter.write_text("Actions:") actions = [ ( "storage", "Access the persistent storage disk administration", [ ( "list", ( "List all URL IDs associated with detected" " URL(s). This is also the default action" " run if nothing is provided" ), ), ( "prune", ( "Eliminates stale entries found based on " "--storage-prune-days (-SPD)" ), ), ( "clean", "Removes any persistent data created by Apprise", ), ], ) ] # # Some variables # # actions are indented this many spaces # sub actions double this value action_indent = 2 # label padding (for alignment) action_label_width = 10 space = " " space_re = re.compile(r"\r*\n") cols = 80 indent = 10 # Format each action and its subactions for action, description, sub_actions in actions: # Our action indent ai = " " * action_indent # Format the main action description formatted_description = space_re.split( textwrap.fill( description, width=(cols - indent - action_indent), initial_indent=space * indent, subsequent_indent=space * indent, ) ) for no, line in enumerate(formatted_description): if not no: formatter.write_text( f"{ai}{action:<{action_label_width}}{line}" ) else: # pragma: no cover # Note: no branch is set intentionally since this is not # tested since in 2025.08.13 when this was set up # it never entered this area of the code. But we # know it works because we repeat this process with # our sub-options below formatter.write_text( f"{ai}{space:<{action_label_width}}{line}" ) # Format each subaction ai = " " * (action_indent * 2) for action, description in sub_actions: formatted_description = space_re.split( textwrap.fill( description, width=(cols - indent - (action_indent * 3)), initial_indent=space * (indent - action_indent), subsequent_indent=space * (indent - action_indent), ) ) for no, line in enumerate(formatted_description): if not no: formatter.write_text( f"{ai}{action:<{action_label_width}}{line}" ) else: formatter.write_text( f"{ai}{space:<{action_label_width}}{line}" ) # Include any epilog or additional text self.format_epilog(ctx, formatter) @click.command(context_settings=CONTEXT_SETTINGS, cls=CustomHelpCommand) @click.option( "--body", "-b", default=None, type=str, help=( "Specify the message body. If no body is specified then " "content is read from ." ), ) @click.option( "--title", "-t", default=None, type=str, help="Specify the message title. This field is completely optional.", ) @click.option( "--plugin-path", "-P", default=None, type=str, multiple=True, metavar="PATH", help="Specify one or more plugin paths to scan.", ) @click.option( "--storage-path", "-S", default=DEFAULT_STORAGE_PATH, type=str, metavar="PATH", help=( "Specify the path to the persistent storage location " f"(default={DEFAULT_STORAGE_PATH})." ), ) @click.option( "--storage-prune-days", "-SPD", default=DEFAULT_STORAGE_PRUNE_DAYS, type=int, help=( "Define the number of days the storage prune should run using." " Setting this to zero (0) will eliminate all accumulated content. By" f" default this value is {DEFAULT_STORAGE_PRUNE_DAYS} days." ), ) @click.option( "--storage-uid-length", "-SUL", default=DEFAULT_STORAGE_UID_LENGTH, type=int, help=( "Define the number of unique characters to store persistent cache in." f" By default this value is {DEFAULT_STORAGE_UID_LENGTH} characters." ), ) @click.option( "--storage-mode", "-SM", default=DEFAULT_STORAGE_MODE.value, type=str, metavar="MODE", help=( "Specify the persistent storage operational mode " f"(default={DEFAULT_STORAGE_MODE.value}). " 'Possible values are: "{}".'.format( '", "'.join(mode.value for mode in PERSISTENT_STORE_MODE_CHOICES) ) ), ) @click.option( "--config", "-c", default=None, type=str, multiple=True, metavar="CONFIG_URL", help="Specify one or more configuration locations.", ) @click.option( "--attach", "-a", default=None, type=str, multiple=True, metavar="ATTACHMENT_URL", help="Specify one or more attachments.", ) @click.option( "--notification-type", "-n", default=DEFAULT_NOTIFY_TYPE.value, type=str, metavar="TYPE", help=( f"Specify the message type (default={DEFAULT_NOTIFY_TYPE.value}). " 'Possible values are: "{}".'.format( '", "'.join(nt.value for nt in NOTIFY_TYPE_CHOICES) ) ), ) @click.option( "--input-format", "-i", default=DEFAULT_NOTIFY_FORMAT.value, type=str, metavar="FORMAT", help=( f"Specify the message input format " f"(default={DEFAULT_NOTIFY_FORMAT.value}). " 'Possible values are: "{}".'.format( '", "'.join(fmt.value for fmt in NOTIFY_FORMAT_CHOICES) ) ), ) @click.option( "--theme", "-T", default="default", type=str, metavar="THEME", help="Specify the default theme.", ) @click.option( "--tag", "-g", default=None, type=str, multiple=True, metavar="TAG", help=( "Specify one or more tags to filter which services to notify. Use " "multiple --tag (-g) entries to match ANY tag. Use comma separators " "to require ALL tags (strict match). Omit to notify untagged services " 'only, or use "all" to notify everything.' ), ) @click.option( "--disable-async", "-Da", is_flag=True, help="Send all notifications sequentially", ) @click.option( "--dry-run", "-d", is_flag=True, help=( "Perform a trial run but only prints the notification " "services to-be triggered to stdout. Notifications are never " "sent using this mode." ), ) @click.option( "--details", "-l", is_flag=True, help="Prints details about the current services supported by Apprise.", ) @click.option( "--recursion-depth", "-R", default=DEFAULT_RECURSION_DEPTH, type=int, help=( "The number of recursive import entries that can be " "loaded from within Apprise configuration. By default " f"this is set to {DEFAULT_RECURSION_DEPTH}." ), ) @click.option( "--verbose", "-v", count=True, help=( "Makes the operation more talkative. Use multiple v to " "increase the verbosity. I.e.: -vvvv" ), ) @click.option( "--interpret-escapes", "-e", is_flag=True, help="Enable interpretation of backslash escapes", ) @click.option( "--interpret-emojis", "-j", is_flag=True, help="Enable interpretation of :emoji: definitions", ) @click.option("--debug", "-D", is_flag=True, help="Debug mode") @click.option( "--version", "-V", is_flag=True, help="Display the apprise version and exit.", ) @click.argument( "urls", nargs=-1, metavar="SERVER_URL [SERVER_URL2 [SERVER_URL3]]", ) @click.pass_context def main( ctx, body, title, config, attach, urls, notification_type, theme, tag, input_format, dry_run, recursion_depth, verbose, disable_async, details, interpret_escapes, interpret_emojis, plugin_path, storage_path, storage_mode, storage_prune_days, storage_uid_length, debug, version, ): """Send a notification to all of the specified servers identified by their URLs the content provided within the title, body and notification-type. For a list of all of the supported services and information on how to use them, check out https://github.com/caronc/apprise """ # Note: Click ignores the return values of functions it wraps, If you # want to return a specific error code, you must call ctx.exit() # as you will see below. debug = bool(debug) if debug: # Verbosity must be a minimum of 3 verbose = 3 if verbose < 3 else verbose # Logging ch = logging.StreamHandler(sys.stdout) if verbose > 3: # -vvvv: Most Verbose Debug Logging logger.setLevel(logging.TRACE) elif verbose > 2: # -vvv: Debug Logging logger.setLevel(logging.DEBUG) elif verbose > 1: # -vv: INFO Messages logger.setLevel(logging.INFO) elif verbose > 0: # -v: WARNING Messages logger.setLevel(logging.WARNING) else: # No verbosity means we display ERRORS only AND any deprecation # warnings logger.setLevel(logging.ERROR) # Format our logger formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") ch.setFormatter(formatter) logger.addHandler(ch) # Update our asyncio logger asyncio_logger = logging.getLogger("asyncio") for handler in logger.handlers: asyncio_logger.addHandler(handler) asyncio_logger.setLevel(logger.level) # Log environment and runtime-dependency versions when debugging _log_runtime_env() if version: print_version_msg() ctx.exit(0) # Simple Error Checking notification_type = notification_type.strip().lower() if notification_type not in NOTIFY_TYPES: click.echo( f"The --notification-type (-n) value of {notification_type} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) input_format = input_format.strip().lower() if input_format not in NOTIFY_FORMATS: click.echo( f"The --input-format (-i) value of {input_format} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) storage_mode = storage_mode.strip().lower() if storage_mode not in PERSISTENT_STORE_MODES: click.echo( f"The --storage-mode (-SM) value of {storage_mode} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) # # Apply Environment Overrides if defined # config_paths = DEFAULT_CONFIG_PATHS if "APPRISE_CONFIG" in os.environ: # Deprecate (this was from previous versions of Apprise <= 1.9.1) logger.deprecate( "APPRISE_CONFIG environment variable has been changed to " f"{DEFAULT_ENV_APPRISE_CONFIG_PATH}" ) logger.debug( "Loading provided APPRISE_CONFIG (deprecated) environment variable" ) config_paths = (os.environ.get("APPRISE_CONFIG", "").strip(),) elif DEFAULT_ENV_APPRISE_CONFIG_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_CONFIG_PATH} " "environment variable" ) config_paths = re.split( r"[\r\n;]+", os.environ.get(DEFAULT_ENV_APPRISE_CONFIG_PATH).strip(), ) plugin_paths_ = DEFAULT_PLUGIN_PATHS if DEFAULT_ENV_APPRISE_PLUGIN_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_PLUGIN_PATH} environment " "variable" ) plugin_paths_ = re.split( r"[\r\n;]+", os.environ.get(DEFAULT_ENV_APPRISE_PLUGIN_PATH).strip(), ) if DEFAULT_ENV_APPRISE_STORAGE_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_STORAGE_PATH} environment " "variable" ) storage_path = os.environ.get(DEFAULT_ENV_APPRISE_STORAGE_PATH).strip() # # Continue with initialization process # # Prepare a default set of plugin paths to scan; anything specified # on the CLI always trumps plugin_paths = ( plugin_path if plugin_path else [path for path in plugin_paths_ if exists(path_decode(path))] ) if storage_uid_length < 2: click.echo( "The --storage-uid-length (-SUL) value can not be lower " "than two (2)." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a # parameter issue. For consistency, we also return a 2 ctx.exit(2) # Prepare our asset asset = AppriseAsset( # Our body format body_format=input_format, # Interpret Escapes interpret_escapes=interpret_escapes, # Interpret Emojis interpret_emojis=None if not interpret_emojis else True, # Set the theme theme=theme, # Async mode allows a user to send all of their notifications # asynchronously. This was made an option incase there are problems # in the future where it is better that everything runs sequentially/ # synchronously instead. async_mode=disable_async is not True, # Load our plugins plugin_paths=plugin_paths, # Load our persistent storage path storage_path=path_decode(storage_path), # Our storage URL ID Length storage_idlen=storage_uid_length, # Define if we flush to disk as soon as possible or not when required storage_mode=storage_mode, ) # Create our Apprise object a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL) # Track if we are performing a storage action storage_action = bool(urls and "storage".startswith(urls[0])) # Save the original state; the storage block below always needs these. _in_storage_mode = storage_action _raw_urls = urls if storage_action: # Scan storage args for URL-filter entries. If any are found, # remap urls to only those entries and flip storage_action so the # priority chain (rule #1 below) loads them into 'a' (our Apprise # instance). This mirrors the sending notification process. _sfp = list(urls[1:]) if _sfp and any( m.startswith(_sfp[0]) for m in PERSISTENT_STORAGE_MODES ): # Skip the action word ("list", "prune", "clear") _sfp = _sfp[1:] _url_filters = [_f for _f in _sfp if GET_SCHEMA_RE.match(_f)] if _url_filters: # Hand off to rule #1 by replacing urls and clearing the flag. urls = _url_filters storage_action = False if details: # Print details and exit results = a.details(show_requirements=True, show_disabled=True) # Sort our results: plugins = sorted( results["schemas"], key=lambda i: str(i["service_name"]) ) for entry in plugins: protocols = ( [] if not entry["protocols"] else [p for p in entry["protocols"] if isinstance(p, str)] ) protocols.extend( [] if not entry["secure_protocols"] else [ p for p in entry["secure_protocols"] if isinstance(p, str) ] ) if len(protocols) == 1: # Simplify view by swapping {schema} with the single # protocol value # Convert tuple to list entry["details"]["templates"] = list( entry["details"]["templates"] ) for x in range(len(entry["details"]["templates"])): entry["details"]["templates"][x] = re.sub( r"^[^}]+}://", f"{protocols[0]}://", entry["details"]["templates"][x], ) fg = "green" if entry["enabled"] else "red" if entry["category"] == "custom": # Identify these differently fg = "cyan" # Flip the enable switch so it forces the requirements # to be displayed entry["enabled"] = False click.echo( click.style( "{} {:<30} ".format( "+" if entry["enabled"] else "-", str(entry["service_name"]), ), fg=fg, bold=True, ), nl=(not entry["enabled"] or len(protocols) == 1), ) if not entry["enabled"]: if entry["requirements"]["details"]: click.echo(" " + str(entry["requirements"]["details"])) if entry["requirements"]["packages_required"]: click.echo(" Python Packages Required:") for req in entry["requirements"]["packages_required"]: click.echo(" - " + req) if entry["requirements"]["packages_recommended"]: click.echo(" Python Packages Recommended:") for req in entry["requirements"]["packages_recommended"]: click.echo(" - " + req) # new line padding between entries if entry["category"] == "native": click.echo() continue if len(protocols) > 1: click.echo( "| Schema(s): {}".format( ", ".join(protocols), ) ) prefix = " - " click.echo( "{}{}".format( prefix, f"\n{prefix}".join(entry["details"]["templates"]) ) ) # new line padding between entries click.echo() ctx.exit(0) # end if details() # The priorities of what is accepted are parsed in order below: # 1. URLs by command line # 2. Configuration by command line # 3. URLs by environment variable: APPRISE_URLS # 4. Default Configuration File(s) # elif urls and not storage_action: if tag: # Ignore any tags specified logger.warning( "--tag (-g) entries are ignored when using specified URLs" ) tag = None # Load our URLs (if any defined) for url in urls: a.add(url) if config: # Provide a warning to the end user if they specified both logger.warning( "You defined both URLs and a --config (-c) entry; " "Only the URLs will be referenced." ) elif config: # We load our configuration file(s) now only if no URLs were specified # Specified config entries trump all a.add( AppriseConfig(paths=config, asset=asset, recursion=recursion_depth) ) elif os.environ.get(DEFAULT_ENV_APPRISE_URLS, "").strip(): logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_URLS} environment variable" ) if tag: # Ignore any tags specified logger.warning( "--tag (-g) entries are ignored when using specified URLs" ) tag = None # Attempt to use our APPRISE_URLS environment variable (if populated) a.add(os.environ[DEFAULT_ENV_APPRISE_URLS].strip()) else: # Load default configuration a.add( AppriseConfig( paths=[f for f in config_paths if isfile(path_decode(f))], asset=asset, recursion=recursion_depth, ) ) # Restore storage_action in case it was flipped above to allow URL- # filter entries to be loaded via rule #1. storage_action = _in_storage_mode if not dry_run and not (a or storage_action): click.echo( "You must specify at least one server URL or populated " "configuration file." ) click.echo("Try 'apprise --help' for more information.") ctx.exit(1) # each --tag entry comprises of a comma separated 'and' list # we or each of of the --tag and sets specified. tags = None if not tag else [parse_list(t) for t in tag] # Determine if we're dealing with URLs or url_ids based on the first # entry provided. if storage_action: # # Storage Mode # - urls are now to be interpreted as best matching namespaces # if storage_prune_days < 0: click.echo( "The --storage-prune-days (-SPD) value can not be lower " "than zero (0)." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a # parameter issue. For consistency, we also return a 2 ctx.exit(2) # Number of columns to assume in the terminal. In future, maybe this # can be detected and made dynamic. The actual column count is 80, but # 5 characters are already reserved for the counter on the left (columns, _) = shutil.get_terminal_size(fallback=(80, 24)) # Pop 'storage' off of the head of the original url list. # _raw_urls always holds the unmodified list even when urls was # remapped to URL-filter entries for the priority chain above. filter_uids = _raw_urls[1:] action = PERSISTENT_STORAGE_MODES[0] if filter_uids: action_ = next( # pragma: no branch ( a for a in PERSISTENT_STORAGE_MODES if a.startswith(filter_uids[0]) ), None, ) if action_: # pop 'action' off the head of our list filter_uids = filter_uids[1:] action = action_ # True when URL-filter entries were present in the storage args; # detected by whether urls was remapped to a new list object above. _had_url_filters = _raw_urls is not urls # Build the uid-prefix filter list from non-URL entries only. # URL-filter entries were already loaded into 'a' by rule #1. uid_filter_list = [ _f.lower() for _f in filter_uids if not GET_SCHEMA_RE.match(_f) ] # Single pass: collect plugin uids, applying the uid-prefix filter uids = {} for plugin in a if not tags else a.find(tag=tags): id_ = plugin.url_id() if not id_: # No persistent storage uid; skip this plugin continue if uid_filter_list and not next( (u for u in uid_filter_list if id_.startswith(u)), None ): # Plugin uid does not match any uid-prefix filter logger.trace( "Storage: plugin uid '%s' excluded by filter %s", id_, uid_filter_list, ) continue if id_ not in uids: uids[id_] = { "plugins": [plugin], "state": PersistentStoreState.UNUSED.value, "size": 0, } else: # It's possible to have more than one URL point to the same # location (thus match against the same url id more than once uids[id_]["plugins"].append(plugin) if action == PersistentStorageMode.LIST: # Scope disk_scan based on what filters were provided: # - URL filters: scope to resolved plugin uids plus any # plain uid-prefix strings; skip scan if none resolved # - Plain-string or no filter: namespace=uid_filter_list # (empty list means return all on-disk directories) if _had_url_filters: # Derive namespace from the plugins resolved by URL filters _disk_ns = list(uids.keys()) + uid_filter_list if not _disk_ns: # URL filters yielded no plugins; skip the disk scan detected_uid = {} else: detected_uid = PersistentStore.disk_scan( # Use our asset path as already properly parsed path=asset.storage_path, namespace=_disk_ns, ) else: detected_uid = PersistentStore.disk_scan( # Use our asset path as it has already been properly # parsed path=asset.storage_path, # Provide filter if specified; empty = all directories namespace=uid_filter_list, ) for id_ in detected_uid: size, _ = dir_size(os.path.join(asset.storage_path, id_)) if id_ in uids: uids[id_]["state"] = PersistentStoreState.ACTIVE.value uids[id_]["size"] = size elif not tags: uids[id_] = { "plugins": [], # No cross reference (wasted space?) "state": PersistentStoreState.STALE.value, # Acquire disk space "size": size, } for idx, (uid, meta) in enumerate(uids.items()): fg = ( "green" if meta["state"] == PersistentStoreState.ACTIVE.value else ( "red" if meta["state"] == PersistentStoreState.STALE.value else "white" ) ) if idx > 0: # New line click.echo() click.echo(f"{idx + 1: 4d}. ", nl=False) click.echo( click.style( "{:<52} {:<8} {}".format( uid, bytes_to_str(meta["size"]), meta["state"] ), fg=fg, bold=True, ) ) for entry in meta["plugins"]: url = entry.url(privacy=True) click.echo( "{:>7} {}".format( "-", ( url if len(url) <= (columns - 8) else f"{url[: columns - 11]}..." ), ) ) if entry.tags: click.echo( "{:>10}: {}".format( "tags", ", ".join(str(t) for t in entry.tags), ) ) else: # PersistentStorageMode.PRUNE or PersistentStorageMode.CLEAR if action == PersistentStorageMode.CLEAR: storage_prune_days = 0 # Derive the namespace for disk_prune. The scoping rules # mirror those for disk_scan: # - URL filters: resolved plugin uids + any uid-prefix # strings; empty means no-op (nothing to prune). # - Tag filters: same scoping β€” resolved plugin uids + any # uid-prefix strings; empty means no-op. # - Plain uid-prefix or no filter: pass through directly. if _had_url_filters or tags: # Scope to the uids that the filtered plugins resolved # to. If none resolved (e.g. unknown tag), exit early # -- disk_prune() with namespace=None targets ALL # namespaces, so passing None here is not a no-op. _prune_ns = list(uids.keys()) + uid_filter_list if not _prune_ns: ctx.exit(0) else: _prune_ns = uid_filter_list or None # clean up storage results = PersistentStore.disk_prune( # Use our asset path as it has already been properly parsed path=asset.storage_path, # Provide our namespaces if they exist namespace=_prune_ns, # Convert expiry from days to seconds expires=storage_prune_days * 60 * 60 * 24, action=not dry_run, ) ctx.exit(0) # end if disk_prune() ctx.exit(0) # end if storage() if not dry_run: if body is None: logger.trace("No --body (-b) specified; reading from stdin") # if no body was specified, then read from STDIN body = click.get_text_stream("stdin").read() # now print it out result = a.notify( body=body, title=title, notify_type=notification_type, tag=tags, attach=attach, ) else: # Number of columns to assume in the terminal. In future, maybe this # can be detected and made dynamic. The actual column count is 80, but # 5 characters are already reserved for the counter on the left (columns, _) = shutil.get_terminal_size(fallback=(80, 24)) # Initialize our URL response; This is populated within the for/loop # below; but plays a factor at the end when we need to determine if # we iterated at least once in the loop. url = None for idx, server in enumerate(a.find(tag=tags)): url = server.url(privacy=True) click.echo( "{: 4d}. {}".format( idx + 1, ( url if len(url) <= (columns - 8) else f"{url[: columns - 9]}..." ), ) ) # Share our URL ID click.echo( "{:>10}: {}".format( "uid", "- n/a -" if not server.url_id() else server.url_id(), ) ) if server.tags: click.echo( "{:>10}: {}".format( "tags", ", ".join(str(t) for t in server.tags), ) ) # Initialize a default response of nothing matched, otherwise # if we matched at least one entry, we can return True result = None if url is None else True if result is None: # There were no notifications set. This is a result of just having # empty configuration files and/or being to restrictive when filtering # by specific tag(s) # Exit code 3 is used since Click uses exit code 2 if there is an # error with the parameters specified ctx.exit(3) elif result is False: # At least 1 notification service failed to send ctx.exit(1) # else: We're good! ctx.exit(0) caronc-apprise-182f859/apprise/common.py000066400000000000000000000164761524161175200202370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from enum import Enum # isoformat is spelled out for compatibility with Python v3.6 AWARE_DATE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" NAIVE_DATE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" class NotifyType(str, Enum): """A simple mapping of notification types most commonly used with all types of logging and notification services.""" INFO = "info" SUCCESS = "success" WARNING = "warning" FAILURE = "failure" # Define our types so we can verify if we need to NOTIFY_TYPES: frozenset[str] = frozenset(e.value for e in NotifyType) class NotifyImageSize(str, Enum): """A list of pre-defined image sizes to make it easier to work with defined plugins.""" XY_32 = "32x32" XY_72 = "72x72" XY_128 = "128x128" XY_256 = "256x256" # Define our image sizes so we can verify if we need to NOTIFY_IMAGE_SIZES: frozenset[str] = frozenset( e.value for e in NotifyImageSize ) class NotifyFormat(str, Enum): """A list of pre-defined text message formats that can be passed via the apprise library.""" TEXT = "text" HTML = "html" MARKDOWN = "markdown" # Define our formats so we can verify if we need to NOTIFY_FORMATS: frozenset[str] = frozenset(e.value for e in NotifyFormat) class OverflowMode(str, Enum): """A list of pre-defined modes of how to handle the text when it exceeds the defined maximum message size.""" # Send the data as is; untouched. Let the upstream server decide how the # content is handled. Some upstream services might gracefully handle this # with expected intentions; others might not. UPSTREAM = "upstream" # Always truncate the text when it exceeds the maximum message size and # send it anyway TRUNCATE = "truncate" # Split the message into multiple smaller messages that fit within the # limits of what is expected. The smaller messages are sent SPLIT = "split" # Define our modes so we can verify if we need to OVERFLOW_MODES: frozenset[str] = frozenset(e.value for e in OverflowMode) class ConfigFormat(str, Enum): """A list of pre-defined config formats that can be passed via the apprise library.""" # A text based configuration. This consists of a list of URLs delimited by # a new line. pound/hashtag (#) or semi-colon (;) can be used as comment # characters. TEXT = "text" # YAML files allow a more rich of an experience when settig up your # apprise configuration files. YAML = "yaml" # Define our configuration formats mostly used for verification CONFIG_FORMATS: frozenset[str] = frozenset(e.value for e in ConfigFormat) class ContentIncludeMode(str, Enum): """The different Content inclusion modes. All content based plugins will have one of these associated with it. """ # - Content inclusion of same type only; hence a file:// can include # a file:// # - Cross file inclusion is not allowed unless insecure_includes (a flag) # is set to True. In these cases STRICT acts as type ALWAYS STRICT = "strict" # This content type can never be included NEVER = "never" # This content can always be included ALWAYS = "always" # Define our file inclusion types so we can verify if we need to CONTENT_INCLUDE_MODES: frozenset[str] = frozenset( e.value for e in ContentIncludeMode ) class ContentLocation(str, Enum): """This is primarily used for handling file attachments. The idea is to track the source of the attachment itself. We don't want remote calls to a server to access local attachments for example. By knowing the attachment type and cross-associating it with how we plan on accessing the content, we can make a judgement call (for security reasons) if we will allow it. Obviously local uses of apprise can access both local and remote type files. """ # Content is located locally (on the same server as apprise) LOCAL = "local" # Content is located in a remote location HOSTED = "hosted" # Content is inaccessible INACCESSIBLE = "n/a" # Define our location types so we can verify if we need to CONTENT_LOCATIONS: frozenset[str] = frozenset(e.value for e in ContentLocation) class PersistentStoreMode(str, Enum): # Allow persistent storage; write on demand AUTO = "auto" # Always flush every change to disk after it's saved. This has higher i/o # but enforces disk reflects what was set immediately FLUSH = "flush" # memory based store only MEMORY = "memory" # Define our persistent storage modes so we can verify if we need to PERSISTENT_STORE_MODES: frozenset[str] = frozenset( e.value for e in PersistentStoreMode ) class PersistentStoreState(str, Enum): """Defines the persistent states describing what has been cached.""" # Persistent Directory is actively cross-referenced against a matching URL ACTIVE = "active" # Persistent Directory is no longer being used or has no cross-reference STALE = "stale" # Persistent Directory is not utilizing any disk space at all, however # it potentially could if the plugin it successfully cross-references # is utilized UNUSED = "unused" # Define our persistent storage states so we can verify if we need to PERSISTENT_STORE_STATES: frozenset[str] = frozenset( e.value for e in PersistentStoreState ) # This is a reserved tag that is automatically assigned to every # Notification Plugin MATCH_ALL_TAG = "all" # Will cause notification to trigger under any circumstance even if an # exclusive tagging was provided. MATCH_ALWAYS_TAG = "always" # Maximum number of retries a service may attempt after an initial failure. # This cap prevents runaway retry loops; the effective value is clamped to # [0, APPRISE_MAX_SERVICE_RETRY] everywhere it is consumed. APPRISE_MAX_SERVICE_RETRY = 10 # Maximum inter-retry wait (in seconds). Prevents accidental multi-hour # sleeps from a misconfigured wait= value; clamped to # [0.0, APPRISE_MAX_SERVICE_WAIT] everywhere it is consumed. APPRISE_MAX_SERVICE_WAIT = 20.0 caronc-apprise-182f859/apprise/compat.py000066400000000000000000000037731524161175200202260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Added for Python 3.9 Compatibility from dataclasses import dataclass as _dataclass from typing import Any, Callable, TypeVar _T = TypeVar("_T") def dataclass_compat(*dargs: Any, **dkwargs: Any) -> Callable[[_T], _T]: """ dataclass() wrapper that drops unsupported kwargs on older Python. Python 3.9 does not support slots= in dataclasses.dataclass(). """ try: return _dataclass(*dargs, **dkwargs) except TypeError: # Only strip slots when it is the cause if "slots" in dkwargs: dkwargs.pop("slots", None) return _dataclass(*dargs, **dkwargs) raise caronc-apprise-182f859/apprise/config/000077500000000000000000000000001524161175200176245ustar00rootroot00000000000000caronc-apprise-182f859/apprise/config/__init__.py000066400000000000000000000031671524161175200217440ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Used for testing from ..manager_config import ConfigurationManager from .base import ConfigBase # Initalize our Config Manager Singleton C_MGR = ConfigurationManager() __all__ = [ # Reference "ConfigBase", "ConfigurationManager", ] caronc-apprise-182f859/apprise/config/base.py000066400000000000000000002053261524161175200211200ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations from collections import deque import os import re import time import yaml from .. import common, plugins from ..asset import AppriseAsset from ..logger import logging from ..manager_config import ConfigurationManager from ..manager_plugins import NotificationManager from ..tag import AppriseTag from ..url import URL_TOKEN_ALIASES, URLBase from ..utils.cwe312 import cwe312_url from ..utils.parse import ( GET_SCHEMA_RE, QSD_FULL_MODE_KEYS, parse_bool, parse_list, parse_urls, ) from ..utils.time import zoneinfo # Test whether token is valid or not VALID_TOKEN = re.compile(r"(?P[a-z0-9][a-z0-9_]+)", re.I) # Keys excluded from the YAML-token re-apply that happens after # post_process_parse_url_results() inside config_parse_yaml(). # # 'tag'/'tags': assembled as a merged set (YAML tags + global_tags) by the # while loop before post_process runs; re-applying the raw YAML string would # replace the set with a string. YAML accepts both spellings ('tag:' and # 'tags:'); both are excluded so neither overwrites the assembled set. # # 'asset': written programmatically from the Python asset parameter at # that point in the while loop; VALID_TOKEN does not strip it, so a # user-written YAML 'asset:' key would otherwise overwrite the object. _YAML_REAPPLY_SKIP = frozenset(("tag", "tags", "asset")) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Grant access to our Configuration Manager Singleton C_MGR = ConfigurationManager() class ConfigBase(URLBase): """This is the base class for all supported configuration sources.""" # The Default Encoding to use if not otherwise detected encoding = "utf-8" # The default expected configuration format unless otherwise # detected by the sub-modules default_config_format = common.ConfigFormat.TEXT # This is only set if the user overrides the config format on the URL # this should always initialize itself as None config_format = None # Don't read any more of this amount of data into memory as there is no # reason we should be reading in more. This is more of a safe guard then # anything else. 128KB (131072B) max_buffer_size = 131072 # By default all configuration is not includable using the 'include' # line found in configuration files. allow_cross_includes = common.ContentIncludeMode.NEVER # the config path manages the handling of relative include config_path = os.getcwd() def __init__( self, cache: bool | int = True, recursion: int = 0, insecure_includes: bool = False, **kwargs: object, ) -> None: """Initialize some general logging and common server arguments that will keep things consistent when working with the configurations that inherit this class. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. recursion defines how deep we recursively handle entries that use the `include` keyword. This keyword requires us to fetch more configuration from another source and add it to our existing compilation. If the file we remotely retrieve also has an `include` reference, we will only advance through it if recursion is set to 2 deep. If set to zero it is off. There is no limit to how high you set this value. It would be recommended to keep it low if you do intend to use it. insecure_include by default are disabled. When set to True, all Apprise Config files marked to be in STRICT mode are treated as being in ALWAYS mode. Take a file:// based configuration for example, only a file:// based configuration can include another file:// based one. because it is set to STRICT mode. If an http:// based configuration file attempted to include a file:// one it woul fail. However this include would be possible if insecure_includes is set to True. There are cases where a self hosting apprise developer may wish to load configuration from memory (in a string format) that contains 'include' entries (even file:// based ones). In these circumstances if you want these 'include' entries to be honored, this value must be set to True. """ super().__init__(**kwargs) # Tracks the time the content was last retrieved on. This place a role # for cases where we are not caching our response and are required to # re-retrieve our settings. self._cached_time = None # Tracks previously loaded content for speed self._cached_servers = None # Initialize our recursion value self.recursion = recursion # Initialize our insecure_includes flag self.insecure_includes = insecure_includes if "encoding" in kwargs: # Store the encoding self.encoding = kwargs.get("encoding") fmt = kwargs.get("format") if fmt: try: self.config_format = ( fmt if isinstance(fmt, common.ConfigFormat) else common.ConfigFormat(fmt.lower()) ) except (AttributeError, ValueError): err = f"An invalid config format ({fmt}) was specified." self.logger.warning(err) raise TypeError(err) from None # Set our cache flag; it can be True or a (positive) integer try: self.cache = cache if isinstance(cache, bool) else int(cache) if self.cache < 0: err = f"A negative cache value ({cache}) was specified." self.logger.warning(err) raise TypeError(err) except (ValueError, TypeError): err = f"An invalid cache value ({cache}) was specified." self.logger.warning(err) raise TypeError(err) from None return def servers( self, asset: AppriseAsset | None = None, **kwargs: object, ) -> list[plugins.NotifyBase]: """Performs reads loaded configuration and returns all of the services that could be parsed and loaded.""" if not self.expired(): # We already have cached results to return; use them return self._cached_servers # Our cached response object self._cached_servers = [] # read() causes the child class to do whatever it takes for the # config plugin to load the data source and return unparsed content # None is returned if there was an error or simply no data content = self.read(**kwargs) if not isinstance(content, str): # Set the time our content was cached at self._cached_time = time.time() # Nothing more to do; return our empty cache list return self._cached_servers # Our Configuration format uses a default if one wasn't one detected # or enfored. config_format = ( self.default_config_format if self.config_format is None else self.config_format ) # Dynamically load our parse_ function based on our config format fn = getattr(ConfigBase, f"config_parse_{config_format.value}") # Initialize our asset object asset = asset if isinstance(asset, AppriseAsset) else self.asset # Execute our config parse function which always returns a tuple # of our servers and our configuration servers, configs = fn(content=content, asset=asset) # Free memory del content # Add entry to our server list self._cached_servers.extend(servers) # Configuration files were detected; recursively populate them # If we have been configured to do so for url in configs: if self.recursion > 0: # Attempt to acquire the schema at the very least to allow # our configuration based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" if not os.path.isabs(url): # We're dealing with a relative path; prepend # our current config path url = os.path.join(self.config_path, url) url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in C_MGR: ConfigBase.logger.error( f"Unsupported include schema {schema}." ) continue # CWE-312 (Secure Logging) Handling loggable_url = ( url if not asset.secure_logging else cwe312_url(url) ) # Parse our url details of the server object as dictionary # containing all of the information parsed from our URL results = C_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL self.logger.error( f"Unparseable include URL {loggable_url}" ) continue # Handle cross inclusion based on allow_cross_includes rules if ( C_MGR[schema].allow_cross_includes == common.ContentIncludeMode.STRICT and schema not in self.schemas() and not self.insecure_includes ) or C_MGR[ schema ].allow_cross_includes == common.ContentIncludeMode.NEVER: # Prevent the loading if insecure base protocols ConfigBase.logger.warning( f"Including {schema}:// based configuration is" f" prohibited. Ignoring URL {loggable_url}" ) continue # Prepare our Asset Object results["asset"] = asset # No cache is required because we're just lumping this in # and associating it with the cache value we've already # declared (prior to our recursion) results["cache"] = False # Recursion can never be parsed from the URL; we decrement # it one level results["recursion"] = self.recursion - 1 # Insecure Includes flag can never be parsed from the URL results["insecure_includes"] = self.insecure_includes try: # Attempt to create an instance of our plugin using the # parsed URL information cfg_plugin = C_MGR[results["schema"]](**results) except Exception as e: # the arguments are invalid or can not be used. self.logger.error( f"Could not load include URL: {loggable_url}" ) self.logger.debug(f"Loading Exception: {e!s}") continue # if we reach here, we can now add this servers found # in this configuration file to our list self._cached_servers.extend(cfg_plugin.servers(asset=asset)) else: # CWE-312 (Secure Logging) Handling loggable_url = ( url if not asset.secure_logging else cwe312_url(url) ) self.logger.debug( "Recursion limit reached; ignoring Include URL: %s", loggable_url, ) if self._cached_servers: self.logger.info( f"Loaded {len(self._cached_servers)} entries from" f" {self.url(privacy=asset.secure_logging)}" ) else: self.logger.warning( "Failed to load Apprise configuration from" f" {self.url(privacy=asset.secure_logging)}" ) # Set the time our content was cached at self._cached_time = time.time() return self._cached_servers def read(self) -> str | None: """This object should be implimented by the child classes.""" return None def expired(self) -> bool: """Simply returns True if the configuration should be considered as expired or False if content should be retrieved.""" if isinstance(self._cached_servers, list) and self.cache: # We have enough reason to look further into our cached content # and verify it has not expired. if self.cache is True: # we have not expired, return False return False # Verify our cache time to determine whether we will get our # content again. age_in_sec = time.time() - self._cached_time if age_in_sec <= self.cache: # We have not expired; return False return False # If we reach here our configuration should be considered # missing and/or expired. return True @staticmethod def __normalize_tag_groups(group_tags: dict[str, set[str]]) -> None: """ Used to normalize a tag assign map which looks like: { 'group': set('{tag1}', '{group1}', '{tag2}'), 'group1': set('{tag2}','{tag3}'), } Then normalized it (merging groups); with respect to the above, the output would be: { 'group': set('{tag1}', '{tag2}', '{tag3}), 'group1': set('{tag2}','{tag3}'), } """ # Prepare a key set list we can use tag_groups = {str(x) for x in group_tags} def _expand(tags, ignore=None): """Expands based on tag provided and returns a set. this also updates the group_tags while it goes """ # Prepare ourselves a return set results = set() ignore = set() if ignore is None else ignore # track groups groups = set() for tag in tags: if tag in ignore: continue # Track our groups groups.add(tag) # Store what we know is worth keeping if tag not in group_tags: # pragma: no cover # handle cases where the tag doesn't exist group_tags[tag] = set() results |= group_tags[tag] - tag_groups # Get simple tag assignments found = group_tags[tag] & tag_groups if not found: continue for gtag in found: if gtag in ignore: continue # Go deeper (recursion) ignore.add(tag) group_tags[gtag] = _expand({gtag}, ignore=ignore) results |= group_tags[gtag] # Pop ignore ignore.remove(tag) return results for tag in tag_groups: # Get our tags group_tags[tag] |= _expand({tag}) if not group_tags[tag]: ConfigBase.logger.warning( f"The group {tag} has no tags assigned to it" ) del group_tags[tag] @staticmethod def parse_url( url: str, verify_host: bool = True, ) -> dict[str, object] | None: """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url(url, verify_host=verify_host) if not results: # We're done; we failed to parse our url return results # Allow overriding the default config format if "format" in results["qsd"]: results["format"] = results["qsd"].get("format") if results["format"] not in common.CONFIG_FORMATS: URLBase.logger.warning( "Unsupported format specified {}".format(results["format"]) ) del results["format"] # Defines the encoding of the payload if "encoding" in results["qsd"]: results["encoding"] = results["qsd"].get("encoding") # Our cache value if "cache" in results["qsd"]: # First try to get it's integer value try: results["cache"] = int(results["qsd"]["cache"]) except (ValueError, TypeError): # No problem, it just isn't an integer; now treat it as a bool # instead: results["cache"] = parse_bool(results["qsd"]["cache"]) return results @staticmethod def detect_config_format( content: str, **kwargs: object, ) -> common.ConfigFormat | None: """Takes the specified content and attempts to detect the format type. The function returns the actual format type if detected, otherwise it returns None """ # Detect Format Logic: # - A pound/hashtag (#) is alawys a comment character so we skip over # lines matched here. # - Detection begins on the first non-comment and non blank line # matched. # - If we find a string followed by a colon, we know we're dealing # with a YAML file. # - If we find a string that starts with a URL, or our tag # definitions (accepting commas) followed by an equal sign we know # we're dealing with a TEXT format. # Define what a valid line should look like. # The tag group allows an optional "N:" priority prefix so that # "2:endpoint=ntfy://..." is recognised as TEXT format. valid_line_re = re.compile( r"^\s*(?P([;#]+(?P.*))|" r"(?P((?P[ \t,a-z0-9_:-]+)=)?[a-z0-9]+://.*)|" r"((?P[a-z0-9]+):.*))?$", re.I, ) try: # split our content up to read line by line content = re.split(r"\r*\n", content) except TypeError: # content was not expected string type ConfigBase.logger.error("Invalid Apprise configuration specified.") return None # By default set our return value to None since we don't know # what the format is yet config_format = None # iterate over each line of the file to attempt to detect it # stop the moment a the type has been determined for line, entry in enumerate(content, start=1): result = valid_line_re.match(entry) if not result: # Invalid syntax ConfigBase.logger.error( "Undetectable Apprise configuration found " f"based on line {line}." ) # Take an early exit return None # Attempt to detect configuration if result.group("yaml"): config_format = common.ConfigFormat.YAML ConfigBase.logger.debug( f"Detected YAML configuration based on line {line}." ) break elif result.group("text"): config_format = common.ConfigFormat.TEXT ConfigBase.logger.debug( f"Detected TEXT configuration based on line {line}." ) break # If we reach here, we have a comment entry # Adjust default format to TEXT config_format = common.ConfigFormat.TEXT return config_format @staticmethod def config_parse( content: str, asset: AppriseAsset | None = None, config_format: str | common.ConfigFormat | None = None, **kwargs: object, ) -> tuple[list[object], list[str]]: """Takes the specified config content and loads it based on the specified config_format. If a format isn't specified, then it is auto detected. """ if config_format is None: # Detect the format config_format = ConfigBase.detect_config_format(content) if not config_format: # We couldn't detect configuration ConfigBase.logger.error("Could not detect configuration") return ([], []) try: config_format = ( config_format if isinstance(config_format, common.ConfigFormat) else common.ConfigFormat(config_format.lower()) ) except (AttributeError, ValueError): ConfigBase.logger.error( f"An invalid configuration format ({config_format}) was" " specified" ) return ([], []) # Dynamically load our parse_ function based on our config format fn = getattr(ConfigBase, f"config_parse_{config_format.value}") # Execute our config parse function which always returns a list return fn(content=content, asset=asset) @staticmethod def config_parse_text( content: str, asset: AppriseAsset | None = None, ) -> tuple[list[object], list[str]]: """Parse the specified content as though it were a simple text file only containing a list of URLs. Return a tuple that looks like (servers, configs) where: - servers contains a list of loaded notification plugins - configs contains a list of additional configuration files referenced. You may also optionally associate an asset with the notification. The file syntax is: # # pound/hashtag allow for line comments # # One or more tags can be idenified using comma's (,) to separate # them. = # Or you can use this format (no tags associated) # you can also use the keyword 'include' and identify a # configuration location (like this file) which will be included # as additional configuration entries when loaded. include # Assign tag contents to a group identifier = """ # A list of loaded Notification Services servers = [] # A list of additional configuration files referenced using # the include keyword configs = [] # Track all of the tags we want to assign later on group_tags = {} # Track our entries to preload preloaded = [] # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() # Define what a valid line should look like. # The tags group allows an optional leading "N:" priority prefix so # that entries like "2:endpoint=ntfy://..." are parsed correctly. valid_line_re = re.compile( r"^\s*(?P([;#]+(?P.*))|" r"(\s*(?P[a-z0-9, \t_:-]+)\s*=|=)?\s*" r"((?P[a-z0-9]{1,32}://.*)|(?P[a-z0-9, \t_-]+))|" r"include\s+(?P.+))?\s*$", re.I, ) try: # split our content up to read line by line content = re.split(r"\r*\n", content) except TypeError: # content was not expected string type ConfigBase.logger.error( "Invalid Apprise TEXT based configuration specified." ) return ([], []) for line, entry in enumerate(content, start=1): result = valid_line_re.match(entry) if not result: # Invalid syntax ConfigBase.logger.error( "Invalid Apprise TEXT configuration format found " f"{entry} on line {line}." ) # Assume this is a file we shouldn't be parsing. It's owner # can read the error printed to screen and take action # otherwise. return ([], []) # Retrieve our line url, assign, config = ( result.group("url"), result.group("assign"), result.group("config"), ) if not (url or config or assign): # Comment/empty line; do nothing continue if config: # CWE-312 (Secure Logging) Handling loggable_url = ( config if not asset.secure_logging else cwe312_url(config) ) ConfigBase.logger.debug(f"Include URL: {loggable_url}") # Store our include line configs.append(config.strip()) continue # CWE-312 (Secure Logging) Handling loggable_url = url if not asset.secure_logging else cwe312_url(url) if assign: groups = set(parse_list(result.group("tags"), cast=str)) if not groups: # no tags were assigned ConfigBase.logger.warning( "Unparseable tag assignment - no group(s) " f"on line {line}" ) continue # Get our tags tags = set(parse_list(assign, cast=str)) if not tags: # no tags were assigned ConfigBase.logger.warning( "Unparseable tag assignment - no tag(s) to assign " f"on line {line}" ) continue # Update our tag group map for tag_group in groups: if tag_group not in group_tags: group_tags[tag_group] = set() # ensure our tag group is never included in the assignment group_tags[tag_group] |= tags - {tag_group} continue # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results is None: # url_to_dict() already logged an error with the URL; # repeat at debug level with line number for context. ConfigBase.logger.debug( f"Unparseable URL {loggable_url} on line {line}." ) continue # Build a list of tags to associate with the newly added # notifications if any were set results["tag"] = set(parse_list(result.group("tags"), cast=str)) # A retry count on a service tag (e.g. "alerts:3=slack://...") is # not valid here: per-service retry belongs on the URL (?retry=N) # and call-time retry overrides belong on the notify() filter. if any( AppriseTag.parse(t).retry is not None for t in results["tag"] ): ConfigBase.logger.warning( "You can not specify a retry count as part of a service" f" tag on line {line}; use ?retry=N in the URL instead." ) continue # Set our Asset Object results["asset"] = asset # Store our preloaded entries preloaded.append( { "results": results, "line": line, "loggable_url": loggable_url, } ) # # Normalize Tag Groups # - Expand Groups of Groups so that they don't exist # ConfigBase.__normalize_tag_groups(group_tags) # # URL Processing # for entry in preloaded: # Point to our results entry for easier reference below results = entry["results"] # # Apply our tag groups if they're defined # for group, tags in group_tags.items(): # Detect if anything assigned to this tag also maps back to a # group. If so we want to add the group to our list if next( (True for tag in results["tag"] if tag in tags), False ): results["tag"].add(group) try: # Attempt to create an instance of our plugin using the # parsed URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL ConfigBase.logger.debug( "Loaded URL: %s", plugin.url(privacy=results["asset"].secure_logging), ) except Exception as e: # the arguments are invalid or can not be used. ConfigBase.logger.error( "Could not load URL {} on line {}.".format( entry["loggable_url"], entry["line"] ) ) ConfigBase.logger.debug(f"Loading Exception: {e!s}") continue # if we reach here, we successfully loaded our data servers.append(plugin) # Return what was loaded return (servers, configs) @staticmethod def config_parse_yaml( content: str, asset: AppriseAsset | None = None, ) -> tuple[list[object], list[str]]: """Parse the specified content as though it were a yaml file specifically formatted for Apprise. Return a tuple that looks like (servers, configs) where: - servers contains a list of loaded notification plugins - configs contains a list of additional configuration files referenced. You may optionally associate an asset with the notification. """ # A list of loaded Notification Services servers = [] # A list of additional configuration files referenced using # the include keyword configs = [] # Group Assignments group_tags = {} # Track our entries to preload preloaded = [] try: # Load our data (safely) result = yaml.load(content, Loader=yaml.SafeLoader) except ( AttributeError, yaml.parser.ParserError, yaml.error.MarkedYAMLError, ) as e: # Invalid content ConfigBase.logger.error("Invalid Apprise YAML data specified.") ConfigBase.logger.debug(f"YAML Exception:{os.linesep}{e}") return ([], []) if not isinstance(result, dict): # Invalid content ConfigBase.logger.error( "Invalid Apprise YAML based configuration specified." ) return ([], []) # YAML Version version = result.get("version", 1) if version != 1: # Invalid syntax ConfigBase.logger.error( f"Invalid Apprise YAML version specified {version}." ) return ([], []) # # global asset object # asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() # Prepare our default timezone default_timezone = asset.tzinfo # Acquire our asset tokens tokens = result.get("asset", None) if tokens and isinstance(tokens, dict): raw_tz = tokens.get("timezone", tokens.get("tz")) if isinstance(raw_tz, str): default_timezone = zoneinfo(re.sub(r"[^\w/-]+", "", raw_tz)) if not default_timezone: ConfigBase.logger.warning( 'Ignored invalid timezone "%s"', raw_tz ) default_timezone = asset.tzinfo else: asset._tzinfo = default_timezone elif raw_tz is not None: ConfigBase.logger.warning( 'Ignored invalid timezone "%r"', raw_tz ) # Iterate over remaining tokens for k, v in tokens.items(): if k.startswith("_") or k.endswith("_"): # Entries are considered reserved if they start or end # with an underscore ConfigBase.logger.warning(f'Ignored asset key "{k}".') continue if not ( hasattr(asset, k) and isinstance(getattr(asset, k), (bool, str)) ): # We can't set a function or non-string set value ConfigBase.logger.warning(f'Invalid asset key "{k}".') continue if v is None: # Convert to an empty string v = "" if isinstance(v, (bool, str)) and isinstance( getattr(asset, k), bool ): # If the object in the Asset is a boolean, then # we want to convert the specified string to # match that. setattr(asset, k, parse_bool(v)) elif isinstance(v, str): # Set our asset object with the new value setattr(asset, k, v.strip()) else: # we must set strings with a string ConfigBase.logger.warning(f'Invalid asset value to "{k}".') continue # # global tag root directive # global_tags = set() tags = result.get("tag", result.get("tags", None)) if tags and isinstance(tags, (list, tuple, str)): # Store any preset tags global_tags = set(parse_list(tags, cast=str)) # # groups root directive # groups = result.get("groups", None) if isinstance(groups, dict): # # Dictionary # for groups_, tags in groups.items(): for group in parse_list(groups_, cast=str): if isinstance(tags, (list, tuple)): tags_ = set() for e in tags: if isinstance(e, dict): tags_ |= set(e.keys()) else: tags_ |= set(parse_list(e, cast=str)) # Final assignment tags = tags_ else: tags = set(parse_list(tags, cast=str)) if group not in group_tags: group_tags[group] = tags else: group_tags[group] |= tags elif isinstance(groups, (list, tuple)): # # List of Dictionaries # # Iterate over each group defined and store it for no, entry in enumerate(groups): if not isinstance(entry, dict): ConfigBase.logger.warning( f"No assignment for group {entry}, entry #{no + 1}" ) continue for groups_, tags in entry.items(): for group in parse_list(groups_, cast=str): if isinstance(tags, (list, tuple)): tags_ = set() for e in tags: if isinstance(e, dict): tags_ |= set(e.keys()) else: tags_ |= set(parse_list(e, cast=str)) # Final assignment tags = tags_ else: tags = set(parse_list(tags, cast=str)) if group not in group_tags: group_tags[group] = tags else: group_tags[group] |= tags # include root directive # includes = result.get("include", None) if isinstance(includes, str): # Support a single inline string or multiple ones separated by a # comma and/or space includes = parse_urls(includes) elif not isinstance(includes, (list, tuple)): # Not a problem; we simply have no includes includes = [] # Iterate over each config URL for _no, url in enumerate(includes): if isinstance(url, str): # Support a single inline string or multiple ones separated by # a comma and/or space configs.extend(parse_urls(url)) elif isinstance(url, dict): # Store the url and ignore arguments associated configs.extend(u for u in url) # # urls root directive # urls = result.get("urls", None) if not isinstance(urls, (list, tuple)): # Not a problem; we simply have no urls urls = [] # Iterate over each URL for no, url in enumerate(urls): # Our results object is what we use to instantiate our object if # we can. Reset it to None on each iteration results = [] # CWE-312 (Secure Logging) Handling loggable_url = url if not asset.secure_logging else cwe312_url(url) if isinstance(url, str): # We're just a simple URL string... schema = GET_SCHEMA_RE.match(url) if schema is None: # Log invalid entries so that maintainer of config # config file at least has something to take action # with. ConfigBase.logger.warning( f"Invalid URL {loggable_url}, entry #{no + 1}" ) continue # We found a valid schema worthy of tracking; store it's # details: results_ = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results_ is None: # url_to_dict() already logged an error with the URL; # repeat at debug level with entry number for context. ConfigBase.logger.debug( f"Unparseable URL {loggable_url}, entry #{no + 1}" ) continue # add our results to our global set results.append((results_, {})) elif isinstance(url, dict): # We are a url string with additional unescaped options. In # this case we want to iterate over all of our options so we # can at least tell the end user what entries were ignored # due to errors it = iter(url.items()) # Track the URL to-load url_ = None # Track last acquired schema schema = None for key, tokens_ in it: # Test our schema schema_ = GET_SCHEMA_RE.match(key) if schema_ is None: # Non-schema key -- may be a sibling token sitting # before the URL key in the YAML mapping. Sibling # tokens are collected later via url.items(), so # nothing is "ignored" here. Skip silently. continue # Store our schema schema = schema_.group("schema").lower() # Store our URL and Schema Regex url_ = key # Update our token assignment tokens = tokens_ # We're done break if url_ is None: # the loop above failed to match anything ConfigBase.logger.warning( f"Unsupported URL, entry #{no + 1}" ) continue # Collect sibling tokens from the entire mapping, # excluding only the URL key itself. Using the full # url.items() dict (rather than the remaining iterator) # ensures siblings that appear *before* the URL key in # the YAML mapping are captured too. YAML does not # guarantee key order within a mapping, so order must # not matter here. # # In YAML, keys at the same indentation level as # the schema URL key become siblings in the same # mapping dict rather than children of the URL key. # We treat these sibling keys as token overrides # that sit above URL-parsed values (priority 3) and # below explicitly-nested child tokens (priority 1). # # This means both YAML forms produce the same result: # # # sibling form (same indentation) # - mailtos://_: # smtp: smtp.example.com # from: no-reply@example.com # # # child form (indented one level deeper) # - mailtos://_: # smtp: smtp.example.com # from: no-reply@example.com sibling_tokens = { k: v for k, v in url.items() if k not in (url_, "schema") } results_ = plugins.url_to_dict( url_, secure_logging=asset.secure_logging ) if results_ is None: # Setup dictionary results_ = { # Minimum requirements "schema": schema, } if isinstance(tokens, (list, tuple, set)): # Pre-process sibling tokens once before the loop # so each per-entry copy already has template # mappings resolved (smtp -> smtp_host, etc.) if sibling_tokens and schema in N_MGR: sibling_tokens = ConfigBase._special_token_handler( schema, sibling_tokens ) # populate and/or override any results populated # by parse_url() for entries in tokens: # Copy ourselves a template of our parsed URL # as a base to work with r = results_.copy() # Apply sibling tokens as a shared base before # per-entry tokens (sibling < per-entry) if sibling_tokens: r.update(sibling_tokens) # We are a url string with additional options if isinstance(entries, dict): # Tags you just can't over-ride if "schema" in entries: del entries["schema"] # support our special tokens if schema in N_MGR: entries = ConfigBase._special_token_handler( schema, entries ) # Extend our dictionary with our new entries r.update(entries) # Record the YAML contributions for this entry # so post_process_parse_url_results() cannot # overwrite them (enforces YAML > qsd priority). yaml_ = {} if sibling_tokens: yaml_.update(sibling_tokens) yaml_.update(entries) # add our results to our global set results.append((r, yaml_)) elif isinstance(tokens, dict): # Strip 'schema' from child tokens -- it is determined # by the URL key's own schema and must not be overridden # by a child dict value (matching the list-expansion # branch that does the same via del entries["schema"]). tokens.pop("schema", None) # Normalize sibling and child token dicts # independently before merging, so that an alias key # in sibling tokens (e.g. 'smtp' -> 'smtp_host') can # never overwrite a canonical key already set in the # higher-priority child tokens. if schema in N_MGR: if sibling_tokens: sibling_tokens = ConfigBase._special_token_handler( schema, sibling_tokens ) tokens = ConfigBase._special_token_handler( schema, tokens ) # Merge sibling tokens as the lower-priority base # and let the child token dict (the indented value # of the URL key) override on a per-key basis. if sibling_tokens: merged = sibling_tokens.copy() merged.update(tokens) tokens = merged # Copy ourselves a template of our parsed URL as # a base to work with r = results_.copy() # add our result set r.update(tokens) # add our results to our global set results.append((r, dict(tokens))) elif sibling_tokens: # The URL key had a null child value, but sibling # keys supply token overrides. Process them the # same way as a child token dict. if schema in N_MGR: sibling_tokens = ConfigBase._special_token_handler( schema, sibling_tokens ) # Copy ourselves a template of our parsed URL as # a base to work with r = results_.copy() # Apply the sibling token overrides r.update(sibling_tokens) # add our results to our global set results.append((r, dict(sibling_tokens))) else: # add our results to our global set results.append((results_, {})) else: # Unsupported ConfigBase.logger.warning( f"Unsupported Apprise YAML entry #{no + 1}" ) continue # Track our entries entry = 0 # Prepare our results for post-processing results = deque(results) while len(results): # Increment our entry count entry += 1 # Grab our first item results_, yaml_tokens_ = results.popleft() if results_["schema"] not in N_MGR: # the arguments are invalid or can not be used. ConfigBase.logger.warning( "An invalid Apprise schema ({}) in YAML configuration " "entry #{}, item #{}".format( results_["schema"], no + 1, entry ) ) continue # tag is a special keyword that is managed by Apprise object. # The below ensures our tags are set correctly if "tag" in results_: # Tidy our list up results_["tag"] = ( set(parse_list(results_["tag"], cast=str)) | global_tags ) if "tags" in results_: ConfigBase.logger.warning( ( "URL #{}: {} contains both 'tag' and 'tags' " "keyword" ).format(no + 1, url) ) del results_["tags"] elif "tags" in results_: # Tidy our list up results_["tag"] = ( set(parse_list(results_["tags"], cast=str)) | global_tags ) # Should not carry forward del results_["tags"] else: # Just use the global settings results_["tag"] = global_tags # A retry count on a service tag is not valid here: per-service # retry belongs on the URL (retry: key) and call-time retry # overrides belong on the notify() filter. _retry_tags = [ t for t in results_["tag"] if AppriseTag.parse(t).retry is not None ] if _retry_tags: ConfigBase.logger.warning( "You can not specify a retry count as part of a" " service tag (YAML entry #{}, item #{}); use the" " 'retry:' key instead.".format(no + 1, entry) ) continue for key in list(results_.keys()): # Strip out any tokens we know that we can't accept and # warn the user match = VALID_TOKEN.match(key) if not match: ConfigBase.logger.warning( f"Ignoring invalid token ({key}) found in YAML " f"configuration entry #{no + 1}, item #{entry}" ) del results_[key] if ConfigBase.logger.isEnabledFor(logging.TRACE): ConfigBase.logger.trace( "URL #%d: %s unpacked as:%s%s", no + 1, url, os.linesep, os.linesep.join( [f'{k}="{a}"' for k, a in results_.items()] ), ) # Prepare our Asset Object results_["asset"] = asset # For the second post_process_parse_url_results call we # need to distinguish two plugin families: # # URLBase-based plugins use the full-mode # utils.parse.parse_url() (simple=False), which always creates # qsd plus the extended qsd dicts (qsd+, qsd-, qsd:). The # presence of these keys is a reliable indicator that qsd was # already applied once before YAML tokens were merged. # # post_process reads from qsd (verify, redirect, rto, cto, # port, user, password, URL_TOKEN_ALIASES aliases, ...) is # already reflected in results_. YAML tokens then overrode # those values at higher priority. Strip qsd entirely so # the second call cannot re-apply any qsd field and undo # the YAML-token values. The second call will still # normalise types (e.g. string -> bool for 'verify') from # the already-merged results_. There is intentionally no # hardcoded list of fields to skip here; stripping the whole # qsd is sufficient and avoids coupling this code to the # internals of post_process_parse_url_results(). # # @notify-style plugins use a minimal parse_url that does # NOT call post_process, so 'verify' is absent. Keep qsd # intact for those so verify, redirect, and other fields # still get processed on this second call. # # The original qsd is always restored after the call so # that callers (e.g. the @notify meta dict) can still # inspect the raw query-string values. orig_qsd = results_.get("qsd") if all( k in results_ for k in QSD_FULL_MODE_KEYS ) and isinstance(orig_qsd, dict): # Full-mode parse_url() always creates all three extended # qsd dicts (qsd+, qsd-, qsd:), even when the URL has no # query params. Simple-mode @notify parse_url() creates # only qsd. YAML sibling tokens never inject any of these # keys. QSD_FULL_MODE_KEYS is the authoritative list from # utils/parse.py -- no duplication. # Strip the full qsd so it cannot overwrite YAML tokens # on this second post_process call. del results_["qsd"] # Handle post processing of result set results_ = URLBase.post_process_parse_url_results(results_) # Re-apply YAML tokens on top of post_process results. # For URLBase plugins qsd was stripped above so this is # idempotent. For @notify plugins qsd was kept intact and # post_process_parse_url_results() will have overwritten any # YAML-token values with qsd values; re-applying here restores # the correct YAML > qsd priority for those plugins too. # # Keys in _YAML_REAPPLY_SKIP are handled specially by the # while loop above and must not be overwritten here. if yaml_tokens_: results_.update( { k: v for k, v in yaml_tokens_.items() if k not in _YAML_REAPPLY_SKIP } ) # Restore the original qsd so the full meta dict is # available downstream regardless of which branch above ran. # post_process_parse_url_results() never adds a 'qsd' key, so # the elif branch (orig_qsd is None but qsd appeared) cannot # occur and is omitted intentionally. if orig_qsd is not None: results_["qsd"] = orig_qsd # Store our preloaded entries preloaded.append( { "results": results_, "entry": no + 1, "item": entry, } ) # # Normalize Tag Groups # - Expand Groups of Groups so that they don't exist # ConfigBase.__normalize_tag_groups(group_tags) # # URL Processing # for entry in preloaded: # Point to our results entry for easier reference below results = entry["results"] # # Apply our tag groups if they're defined # for group, tags in group_tags.items(): # Detect if anything assigned to this tag also maps back to a # group. If so we want to add the group to our list if next( (True for tag in results["tag"] if tag in tags), False ): results["tag"].add(group) # Now we generate our plugin try: # Attempt to create an instance of our plugin using the # parsed URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL ConfigBase.logger.debug( "Loaded URL: %s", plugin.url(privacy=results["asset"].secure_logging), ) except Exception as e: # the arguments are invalid or can not be used. ConfigBase.logger.error( "Could not load Apprise YAML configuration " "entry #{}, item #{}".format(entry["entry"], entry["item"]) ) ConfigBase.logger.debug(f"Loading Exception: {e!s}") continue # if we reach here, we successfully loaded our data servers.append(plugin) preloaded.clear() return (servers, configs) def pop(self, index: int = -1) -> object: """Removes an indexed Notification Service from the stack and returns it. By default, the last element of the list is removed. """ if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() # Pop the element off of the stack return self._cached_servers.pop(index) def clear_cache(self) -> None: """Cleans cache""" self._cached_servers = None self._cached_time = None @staticmethod def _special_token_handler( schema: str, tokens: dict[str, object], ) -> dict[str, object]: """This function takes a list of tokens and updates them to no longer include any special tokens such as +,-, and : - schema must be a valid schema of a supported plugin type - tokens must be a dictionary containing the yaml entries parsed. The idea here is we can post process a set of tokens provided in a YAML file where the user provided some of the special keywords. We effectivley look up what these keywords map to their appropriate value they're expected """ # Create a copy of our dictionary tokens = tokens.copy() # Apply URL_TOKEN_ALIASES so shorthand keys (e.g. 'pass') are # normalized to the canonical kwarg name ('password') before any # plugin-specific template_args mapping runs. YAML tokens bypass # parse_url() and its initial post_process_parse_url_results() call # inside url_to_dict(), so the same alias table must be applied here. for alias, canonical in URL_TOKEN_ALIASES.items(): if alias in tokens and ( canonical not in tokens or tokens[canonical] is None ): # Canonical absent or explicitly null -- promote alias. # A null canonical (e.g. password: with no value) is treated # as "not provided", consistent with how # post_process_parse_url_results() handles None credentials. tokens[canonical] = tokens.pop(alias) elif alias in tokens: # canonical key already set to a non-None value -- discard # the alias so the explicit canonical is not overwritten del tokens[alias] for kw, meta in N_MGR[schema].template_kwargs.items(): # Determine our prefix: prefix = meta.get("prefix", "+") # Detect any matches matches = { k[1:]: str(v) for k, v in tokens.items() if k.startswith(prefix) } if not matches: # we're done with this entry continue if not isinstance(tokens.get(kw), dict): # Invalid; correct it tokens[kw] = {} # strip out processed tokens tokens = { k: v for k, v in tokens.items() if not k.startswith(prefix) } # Update our entries tokens[kw].update(matches) # Now map our tokens accordingly to the class templates defined by # each service. # # This is specifically used for YAML file parsing. It allows a user to # define an entry such as: # # urls: # - mailto://user:pass@domain: # - to: user1@hotmail.com # - to: user2@hotmail.com # # Under the hood, the NotifyEmail() class does not parse the `to` # argument. It's contents needs to be mapped to `targets`. This is # defined in the class via the `template_args` and template_tokens` # section. # # This function here allows these mappings to take place within the # YAML file as independant arguments. class_templates = plugins.details(N_MGR[schema]) for key in list(tokens.keys()): if key not in class_templates["args"]: # No need to handle non-arg entries continue # get our `map_to` and/or 'alias_of' value (if it exists) map_to = class_templates["args"][key].get( "alias_of", class_templates["args"][key].get("map_to", "") ) if map_to == key: # We're already good as we are now continue if map_to in class_templates["tokens"]: meta = class_templates["tokens"][map_to] else: meta = class_templates["args"].get( map_to, class_templates["args"][key] ) # Perform a translation/mapping if our code reaches here value = tokens[key] del tokens[key] # Detect if we're dealign with a list or not is_list = re.search(r"^list:.*", meta.get("type"), re.IGNORECASE) if map_to not in tokens: tokens[map_to] = [] if is_list else meta.get("default") elif is_list and not isinstance(tokens.get(map_to), list): # Convert ourselves to a list if we aren't already tokens[map_to] = [tokens[map_to]] # Type Conversion if re.search( r"^(choice:)?string", meta.get("type"), re.IGNORECASE ) and not isinstance(value, str): # Ensure our format is as expected value = str(value) # Apply any further translations if required (absolute map) # This is the case when an arg maps to a token which further # maps to a different function arg on the class constructor abs_map = meta.get("map_to", map_to) # Set our token as how it was provided by the configuration if isinstance(tokens.get(map_to), list): tokens[abs_map].append(value) else: tokens[abs_map] = value # Return our tokens return tokens def __getitem__(self, index: int) -> object: """Returns the indexed server entry associated with the loaded notification servers.""" if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() return self._cached_servers[index] def __iter__(self) -> object: """Returns an iterator to our server list.""" if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() return iter(self._cached_servers) def __len__(self) -> int: """Returns the total number of servers loaded.""" if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() return len(self._cached_servers) def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if our content was downloaded correctly. """ if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() return bool(self._cached_servers) caronc-apprise-182f859/apprise/config/file.py000066400000000000000000000137451524161175200211270ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import re from ..common import ConfigFormat, ContentIncludeMode from ..locale import gettext_lazy as _ from ..utils.disk import path_decode from .base import ConfigBase class ConfigFile(ConfigBase): """A wrapper for File based configuration sources.""" # The default descriptive name associated with the service service_name = _("Local File") # The default protocol protocol = "file" # Configuration file inclusion can only be of the same type allow_cross_includes = ContentIncludeMode.STRICT def __init__(self, path, **kwargs): """Initialize File Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) # Store our file path as it was set self.path = path_decode(path) # Track the file as it was saved self.__original_path = os.path.normpath(path) # Update the config path to be relative to our file we just loaded self.config_path = os.path.dirname(self.path) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our cache value if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Define any URL parameters params = { "encoding": self.encoding, "cache": cache, } if self.config_format: # A format was enforced; make sure it's passed back with the url params["format"] = self.config_format return "file://{path}{params}".format( path=self.quote(self.__original_path), params=f"?{self.urlencode(params)}" if params else "", ) def read(self, **kwargs): """Perform retrieval of the configuration based on the specified request.""" response = None try: if ( self.max_buffer_size > 0 and os.path.getsize(self.path) > self.max_buffer_size ): # Content exceeds maximum buffer size self.logger.error( "File size exceeds maximum allowable buffer length" f" ({int(self.max_buffer_size / 1024)}KB)." ) return None except OSError: # getsize() can throw this acception if the file is missing # and or simply isn't accessible self.logger.error(f"File is not accessible: {self.path}") return None # Always call throttle before any server i/o is made self.throttle() try: with open(self.path, encoding=self.encoding) as f: # Store our content for parsing response = f.read() except (ValueError, UnicodeDecodeError): # A result of our strict encoding check; if we receive this # then the file we're opening is not something we can # understand the encoding of.. self.logger.error( f"File not using expected encoding ({self.encoding}) :" f" {self.path}" ) return None except OSError: # IOError is present for backwards compatibility with Python # versions older then 3.3. >= 3.3 throw OSError now. # Could not open and/or read the file; this is not a problem since # we scan a lot of default paths. self.logger.error(f"File can not be opened for read: {self.path}") return None # Detect config format based on file extension if it isn't already # enforced if ( self.config_format is None and re.match(r"^.*\.ya?ml\s*$", self.path, re.I) is not None ): # YAML Filename Detected self.default_config_format = ConfigFormat.YAML # Return our response object return response @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = ConfigBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results match = re.match(r"[a-z0-9]+://(?P[^?]+)(\?.*)?", url, re.I) if not match: return None results["path"] = ConfigFile.unquote(match.group("path")) return results caronc-apprise-182f859/apprise/config/http.py000066400000000000000000000240051524161175200211560ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import requests from ..common import ConfigFormat, ContentIncludeMode from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_PATH_SAFE_CHARS from .base import ConfigBase # Support YAML formats # text/yaml # text/x-yaml # application/yaml # application/x-yaml MIME_IS_YAML = re.compile(r"(text|application)/(x-)?yaml", re.I) # Support TEXT formats # text/plain # text/html MIME_IS_TEXT = re.compile(r"text/(plain|html)", re.I) class ConfigHTTP(ConfigBase): """A wrapper for HTTP based configuration sources.""" # The default descriptive name associated with the service service_name = _("Web Based") # The default protocol protocol = "http" # The default secure protocol secure_protocol = "https" # If an HTTP error occurs, define the number of characters you still want # to read back. This is useful for debugging purposes, but nothing else. # The idea behind enforcing this kind of restriction is to prevent abuse # from queries to services that may be untrusted. max_error_buffer_size = 2048 # Configuration file inclusion can always include this type allow_cross_includes = ContentIncludeMode.ALWAYS def __init__(self, headers=None, **kwargs): """Initialize HTTP Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.schema = "https" if self.secure else "http" self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "/" self.headers = {} if headers: # Store our extra headers self.headers.update(headers) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our cache value if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Define any arguments set params = { "encoding": self.encoding, "cache": cache, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if self.config_format: # A format was enforced; make sure it's passed back with the url params["format"] = self.config_format # Append our headers into our args params.update({f"+{k}": v for k, v in self.headers.items()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=self.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=self.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, hostname=self.quote(self.host, safe=""), port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=self.quote(self.fullpath, safe=URL_PATH_SAFE_CHARS), params=self.urlencode(params), ) def read(self, **kwargs): """Perform retrieval of the configuration based on the specified request.""" # prepare XML Object headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) auth = None if self.user: auth = (self.user, self.password) url = f"{self.schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath self.logger.debug( f"HTTP POST URL: {url} (cert_verify={self.verify_certificate!r})" ) # Prepare our response object response = None # Where our request object will temporarily live. r = None # Always call throttle before any remote server i/o is made self.throttle() try: # Make our request with requests.post( url, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, stream=True, ) as r: # Handle Errors r.raise_for_status() # raise_for_status() only covers 4xx/5xx; when redirect # following is disabled any 3xx must be treated as a failure # so we do not silently return a redirect stub as config. # Using a status-code range rather than r.is_redirect # catches 3xx responses that lack a Location header. if not self.redirects and 300 <= r.status_code < 400: self.logger.error( "HTTP redirect encountered but redirect " "following is disabled:" f" {self.url(privacy=True)}" ) return None # Get our file-size (if known) try: file_size = int(r.headers.get("Content-Length", "0")) except (TypeError, ValueError): # Handle edge case where Content-Length is a bad value file_size = 0 # Store our response if ( self.max_buffer_size > 0 and file_size > self.max_buffer_size ): # Provide warning of data truncation self.logger.error( "HTTP config response exceeds maximum buffer length " f"({int(self.max_buffer_size / 1024)}KB);" ) # Return None - buffer execeeded return None # Store our result (but no more than our buffer length) response = r.text[: self.max_buffer_size + 1] # Verify that our content did not exceed the buffer size: if len(response) > self.max_buffer_size: # Provide warning of data truncation self.logger.error( "HTTP config response exceeds maximum buffer length " f"({int(self.max_buffer_size / 1024)}KB);" ) # Return None - buffer execeeded return None # Detect config format based on mime if the format isn't # already enforced content_type = r.headers.get( "Content-Type", "application/octet-stream" ) if self.config_format is None and content_type: if MIME_IS_YAML.match(content_type) is not None: # YAML data detected based on header content self.default_config_format = ConfigFormat.YAML elif MIME_IS_TEXT.match(content_type) is not None: # TEXT data detected based on header content self.default_config_format = ConfigFormat.TEXT except requests.RequestException as e: self.logger.error( "A Connection error occurred retrieving HTTP " f"configuration from {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return None (signifying a failure) return None # Return our response object return response @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = ConfigBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set results["headers"] = results["qsd-"] results["headers"].update(results["qsd+"]) return results caronc-apprise-182f859/apprise/config/memory.py000066400000000000000000000052551524161175200215150ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from ..locale import gettext_lazy as _ from .base import ConfigBase class ConfigMemory(ConfigBase): """For information that was loaded from memory and does not persist anywhere.""" # The default descriptive name associated with the service service_name = _("Memory") # The default protocol protocol = "memory" def __init__(self, content, **kwargs): """Initialize Memory Object. Memory objects just store the raw configuration in memory. There is no external reference point. It's always considered cached. """ super().__init__(**kwargs) # Store our raw config into memory self.content = content if self.config_format is None: # Detect our format if possible self.config_format = ConfigMemory.detect_config_format( self.content ) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" return "memory://" def read(self, **kwargs): """Simply return content stored into memory.""" return self.content @staticmethod def parse_url(url): """Memory objects have no parseable URL.""" # These URLs can not be parsed return None caronc-apprise-182f859/apprise/conversion.py000066400000000000000000002075251524161175200211310ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from bisect import bisect_left import contextlib from html.parser import HTMLParser import re from markdown import markdown from .common import NotifyFormat from .url import URLBase # Cap list indentation so deeply nested input stays linear. LIST_DEPTH_MAX = 4 # Apply the same depth cap to blockquote prefixes. BLOCKQUOTE_DEPTH_MAX = 4 # Bound the context-stack depth so adversarially nested HTML cannot # exhaust memory. Frames beyond this limit are silently dropped. MAX_FRAME_DEPTH = 200 class _Marker(str): """Identify converter-generated structure.""" class _ListMarker(_Marker): """Identify a generated list marker.""" class _QuoteMarker(_Marker): """Identify a generated quote prefix.""" class _ListIndent(_Marker): """Identify list continuation indentation.""" class _ParaBreak: """Represent a paragraph boundary and its prefix.""" __slots__ = ("in_quote", "prefix") def __init__(self, prefix="", in_quote=False): """Initialize a paragraph boundary.""" # Store the continuation prefix self.prefix = prefix # Track whether the boundary remains inside a quote self.in_quote = in_quote def convert_between(from_format, to_format, content): """Converts between different suported formats. If no conversion exists, or the selected one fails, the original text will be returned. This function returns the content translated (if required) """ # Map each supported format pair to its converter converters = { (NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html, (NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html, (NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text, (NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_markdown, } # Fetch the converter registered for this format pair. convert = converters.get((from_format, to_format)) # Preserve the original content when no conversion is available return convert(content) if convert else content def markdown_to_html(content): """Converts specified content from markdown to HTML.""" # Enable notification-friendly line-break and table extensions. return markdown( content, extensions=["markdown.extensions.nl2br", "markdown.extensions.tables"], ) def text_to_html(content): """Converts specified content from plain text to HTML.""" # First eliminate any carriage returns return URLBase.escape_html(content, convert_new_lines=True) def html_to_text(content): """Converts a content from HTML to plain text.""" # Initialize the plain-text parser. parser = HTMLConverter() # Feed and finalize the HTML document parser.feed(content) parser.close() # Return the finalized parser output. return parser.converted def html_to_markdown(content): """Convert HTML content to CommonMark.""" # Initialize the Markdown parser. parser = HTMLMarkdownConverter() # Feed and finalize the HTML document parser.feed(content) parser.close() # Return the finalized parser output. return parser.converted class HTMLConverter(HTMLParser): """An HTML to plain text converter tuned for email messages.""" # The following tags must start on a new line BLOCK_TAGS = ( "p", "h1", "h2", "h3", "h4", "h5", "h6", "div", "td", "th", "code", "pre", "label", "li", ) # the folowing tags ignore any internal text IGNORE_TAGS = ( "form", "input", "textarea", "select", "ul", "ol", "style", "link", "meta", "title", "html", "head", "script", ) # Condense Whitespace WS_TRIM = re.compile(r"[\s]+", re.DOTALL | re.MULTILINE) # Sentinel value for block tag boundaries, which may be consolidated into a # single line break. BLOCK_END = {} def __init__(self, **kwargs): """Initialize the HTML converter.""" # Initialize the standard-library HTML parser. super().__init__(**kwargs) # Should we store the text content or not? self._do_store = True # Initialize internal result list self._result = [] # Initialize public result field (not populated until close() is # called) self.converted = "" def close(self): """Finalize the converted content.""" # Combine buffered fragments into one string. string = "".join(self._finalize(self._result)) # Publish the normalized result. self.converted = string.strip() def _finalize(self, result): """Combines and strips consecutive strings, then converts consecutive block ends into singleton newlines. [ {be} " Hello " {be} {be} " World!" ] -> "\nHello\nWorld!" """ # None means the last visited item was a block end. accum = None for item in result: if item == self.BLOCK_END: # Multiple consecutive block ends; do nothing. if accum is None: continue # First block end; yield the current string, plus a newline. yield accum.strip() + "\n" accum = None # Multiple consecutive strings; combine them. elif accum is not None: accum += item # First consecutive string; store it. else: accum = item # Yield the last string if we have not already done so. if accum is not None: yield accum.strip() def handle_data(self, data, *args, **kwargs): """Store our data if it is not on the ignore list.""" # Ignore data while an ignored container is active. if self._do_store: # Collapse whitespace before buffering visible text. content = self.WS_TRIM.sub(" ", data) # Preserve the normalized fragment for final assembly. self._result.append(content) def handle_starttag(self, tag, attrs): """Process our starting HTML Tag.""" # Toggle storage according to the newly opened container. self._do_store = tag not in self.IGNORE_TAGS # Start block elements on a fresh output line. if tag in self.BLOCK_TAGS: self._result.append(self.BLOCK_END) # Prefix each list item with a plain-text bullet. if tag == "li": self._result.append("- ") # Preserve explicit HTML line breaks. elif tag == "br": self._result.append("\n") # Render horizontal rules on their own line. elif tag == "hr": # Remove spacing that would precede the rule. if self._result and isinstance(self._result[-1], str): self._result[-1] = self._result[-1].rstrip(" ") self._result.append("\n---\n") # Mark the start of quoted plain text. elif tag == "blockquote": self._result.append(" >") def handle_endtag(self, tag): """Edge case handling of open/close tags.""" # Resume storage after leaving an ignored container. self._do_store = True # Close block elements with a line boundary. if tag in self.BLOCK_TAGS: self._result.append(self.BLOCK_END) def build_backtick_run_index(text): """Index unescaped backtick positions by run length in one pass.""" # Maps run-length -> [start, ...] in ascending position order. index = {} # Track the current scanner position and input length. i = 0 n = len(text) # Visit each character at most once. while i < n: ch = text[i] # Consume escape pairs so a backslash-escaped backtick is not # mistaken for the start or end of a code span. if ch == "\\" and i + 1 < n: i += 2 continue if ch == "`": # Measure the run by advancing until the first non-backtick. j = i while j < n and text[j] == "`": j += 1 # Store this run's starting position under its length key. # setdefault ensures the list exists before appending. index.setdefault(j - i, []).append(i) # Jump past the entire run to avoid double-counting. i = j continue # Advance past ordinary text. i += 1 # Return positions grouped by delimiter width. return index def find_unescaped_run(index, start, run): """Find the next indexed backtick run of the requested length.""" # Nothing to search if no run of this exact length was indexed. positions = index.get(run) if not positions: return None # Binary-search for the first position that is >= start. pos = bisect_left(positions, start) # Return the found position, or None if we went past the end of the list. return positions[pos] if pos < len(positions) else None def commonmark_escape_link_url(url): """Adapt a CommonMark URL for ```` dialects.""" # Step 1: strip CommonMark backslash escapes so we recover the raw URL # characters before re-applying any encoding the target dialect needs. out = [] # Scan the URL without allocating intermediate match objects. i = 0 n = len(url) while i < n: ch = url[i] if ch == "\\" and i + 1 < n: # Discard the backslash and keep only the escaped character. out.append(url[i + 1]) i += 2 continue out.append(ch) i += 1 # Reassemble the decoded CommonMark destination. url = "".join(out) # Step 2: re-encode the characters that the delimiter syntax # would mis-parse if left bare in the URL string. # "&" must come first to avoid double-encoding the entities below. url = url.replace("&", "&").replace("<", "<") url = url.replace(">", ">") # "|" is the separator between the URL and label inside ; # percent-encode it so it cannot be mistaken for the delimiter. return url.replace("|", "%7C") def commonmark_scan_angle_dest(body, i, n): """Find the closing ``>`` of a ``]()`` destination. ``i`` points at ``](<`` and ``n`` bounds the scan. Escaped ``>)`` pairs are skipped; the closing index or ``None`` is returned. """ # Start immediately after the opening ``](<`` sequence. k = i + 3 # Scan until a complete two-character terminator can no longer fit. while k < n - 1: if body[k] == "\\" and k + 1 < n: # Skip escape sequences -- they cannot be the terminator. k += 2 continue if body[k] == ">" and body[k + 1] == ")": return k k += 1 return None def commonmark_emphasis_run(body, i, n, stack, out): """Map one CommonMark asterisk run to target emphasis delimiters. ``stack`` tracks nested bold/italic spans while ``out`` receives their delimiters. The returned index points immediately after the run. """ # Measure the full asterisk run starting at i. j = i while j < n and body[j] == "*": j += 1 run = j - i # Consume the run one span at a time. Each iteration either closes # the top-of-stack span (if the remaining run width satisfies it) or # opens a new span. while run > 0: if stack and ( (stack[-1][0] == "*" and run >= 2) or (stack[-1][0] == "_" and run >= 1) ): # Close the innermost open span. delim, open_index = stack.pop() if open_index == len(out) - 1: # Span opened but collected no content -- drop the orphan. out.pop() else: # Emit the matching close delimiter. out.append(delim) # Bold ("*") consumes 2 asterisks; italic ("_") consumes 1. run -= 2 if delim == "*" else 1 elif run >= 2: # No closeable bold on stack; open a new bold span. out.append("*") stack.append(("*", len(out) - 1)) run -= 2 else: # run == 1: open a new italic span. out.append("_") stack.append(("_", len(out) - 1)) run -= 1 # Return the position after the full asterisk run. return j def commonmark_force_close_spans(out, stack): """Close remaining LIFO emphasis spans and discard empty ones. Both ``out`` and ``stack`` are updated in place. """ # Close innermost spans first to preserve valid nesting. while stack: delim, open_index = stack.pop() if open_index == len(out) - 1: # Span opened but collected no content -- remove the orphan. out.pop() else: # Emit the close delimiter. out.append(delim) def commonmark_prepend_title(body, title): """Prepend a clean CommonMark H1 title above the body. Returns ``(body, "")`` so callers can clear their separate title field. """ # Remove whitespace and structural prefixes before adding our heading. title_text = title.lstrip("\r\n \t\v\f#-") # Add the heading only when meaningful title text remains. if title_text: body = f"# {title_text}\n{body}" if body else f"# {title_text}" return body, "" class HTMLMarkdownConverter(HTMLConverter): """Convert HTML to notification-friendly CommonMark. Plugins may adapt this output to their own Markdown dialects. """ # Define tags that create Markdown block boundaries. BLOCK_TAGS = frozenset( { "p", "h1", "h2", "h3", "h4", "h5", "h6", "div", "td", "th", "pre", "samp", "label", "li", } ) # Paragraph-like blocks need blank-line separation in CommonMark. _PARA_LIKE_TAGS = frozenset({"p", "div", "td", "th", "label"}) # Reuse a plain paragraph boundary outside blockquotes. PARA_BREAK = _ParaBreak() # Escape characters that could become inline Markdown. MARKDOWN_ESCAPE = re.compile( r"([\\`*_~#\[\]()!<>])", re.DOTALL | re.MULTILINE ) # Block syntax only needs escaping at the start of a line. _ORDERED_MARKER_RE = re.compile(r"^\d+\.(?=[ \t]|$)") # Match bullet markers at the start of a line. _BULLET_MARKER_RE = re.compile(r"^[-+](?=[ \t]|$)") # Match thematic breaks and setext heading underlines. # Uses separate alternates (no backreference) to avoid ReDoS on # inputs like "- - - - - - - - ... -x". _THEMATIC_BREAK_RE = re.compile(r"^(?:-[ \t]*){3,}$|^(?:=[ \t]*){3,}$") # Collapse ASCII whitespace when flattening table cells. _CELL_WHITESPACE_RUN = re.compile(r"[ \t\r\f\v]+") # Escape angle-bracket link destinations. HREF_ESCAPE = re.compile(r"([\\<>])") # Remove ASCII controls, Unicode format/zero-width characters, and # BiDi override/embedding controls that browsers silently discard during # URL parsing. These characters can be prepended to a dangerous scheme # (e.g. U+200B before "javascript:") or used to visually reverse a URL # (e.g. U+202E before "javascript:") so they appear safe on screen while # containing an exploitable scheme once the override is decoded. _HREF_CONTROL_CHARS = re.compile( "[\x00-\x1f\x7f\u00ad\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]" ) # Detect URI schemes before sanitizing links. _HREF_SCHEME = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):") # Block executable and local URI schemes while allowing app links. # "blob" is included because blob: URLs can carry arbitrary payloads. _UNSAFE_HREF_SCHEMES = frozenset( {"javascript", "vbscript", "data", "file", "blob"} ) # Trim ASCII whitespace without removing deliberate non-breaking spaces. _ASCII_WHITESPACE = " \t\n\r\x0b\x0c" def __init__(self, **kwargs): """Initialize the HTML-to-Markdown converter.""" # Initialize the base converter super().__init__(**kwargs) # Cache indentation by list depth. self._indent_cache = [""] # Cache prefixes by blockquote depth. self._quote_prefix_cache = [""] # Track open tags for constant-time unmatched-close checks. self._tag_open_counts = {} # Track whether the current frame emitted real content. self._content_seq = 0 # Context stack. The sentinel root frame at index 0 is never # popped and represents top-level content outside every tag. # Each frame is a dict with these keys: # tag -- str|None tag that opened this frame # do_store -- bool store text nodes encountered here? # preserve_cr -- bool keep whitespace literally (pre/code)? # list_type -- str|None 'ul', 'ol', or None # depth -- int list nesting depth (1 = outermost), # clamped to LIST_DEPTH_MAX # counter -- int|None next item number for 'ol' lists # list_do_store -- bool do_store of the list's own parent # (the nearest non-ul/ol/li ancestor), # saved here when the ul/ol is pushed # so

  • can read it directly instead # of walking back up the stack # quote_depth -- int blockquote nesting depth (1 = outermost), # clamped to BLOCKQUOTE_DEPTH_MAX; 0 # means "not inside a blockquote" # list_indent -- str spaces a continuation line needs to # stay part of the current
  • -- # "" outside any list. Combined with # quote_depth's "> " (in whichever # order they were actually nested) # by _write_boundary() for any line # that needs to restate it. # list_indent_base -- str list_indent from *outside* the # entire list-nesting chain (e.g. # from an enclosing
    ) -- # captured once where that chain # starts (depth 0 -> 1) and inherited # unchanged by every level nested # inside it after that.
  • adds # only its own marker's width on top # of *this*, not the immediately # enclosing list's full list_indent, # since that would double-count: a # nested marker's own leading spaces # (from _indent(depth)) already cover # every outer list level's width. # href -- str|None href of an enclosing tag; set # only on frames pushed by # buffer -- list[str] raw text accumulated inside a # code/pre/samp frame; only present # on frames pushed by those tags -- # the matching close tag needs the # full text up front to size a # delimiter that cannot collide with # backticks already in the content self._stack = [ { "tag": None, "do_store": True, "preserve_cr": False, "list_type": None, "depth": 0, "counter": None, "list_do_store": True, "quote_depth": 0, "list_indent": "", "list_indent_base": "", } ] def _make_frame(self, tag, **overrides): """Create a frame from the current context.""" # Inherit frame defaults from the parent context. parent = ( self._stack[-1] if self._stack else { "tag": None, "do_store": True, "preserve_cr": False, "list_type": None, "depth": 0, "counter": None, "list_do_store": True, "quote_depth": 0, "list_indent": "", "list_indent_base": "", } ) # Build the new frame by inheriting all context keys from the parent. # Callers pass **overrides to replace only the keys they need changed. frame = { "tag": tag, "do_store": parent["do_store"], "preserve_cr": parent["preserve_cr"], "list_type": parent["list_type"], "depth": parent["depth"], "counter": parent["counter"], "list_do_store": parent["list_do_store"], "quote_depth": parent["quote_depth"], "list_indent": parent["list_indent"], "list_indent_base": parent["list_indent_base"], } # Apply caller-supplied overrides on top of the inherited defaults. frame.update(overrides) return frame def _indent(self, depth): """Return cached indentation for a list depth.""" # Depth 1 is the outermost level and needs no indentation, # so shift by one: depth 1 -> index 0 (empty string). idx = max(depth - 1, 0) # Work against the shared per-instance indentation cache. cache = self._indent_cache # Extend the cache on demand -- each level adds two spaces. while len(cache) <= idx: cache.append(cache[-1] + " ") return cache[idx] def _quote_prefix(self, depth): """Return the prefix for a quote depth.""" # Clamp negative depths to zero (should not occur in practice). idx = max(depth, 0) # Work against the shared per-instance quote-prefix cache. cache = self._quote_prefix_cache # Extend the cache on demand -- each nesting level prepends "> ". while len(cache) <= idx: cache.append(_QuoteMarker(cache[-1] + "> ")) return cache[idx] def _continuation_prefix(self, ctx): """Return the prefix that any continuation line must carry.""" # Start with whatever list indentation this context inherited. prefix = ctx["list_indent"] # If we are inside a blockquote, append its ">" leader as well. if ctx["quote_depth"]: prefix += self._quote_prefix(ctx["quote_depth"]) return prefix def _write_boundary(self, ctx, strong=False): """Append a line break or a nesting-aware paragraph break.""" # Compute the prefix that any following line must carry. prefix = self._continuation_prefix(ctx) if not strong: # Weak boundary -- a single line break in the output. self._result.append(self.BLOCK_END) # Restate any active quote or list prefix so the next # content line opens inside the correct nesting context. if prefix: self._result.append( _QuoteMarker(prefix) if ctx["quote_depth"] else _ListIndent(prefix) ) # The ordinary boundary is complete; nothing more to do. return # Strong boundary -- blank-line paragraph separation. if prefix: # Carry nesting context into the paragraph break so _finalize() # can restate it on the first line of the next paragraph. self._result.append( _ParaBreak(prefix, in_quote=bool(ctx["quote_depth"])) ) else: # No active nesting -- use the cached bare paragraph break. self._result.append(self.PARA_BREAK) def _open_line_boundary(self, ctx, strong=False): """Start a block unless a structural marker owns the line.""" if self._result and isinstance(self._result[-1], _Marker): # A structural marker is already on the line -- suppress the # boundary so the block content follows it directly. return self._write_boundary(ctx, strong=strong) def _emit(self, text): """Append content and advance the empty-span sequence counter.""" self._result.append(text) # Each emission is a unique sequence point; comparing the counter # value at open-time vs. close-time reveals whether the frame was # empty without scanning the result buffer. self._content_seq += 1 def _push_frame(self, frame): """Push a frame, returning whether the depth limit accepted it. ``True`` means the frame and tag counter were stored. ``False`` means the depth cap rejected it. Delimiter-producing callers must emit only after a successful push or their Markdown would remain unmatched. """ # Reject excessive nesting without emitting unmatched delimiters. if len(self._stack) >= MAX_FRAME_DEPTH: return False # Add the frame to the context stack. self._stack.append(frame) # Maintain a per-tag count of currently open frames so that # _pop_to() can reject unmatched closing tags in O(1). tag = frame["tag"] self._tag_open_counts[tag] = self._tag_open_counts.get(tag, 0) + 1 return True def _pop_to(self, tag): """Pop through the nearest match and report whether one existed.""" # Reject unmatched closing tags without scanning the stack if not self._tag_open_counts.get(tag): return False # Search backward for the nearest matching frame for i in range(len(self._stack) - 1, 0, -1): if self._stack[i]["tag"] == tag: # Keep tag counts synchronized with removed frames. for frame in self._stack[i:]: self._tag_open_counts[frame["tag"]] -= 1 del self._stack[i:] return True return False def _close_emphasis_frame(self, frame): """Close an emphasis frame, dropping it if it collected no content.""" # Compare the global emission counter now vs. when the frame opened; # any difference means at least one _emit() was called inside the span. has_content = self._content_seq != frame.get( "content_seq_at_open", self._content_seq ) if not has_content: # The span opened but collected nothing -- erase from the opening # delimiter onward so we do not emit an empty ** or * pair. del self._result[frame["emphasis_result_start"] :] return # CommonMark requires that the closing delimiter not be preceded by # whitespace. Relocate any trailing whitespace to after the delimiter. trailing = "" while self._result and type(self._result[-1]) is str: last = self._result[-1] rstripped = last.rstrip(self._ASCII_WHITESPACE) # This fragment has no trailing whitespace -- stop scanning. if rstripped == last: break # Accumulate trailing whitespace in front-to-back order. trailing = last[len(rstripped) :] + trailing if rstripped: # Replace the fragment with its trimmed version and stop. self._result[-1] = rstripped break # The entire fragment was whitespace -- remove it and keep going. self._result.pop() # Emit the closing delimiter (e.g. "**" or "*"). self._emit(frame["emphasis_delim"]) # Re-append any whitespace that was after the span content so it # falls outside the closed delimiter. if trailing: self._result.append(trailing) @staticmethod def _escape_cell_pipes(text): """Escape cell delimiters outside CommonMark code spans.""" # Accumulate escaped fragments without rebuilding the output string. out = [] # Initialize the single-pass scanner. i = 0 n = len(text) # Index code delimiters before inspecting table separators. backtick_runs = build_backtick_run_index(text) while i < n: ch = text[i] # Keep escape pairs intact so a backslash-escaped "|" does not # trigger an extra escape on the next pass. if ch == "\\" and i + 1 < n: out.append(text[i : i + 2]) i += 2 continue if ch == "`": j = i # Measure the backtick run length. while j < n and text[j] == "`": j += 1 run = j - i # Find the matching closing run of the same length. close = find_unescaped_run(backtick_runs, j, run) if close is not None: # Inside a code span, "|" is a literal character -- # copy the entire span verbatim without escaping. out.append(text[i : close + run]) i = close + run continue # No matching close -- not a code span; preserve the # raw backticks and let the outer Markdown renderer see them. out.append(text[i:j]) i = j continue if ch == "|": # Outside code spans, "|" is a Markdown table delimiter -- # escape it so it does not break the table structure. out.append("\\|") i += 1 continue # All other characters are safe to pass through unchanged. out.append(ch) i += 1 # Publish the escaped cell content. return "".join(out) def _close_cell_frame(self, frame): """Capture a completed table cell and store its text.""" # Splice out all result tokens written since this cell was opened. start = frame["cell_result_start"] chunk = self._result[start:] del self._result[start:] if not frame["do_store"]: # This cell belongs to a suppressed or nested table -- discard it # so we do not emit empty cells or corrupt the outer table. return # Finalize the buffered tokens into a plain string and strip edges. text = "".join(self._finalize(chunk)).strip(self._ASCII_WHITESPACE) # CommonMark table cells must live on a single source line. # Collapse embedded newlines to spaces and then normalize runs. if "\n" in text: text = " ".join(text.split("\n")) text = self._CELL_WHITESPACE_RUN.sub(" ", text).strip( self._ASCII_WHITESPACE ) # Escape cell delimiters except where code spans make them literal. if "|" in text: text = self._escape_cell_pipes(text) # Add the completed value to its enclosing row. self._stack[-1]["cells"].append(text) def _close_pre_frame(self, ctx, tag): """Render a completed code/pre/samp frame's buffered content.""" # Join the buffered literal content content = "".join(ctx.get("buffer", ())) # Size the delimiter beyond any embedded backtick run run = self._longest_backtick_run(content) if tag == "code": # Inline code uses a delimiter wider than its content. # CommonMark requires padding around edge backticks. delim = "`" * (run + 1) pad = ( " " if not content or content[0] == "`" or content[-1] == "`" else "" ) self._emit(delim + pad + content + pad + delim) return # Fenced block -- widen the fence past the longest run of # backticks already present in the content fence = "`" * max(3, run + 1) # Restate list and quote prefixes on every fenced-block line. prefix = self._continuation_prefix(ctx) # Prefix every fenced line inside a list or quote if prefix: marker = ( _QuoteMarker(prefix) if ctx["quote_depth"] else _ListIndent(prefix) ) content = ("\n" + prefix).join(content.split("\n")) # Emit the opening fence and terminate its line. self._emit(fence) self._result.append(self.BLOCK_END) # Restate nesting before the code content if prefix: self._result.append(marker) # Emit the literal code body and terminate its final line. self._emit(content) self._result.append(self.BLOCK_END) # Restate nesting before the closing fence if prefix: self._result.append(marker) # Emit the closing fence and restore a block boundary. self._emit(fence) self._result.append(self.BLOCK_END) def _close_row_frame(self, frame): """Render a completed table row as a Markdown pipe-table line.""" # Retrieve the cells collected while the row was open. cells = frame["cells"] if not cells: # An entirely empty row (no cells at all) is not useful -- drop it. return # Retrieve the enclosing table frame, if one exists. table = frame.get("table_frame") # The first row of a table is treated as the header row; CommonMark # requires a separator line of dashes ("---") immediately after it. is_header = table is None or table["table_rows"] == 0 if table is not None: # Count this row so that subsequent rows know they are not headers. table["table_rows"] += 1 # Use the restored parent context for row boundaries and prefixes. ctx = self._stack[-1] # Ensure the row starts on its own line within any active nesting. self._open_line_boundary(ctx, strong=False) # Emit the pipe-delimited cell values. self._emit("| " + " | ".join(cells) + " |") if is_header: # Emit the mandatory separator line beneath the header row. # Restate list or quote prefixes so it stays inside any nesting. self._write_boundary(ctx, strong=False) self._emit("| " + " | ".join(["---"] * len(cells)) + " |") if table is None: # A standalone row has no enclosing table to supply the trailing # paragraph boundary, so we must emit it here. self._write_boundary(ctx, strong=True) def _close_table_frame(self, table_frame=None): """Emit the paragraph boundary that follows a completed table.""" parent = self._stack[-1] # Empty visible tables already received their only needed boundary. # Suppressed nested tables still need spacing inside their cell. if ( table_frame is not None and not table_frame.get("table_rows") and table_frame.get("table_do_store") ): return if parent["do_store"]: # Separate the table from any following content with a blank line. self._write_boundary(parent, strong=True) def _close_stale_cell(self): """Close a stale cell hidden beneath malformed inline frames.""" # Walk backward until a cell or its structural boundary is found. for i in range(len(self._stack) - 1, 0, -1): frame = self._stack[i] tag = frame["tag"] # A row or table boundary means no stale cell exists here. if tag in ("tr", "table"): return if tag in ("td", "th"): # Silently drop any interleaved inline frames above the cell. while len(self._stack) > i + 1: # Remove the nearest orphaned inline frame. orphan = self._stack[-1] # Keep its open-tag counter synchronized. self._tag_open_counts[orphan["tag"]] = max( 0, self._tag_open_counts.get(orphan["tag"], 0) - 1, ) self._stack.pop() # Finalize the cell before its sibling begins. self._pop_to(tag) self._close_cell_frame(frame) return def _close_stale_row(self): """Finalize an unterminated row before its table advances.""" # A stale row always has a stale cell inside it -- close that first. self._close_stale_cell() # Inspect the frame exposed after closing any stale cell. top = self._stack[-1] # If the top of the stack is now a row frame, close it too. if top["tag"] == "tr": self._pop_to("tr") self._close_row_frame(top) def close(self): """Recover any frames left open at end-of-document, then finalize.""" # HTML does not require all tags to be closed, so we walk the stack # top-down and synthesize the missing close events for each open frame. # Only frames that emit Markdown structure need explicit finalization; # list and blockquote frames just restore context silently via _pop_to. while len(self._stack) > 1: top = self._stack[-1] tag = top["tag"] # Remove the frame from the stack (and update open-tag counts). self._pop_to(tag) if top.get("emphasis_delim"): # Emphasis frame -- emit or drop the closing delimiter. self._close_emphasis_frame(top) elif tag in ("td", "th"): # Cell frame -- finalize and store its content. self._close_cell_frame(top) elif tag == "tr": # Row frame -- emit the pipe-table line. self._close_row_frame(top) elif tag == "table": # Table frame -- emit the trailing paragraph boundary. self._close_table_frame(top) elif tag in ("code", "pre", "samp") and top["do_store"]: # Preformatted frame -- emit the fenced code block. self._close_pre_frame(top, tag) # Hand off to the base converter to assemble the final output string. super().close() @staticmethod def _longest_backtick_run(text): """Return the longest consecutive backtick run.""" # Track both the active run and the maximum observed width. longest = current = 0 # Scan the content one character at a time for ch in text: if ch == "`": current += 1 longest = max(longest, current) # Reset the active run on non-backtick content else: current = 0 # Use the maximum to size a collision-free delimiter. return longest def _finalize(self, result): """Combine tokens into normalized Markdown lines.""" # Track whether a line is waiting after a boundary. accum = None # Whether accum, so far, is composed only of marker fragments accum_is_marker = False # True when a marker absorbed a boundary without content. marker_boundary_passed = False # True when a complete line is waiting to be flushed. terminated = False # Set when a plain (no prefix at all) _ParaBreak has been seen since # accum was last extended with real content para_break_seen = False # Active list or quote prefix for the next paragraph line. para_break_prefix = None # Whether the active paragraph prefix belongs to a quote. para_break_in_quote = False # Set when a boundary arrives *after* the line was already terminated # -- i.e. anything beyond the single boundary that did the terminating. boundary_after_terminate = False for item in result: # Boundaries update state but do not immediately emit text. # Collect boundary state before flushing the current line if item == self.BLOCK_END or isinstance(item, _ParaBreak): if isinstance(item, _ParaBreak): # The latest break carries the current nesting context. if item.prefix: para_break_prefix = item.prefix para_break_in_quote = item.in_quote else: para_break_seen = True para_break_prefix = None para_break_in_quote = False # Absorb boundaries until real content arrives. if accum is None: continue # Keep an unused structural marker pending if accum_is_marker: marker_boundary_passed = True continue # Remember boundaries beyond the one ending this line if terminated: boundary_after_terminate = True terminated = True continue # Flush the previous line before adding new content. if terminated: if para_break_prefix is not None: # Keep blank lines inside an active quote if para_break_in_quote: blank = para_break_prefix.rstrip( self._ASCII_WHITESPACE ) sep = "\n" + blank + "\n" # Lists use an ordinary blank paragraph line else: sep = "\n\n" elif para_break_seen: sep = "\n\n" # Fall back to a single line break else: sep = "\n" # Emit the completed line with its chosen separator. yield accum.rstrip(self._ASCII_WHITESPACE) + sep # Seed the next line with any active list or quote prefix. if para_break_prefix is not None: accum = ( _QuoteMarker(para_break_prefix) if para_break_in_quote else _ListIndent(para_break_prefix) ) accum_is_marker = True marker_boundary_passed = boundary_after_terminate else: accum = None accum_is_marker = False marker_boundary_passed = False # Reset state gathered for the completed line terminated = False para_break_seen = False para_break_prefix = None para_break_in_quote = False boundary_after_terminate = False if accum is not None: # Determine whether this token is generated structure. item_is_marker = isinstance(item, _Marker) if ( marker_boundary_passed and accum_is_marker and item_is_marker ): # A new marker after the old one sat through a boundary # unused -- start over with this one accum = item accum_is_marker = True else: # Append ordinary content to the pending line. accum += item accum_is_marker = accum_is_marker and item_is_marker marker_boundary_passed = False else: # Seed a new line with this token accum = item accum_is_marker = isinstance(item, _Marker) para_break_seen = False para_break_prefix = None para_break_in_quote = False # Flush the final content line without adding another newline. if accum is not None and not accum_is_marker: yield accum.rstrip(self._ASCII_WHITESPACE) def _escape_line_start(self, content): """Escape text that could become block syntax.""" # Look for an ordered-list marker ordered = self._ORDERED_MARKER_RE.match(content) if ordered: # Escape just the '.', not the digits themselves dot = ordered.end() - 1 return content[:dot] + "\\" + content[dot:] # Escape an unordered-list marker if self._BULLET_MARKER_RE.match(content): return "\\" + content # Ignore surrounding whitespace while checking the full line trimmed = content.strip(self._ASCII_WHITESPACE) # Escape thematic breaks and setext underlines if trimmed and self._THEMATIC_BREAK_RE.match(trimmed): idx = content.index(trimmed[0]) return content[:idx] + "\\" + content[idx:] # Leave ordinary line starts unchanged. return content def handle_data(self, data, *args, **kwargs): """Store escaped text for the current context.""" # Read all text-handling flags from the active frame. ctx = self._stack[-1] # Ignore text inside suppressed containers. if not ctx["do_store"]: return # Preserve literal whitespace inside code blocks if ctx["preserve_cr"]: # Buffer raw text so the closing delimiter can be sized safely. ctx["buffer"].append(data) return # Preserve deliberate non-breaking spaces even at block boundaries. if not data.strip() and "\xa0" in data: self._emit("\xa0") return # Collapse ordinary whitespace outside preformatted content. content = self.WS_TRIM.sub(" ", data) # Drop indentation-only text around block boundaries. if not content.strip() and ( not self._result or self._result[-1] == self.BLOCK_END or isinstance(self._result[-1], (_Marker, _ParaBreak)) ): return # Detect text that begins a fresh Markdown source line. is_line_start = ( not self._result or self._result[-1] == self.BLOCK_END or isinstance(self._result[-1], (_Marker, _ParaBreak)) or ( type(self._result[-1]) is str and self._result[-1].endswith("\n") ) ) # Escape Markdown punctuation in ordinary text. content = self.MARKDOWN_ESCAPE.sub(r"\\\1", content) if is_line_start: # Prevent plain text from becoming a Markdown block content = self._escape_line_start(content) # Keep leading whitespace outside an emphasis delimiter. if ctx.get("emphasis_delim") and not ctx["emphasis_started"]: stripped = content.lstrip(self._ASCII_WHITESPACE) # Move leading whitespace before the opening delimiter if stripped != content: leading = content[: len(content) - len(stripped)] delim = self._result.pop() self._result.append(leading) self._result.append(delim) content = stripped if not content: # The close handler removes an empty span. return # Mark the emphasis span as started ctx["emphasis_started"] = True # Append the final escaped fragment to the output stream. self._emit(content) def handle_starttag(self, tag, attrs): """Handle an opening HTML tag.""" # Capture the parent context before opening another frame. ctx = self._stack[-1] # Treat nested tags inside preformatted content as literal text. if ctx["preserve_cr"]: return # Track list type, depth, and numbering. if tag in ("ul", "ol"): # CommonMark ordered lists default to one. counter = 1 if tag == "ol": # Read an optional starting number from ordered lists. start_attr = next((v for k, v in attrs if k == "start"), None) if start_attr is not None: with contextlib.suppress(TypeError, ValueError): # Markdown list markers cannot represent negative # values. counter = max(0, int(start_attr)) # Prepare the list-specific state inherited by its items. overrides = { "do_store": False, "list_type": tag, "depth": min(ctx["depth"] + 1, LIST_DEPTH_MAX), "counter": (counter if tag == "ol" else None), } # Only refresh list_do_store when this is the outermost list (its # parent isn't itself a ul/ol). if ctx["tag"] not in ("ul", "ol"): overrides["list_do_store"] = ctx["do_store"] # Capture indentation once at the start of a nested list chain. if ctx["depth"] == 0: overrides["list_indent_base"] = ctx["list_indent"] # A direct child list continues its parent item without a break. if ctx["do_store"] and ctx["tag"] != "li": self._open_line_boundary(ctx, strong=True) # Open the list frame and suppress whitespace-only text nodes. self._push_frame(self._make_frame(tag, **overrides)) return # Emit a list marker and enable item content. if tag == "li": # An item value resets numbering for it and following siblings. if ctx["list_type"] == "ol" and ctx["counter"] is not None: # Read an optional per-item numbering override. value_attr = next((v for k, v in attrs if k == "value"), None) if value_attr is not None: with contextlib.suppress(TypeError, ValueError): # Match the non-negative
      constraint. ctx["counter"] = max(0, int(value_attr)) # Indent scales with nesting depth (depth 1 = no indent), capped at # LIST_DEPTH_MAX indent = self._indent(ctx["depth"]) if ctx["list_type"] == "ol" and ctx["counter"] is not None: # Render the current ordered-list number. marker_text = "{}{}. ".format(indent, ctx["counter"]) else: # Render a bullet for unordered or malformed list items. marker_text = "{}- ".format(indent) # Tag the marker as converter-generated structure marker = _ListMarker(marker_text) # Re-enable storage, unless something outside the list itself (e.g. # ) is suppressing content do_store = ctx["list_do_store"] # Snapshot content state for constant-time empty-item detection. # Gate marker emission on the return value: at MAX_FRAME_DEPTH # the frame is discarded and we must not append the marker, # otherwise there is nothing to pop it on . if not self._push_frame( self._make_frame( tag, do_store=do_store, content_seq_at_open=self._content_seq, list_indent=( ctx["list_indent_base"] + " " * len(marker_text) ), ) ): return if do_store: # Reuse a pending quote or list marker on this line. if not ( self._result and isinstance(self._result[-1], _Marker) ): self._result.append(self.BLOCK_END) # Restate an enclosing quote prefix if ctx["quote_depth"]: self._result.append( self._quote_prefix(ctx["quote_depth"]) ) self._result.append(marker) return # Extend a pending quote/list marker, or begin a fresh quoted line. if tag == "blockquote": # Clamp quote depth to keep generated prefixes bounded depth = min(ctx["quote_depth"] + 1, BLOCKQUOTE_DEPTH_MAX) # Open a frame carrying the increased quote depth. # Gate marker emission on the return value: at MAX_FRAME_DEPTH # the frame is discarded and we must not append a _QuoteMarker, # otherwise there is nothing to pop it on
  • . new_frame = self._make_frame(tag, quote_depth=depth) if not self._push_frame(new_frame): return if ctx["do_store"]: last = self._result[-1] if self._result else None # Extend an existing quote prefix if isinstance(last, _QuoteMarker) or ( isinstance(last, _ParaBreak) and last.in_quote ): if depth > ctx["quote_depth"]: self._result.append(_QuoteMarker("> ")) elif isinstance(last, _Marker): self._result.append(self._quote_prefix(depth)) # Separate a quote from preceding text. elif isinstance(last, str): self._write_boundary(ctx, strong=True) if depth > ctx["quote_depth"]: self._result.append(_QuoteMarker("> ")) # Emit the prefix directly at the start of a context. else: self._result.append( _QuoteMarker( ctx["list_indent"] + self._quote_prefix(depth) ) ) return # Table section tags are transparent. if tag == "table": # Markdown cannot represent a table nested inside a cell. nested_in_cell = bool( self._tag_open_counts.get("td") or self._tag_open_counts.get("th") ) # Separate a representable table from preceding content. if ctx["do_store"] and not nested_in_cell: self._open_line_boundary(ctx, strong=True) self._push_frame( self._make_frame( tag, do_store=False, # Nested table cells remain suppressed. table_do_store=( False if nested_in_cell else ctx["do_store"] ), table_rows=0, ) ) return if tag == "tr": # Finalize a malformed, unterminated previous row first. self._close_stale_row() # Capture the table or container that owns the new row. row_ctx = self._stack[-1] # Remember whether this row belongs to a real table frame. is_table = row_ctx["tag"] == "table" self._push_frame( self._make_frame( tag, do_store=False, cells=[], # A stray row becomes a standalone one-row table. table_frame=(row_ctx if is_table else None), table_do_store=( row_ctx["table_do_store"] if is_table else row_ctx["do_store"] ), ) ) return if tag in ("td", "th"): # Finalize an unterminated previous cell first. self._close_stale_cell() # Capture the row expected to own the new cell. cell_ctx = self._stack[-1] if cell_ctx["tag"] == "tr": # Record where this cell's buffered output begins. self._push_frame( self._make_frame( tag, do_store=cell_ctx["table_do_store"], cell_result_start=len(self._result), ) ) return # No enclosing -- this isn't really a table cell (e.g. a stray # with no surrounding table structure at all). # Buffer preformatted content until its closing tag. if tag in ("code", "pre", "samp"): # Preserve raw whitespace until the matching closing tag. self._push_frame( self._make_frame(tag, preserve_cr=True, buffer=[]) ) # Resume text capture inside the document body. elif tag == "body": # The document body always resumes visible text capture. self._push_frame(self._make_frame(tag, do_store=True)) # Suppress content inside ignored containers. elif tag in self.IGNORE_TAGS: # Ignored containers inherit context but disable text storage. self._push_frame(self._make_frame(tag, do_store=False)) # Do not emit Markdown markers for suppressed content. if not ctx["do_store"]: return # Start block-level content on the correct boundary. if tag in self.BLOCK_TAGS: self._open_line_boundary(ctx, strong=tag in self._PARA_LIKE_TAGS) # Emit Markdown syntax for the current tag. if tag == "br": # CommonMark needs two trailing spaces for a visible hard break. self._emit(" \n" + self._continuation_prefix(ctx)) elif tag == "hr": # A void
    supplies its own boundaries; the leading blank line # prevents it from becoming a setext heading. self._open_line_boundary(ctx, strong=True) self._emit("---") self._result.append(self.BLOCK_END) elif tag == "h1": # Render a level-one ATX heading marker. self._emit("# ") elif tag == "h2": # Render a level-two ATX heading marker. self._emit("## ") elif tag == "h3": # Render a level-three ATX heading marker. self._emit("### ") elif tag == "h4": # Render a level-four ATX heading marker. self._emit("#### ") elif tag == "h5": # Render a level-five ATX heading marker. self._emit("##### ") elif tag == "h6": # Render a level-six ATX heading marker. self._emit("###### ") # Open an emphasis frame for bold or italic content elif tag in ("strong", "b", "em", "i"): # A frame validates closing tags and relocates edge whitespace. delim = "**" if tag in ("strong", "b") else "*" # Emit a delimiter only when its frame passed the depth limit. if self._push_frame( self._make_frame( tag, emphasis_delim=delim, emphasis_started=False, content_seq_at_open=self._content_seq, # Exact delimiter index used when removing an empty span. emphasis_result_start=len(self._result), ) ): # Buffer the opening delimiter for the matching close tag. self._result.append(delim) # Open a Markdown link when an href is available elif tag == "a": # Keep the target on a frame so nested label markup is included. href = next( (v for k, v in attrs if k == "href"), None, ) # Sanitize the destination before emitting Markdown if href is not None: # Remove controls that browsers ignore during URL parsing. href = self._HREF_CONTROL_CHARS.sub("", href) # Strip all leading/trailing whitespace, including Unicode # space characters that would otherwise bypass scheme checks. href = href.strip() # Extract an explicit URI scheme when one is present. scheme = self._HREF_SCHEME.match(href) # Neutralize schemes that can execute or expose local content if ( scheme and scheme.group(1).lower() in self._UNSAFE_HREF_SCHEMES ): # Preserve link shape while neutralizing unsafe targets. href = "#" if href is not None: # Angle destinations safely contain parentheses. safe_href = self.HREF_ESCAPE.sub(r"\\\1", href) target = "<{}>".format(safe_href) # Store the target only when its frame passes the depth limit. if self._push_frame(self._make_frame(tag, href=target)): # Frame accepted: begin the CommonMark link label. self._emit("[") def handle_endtag(self, tag): """Handle a closing HTML tag.""" # Capture context before any pop so do_store and any buffered code/pre # content can still be read for this closing tag ctx = self._stack[-1] if tag == "a": # Find the href stored in the nearest
    frame href = None # Search backward for the matching anchor for frame in reversed(self._stack): if frame["tag"] == "a": href = frame.get("href") break self._pop_to(tag) # Bare or suppressed anchors do not create link frames. if href is not None: # Complete the Markdown link self._emit("](" + href + ")") return # Track list type, depth, and numbering. if tag in ("ul", "ol"): # Ignore a list closer that has no corresponding open frame. if not self._pop_to(tag): return # Continue rendering in the restored parent context. parent = self._stack[-1] if parent["do_store"]: self._write_boundary(parent, strong=parent["tag"] != "li") return # Emit a list marker and enable item content. if tag == "li": # A stray closing tag with no open
  • anywhere on the stack is # a no-op -- nothing below should run against an unrelated frame. if not self._tag_open_counts.get(tag): return # Locate the actual li frame -- the top frame may be an unclosed # child (e.g. inside
  • textbold
  • ). li_frame = next( (f for f in reversed(self._stack) if f["tag"] == "li"), ctx, ) # Empty items are dropped and do not advance ordered-list # numbering. has_content = li_frame[ "do_store" ] and self._content_seq != li_frame.get( "content_seq_at_open", self._content_seq ) # Block boundary closes the item content -- only if the item # actually emitted a marker when it opened. if li_frame["do_store"]: self._result.append(self.BLOCK_END) # Pop the li frame (and any unclosed children above it). self._pop_to(tag) # The enclosing list is exposed after the item frame is removed. parent = self._stack[-1] # Increment numbering only for items that emitted content if parent["list_type"] == "ol" and has_content: parent["counter"] += 1 return # Track blockquote depth and emit its prefix. if tag == "blockquote": # Restore the quote's parent context -- a stray closing tag with # no open
    anywhere on the stack is a no-op. if not self._pop_to(tag): return # Continue rendering in the restored outer context. parent = self._stack[-1] if parent["do_store"]: self._write_boundary(parent, strong=True) return # Close only matching table frames; ignore stray closing tags. if tag in ("td", "th"): # Ignore stray cell closers instead of emitting a block boundary. if ctx["tag"] == tag: # Remove the frame before storing its value on the row. self._pop_to(tag) self._close_cell_frame(ctx) return elif tag == "tr": # Recover any cell left open by malformed HTML self._close_stale_cell() row_ctx = self._stack[-1] if row_ctx["tag"] == "tr": # Remove the row before rendering it in the parent context. self._pop_to(tag) self._close_row_frame(row_ctx) return elif tag == "table": # Recover any row left open by malformed HTML self._close_stale_row() table_ctx = self._stack[-1] if table_ctx["tag"] == "table": # Remove the table before emitting its trailing boundary. self._pop_to(tag) self._close_table_frame(table_ctx) return # Buffer preformatted content until its closing tag. if tag in ("code", "pre", "samp"): # Render only the matching preformatted frame if ctx["do_store"] and ctx["tag"] == tag: self._close_pre_frame(ctx, tag) # Restore the context that preceded the preformatted element. self._pop_to(tag) return # Do not emit Markdown markers for suppressed content. if not ctx["do_store"] or ctx["preserve_cr"]: if tag == "body" or tag in self.IGNORE_TAGS: self._pop_to(tag) return # Paragraph-like tags strongly separate following bare text. if tag in self.BLOCK_TAGS: self._write_boundary(ctx, strong=tag in self._PARA_LIKE_TAGS) if tag in ("strong", "b", "em", "i"): # Only a matching emphasis frame emits a closing delimiter. if ctx["tag"] == tag and ctx.get("emphasis_delim"): self._pop_to(tag) self._close_emphasis_frame(ctx) return # Pop frames created by container tags. if tag == "body" or tag in self.IGNORE_TAGS: # Restore storage flags inherited from the enclosing frame. self._pop_to(tag) caronc-apprise-182f859/apprise/decorators/000077500000000000000000000000001524161175200205245ustar00rootroot00000000000000caronc-apprise-182f859/apprise/decorators/__init__.py000066400000000000000000000026601524161175200226410ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .notify import notify __all__ = ["notify"] caronc-apprise-182f859/apprise/decorators/base.py000066400000000000000000000173331524161175200220170ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import inspect from .. import common from ..logger import logger from ..manager_plugins import NotificationManager from ..plugins.base import NotifyBase from ..utils.logic import dict_full_update from ..utils.parse import URL_DETAILS_RE, parse_url, url_assembly # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() class CustomNotifyPlugin(NotifyBase): """Apprise Custom Plugin Hook. This gets initialized based on @notify decorator definitions """ # Our Custom notification; identify the URL users can go to learn # more about the service this wrapper supports: service_url = "https://appriseit.com/library/extending/decorator/" # Over-ride our category since this inheritance of the NotifyBase class # should be treated differently. category = "custom" # Support Attachments attachment_support = True # Allow persistent storage support storage_mode = common.PersistentStoreMode.AUTO # Define object templates templates = ("{schema}://",) @staticmethod def parse_url(url): """Parses the URL and returns arguments retrieved.""" return parse_url(url, verify_host=False, simple=True) def url(self, privacy=False, *args, **kwargs): """General URL assembly.""" return f"{self.secure_protocol}://" @staticmethod def instantiate_plugin(url, send_func, name=None): """The function used to add a new notification plugin based on the schema parsed from the provided URL into our supported matrix structure.""" if not isinstance(url, str): msg = ( f"An invalid custom notify url/schema ({url}) provided in " f"function {send_func.__name__}." ) logger.warning(msg) return None # Validate that our schema is okay re_match = URL_DETAILS_RE.match(url) if not re_match: msg = ( f"An invalid custom notify url/schema ({url}) provided in " f"function {send_func.__name__}." ) logger.warning(msg) return None # Acquire our schema schema = re_match.group("schema").lower() if not re_match.group("base"): url = f"{schema}://" # Keep a default set of arguments to apply to all called references base_args = parse_url( url, default_schema=schema, verify_host=False, simple=True ) if schema in N_MGR: # we're already handling this object msg = ( f"The schema ({url}) is already defined and could not be " f"loaded from custom notify function {send_func.__name__}." ) logger.warning(msg) return None # We define our own custom wrapper class so that we can initialize # some key default configuration values allowing calls to our # `Apprise.details()` to correctly differentiate one custom plugin # that was loaded from another class CustomNotifyPluginWrapper(CustomNotifyPlugin): # Our Service Name service_name = ( name if isinstance(name, str) and name else f"Custom - {schema}" ) # Store our matched schema secure_protocol = schema requirements = { # Define our required packaging in order to work "details": f"Source: {inspect.getfile(send_func)}" } # Assign our send() function __send = staticmethod(send_func) # Update our default arguments _base_args = base_args def __init__(self, **kwargs): """Our initialization.""" # init parent super().__init__(**kwargs) self._default_args = {} # Some variables do not need to be set kwargs.pop("secure", None) # Apply our updates based on what was parsed dict_full_update(self._default_args, self._base_args) dict_full_update(self._default_args, kwargs) # Update our arguments (applying them to what we originally) # initialized as self._default_args["url"] = url_assembly(**self._default_args) def send( self, body, title="", notify_type=common.NotifyType.INFO, *args, **kwargs, ): """Our send() call which triggers our hook.""" response = False try: # Enforce a boolean response result = self.__send( body, title, notify_type.value, *args, meta=self._default_args, **kwargs, ) # None and True are both considered successful # False however is passed along further upstream response = True if result is None else bool(result) except Exception as e: # Unhandled Exception self.logger.warning( "An exception occured sending a %s notification.", N_MGR[self.secure_protocol].service_name, ) self.logger.debug( "%s Exception: %s", N_MGR[self.secure_protocol], e ) return False if response: self.logger.info( "Sent %s notification.", N_MGR[self.secure_protocol].service_name, ) else: self.logger.warning( "Failed to send %s notification.", N_MGR[self.secure_protocol].service_name, ) return response # Store our plugin into our core map file return N_MGR.add( plugin=CustomNotifyPluginWrapper, schemas=schema, send_func=send_func, url=url, ) caronc-apprise-182f859/apprise/decorators/notify.py000066400000000000000000000117121524161175200224100ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .base import CustomNotifyPlugin def notify(on, name=None): """ @notify decorator allows you to map functions you've defined to be loaded as a regular notify by Apprise. You must identify a protocol that users will trigger your call by. @notify(on="foobar") def your_declaration(body, title, notify_type, meta, *args, **kwargs): ... You can optionally provide the name to associate with the plugin which is what calling functions via the API will receive. @notify(on="foobar", name="My Foobar Process") def your_action(body, title, notify_type, meta, *args, **kwargs): ... The meta variable is actually the processed URL contents found in configuration files that landed you in this function you wrote in the first place. It's very easily tokenized already for you so that you can bend the notification logic to your hearts content. @notify(on="foobar", name="My Foobar Process") def your_action(body, title, notify_type, body_format, meta, attach, *args, **kwargs): ... Arguments break down as follows: body: The message body associated with the notification title: The message title associated with the notification notify_type: The message type (info, success, warning, and failure) body_format: The format of the incoming notification body. This is either text, html, or markdown. meta: Combines the URL arguments specified on the `on` call with the ones loaded from a users configuration. This is a dictionary that presents itself like this: { 'schema': 'http', 'url': 'http://hostname', 'host': 'hostname', 'user': 'john', 'password': 'doe', 'port': 80, 'path': '/', 'fullpath': '/test.php', 'query': 'test.php', 'qsd': {'key': 'value', 'key2': 'value2'}, 'asset': , 'tag': set(), } Meta entries are ONLY present if found. A simple URL such as foobar:// would only produce the following: { 'schema': 'foobar', 'url': 'foobar://', 'asset': , 'tag': set(), } attach: An array AppriseAttachment objects (if any were provided) body_format: Defaults to the expected format output; By default this will be TEXT unless over-ridden in the Apprise URL If you don't intend on using all of the parameters, your @notify() call # can be greatly simplified to just: @notify(on="foobar", name="My Foobar Process") def your_action(body, title, *args, **kwargs) Always end your wrappers declaration with *args and **kwargs to be future proof with newer versions of Apprise. Your wrapper should return True if processed the send() function as you expected and return False if not. If nothing is returned, then this is treated as as success (True). """ def wrapper(func): """Instantiate our custom (notification) plugin.""" # Generate CustomNotifyPlugin.instantiate_plugin( url=on, send_func=func, name=name ) return func return wrapper caronc-apprise-182f859/apprise/emojis.py000066400000000000000000002544461524161175200202360ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import time from .logger import logger # All Emoji's are wrapped in this character DELIM = ":" # the map simply contains the emoji that should be mapped to the regular # expression it should be swapped on. # This list was based on: https://github.com/ikatyang/emoji-cheat-sheet EMOJI_MAP = { # # Face Smiling # DELIM + r"grinning" + DELIM: "πŸ˜„", DELIM + r"smile" + DELIM: "πŸ˜„", DELIM + r"(laughing|satisfied)" + DELIM: "πŸ˜†", DELIM + r"rofl" + DELIM: "🀣", DELIM + r"slightly_smiling_face" + DELIM: "πŸ™‚", DELIM + r"wink" + DELIM: "πŸ˜‰", DELIM + r"innocent" + DELIM: "πŸ˜‡", DELIM + r"smiley" + DELIM: "πŸ˜ƒ", DELIM + r"grin" + DELIM: "πŸ˜ƒ", DELIM + r"sweat_smile" + DELIM: "πŸ˜…", DELIM + r"joy" + DELIM: "πŸ˜‚", DELIM + r"upside_down_face" + DELIM: "πŸ™ƒ", DELIM + r"blush" + DELIM: "😊", # # Face Affection # DELIM + r"smiling_face_with_three_hearts" + DELIM: "πŸ₯°", DELIM + r"star_struck" + DELIM: "🀩", DELIM + r"kissing" + DELIM: "πŸ˜—", DELIM + r"kissing_closed_eyes" + DELIM: "😚", DELIM + r"smiling_face_with_tear" + DELIM: "πŸ₯²", DELIM + r"heart_eyes" + DELIM: "😍", DELIM + r"kissing_heart" + DELIM: "😘", DELIM + r"relaxed" + DELIM: "☺️", DELIM + r"kissing_smiling_eyes" + DELIM: "πŸ˜™", # # Face Tongue # DELIM + r"yum" + DELIM: "πŸ˜‹", DELIM + r"stuck_out_tongue_winking_eye" + DELIM: "😜", DELIM + r"stuck_out_tongue_closed_eyes" + DELIM: "😝", DELIM + r"stuck_out_tongue" + DELIM: "πŸ˜›", DELIM + r"zany_face" + DELIM: "πŸ€ͺ", DELIM + r"money_mouth_face" + DELIM: "πŸ€‘", # # Face Hand # DELIM + r"hugs" + DELIM: "πŸ€—", DELIM + r"shushing_face" + DELIM: "🀫", DELIM + r"hand_over_mouth" + DELIM: "🀭", DELIM + r"thinking" + DELIM: "πŸ€”", # # Face Neutral Skeptical # DELIM + r"zipper_mouth_face" + DELIM: "🀐", DELIM + r"neutral_face" + DELIM: "😐", DELIM + r"no_mouth" + DELIM: "😢", DELIM + r"smirk" + DELIM: "😏", DELIM + r"roll_eyes" + DELIM: "πŸ™„", DELIM + r"face_exhaling" + DELIM: "πŸ˜β€πŸ’¨", DELIM + r"raised_eyebrow" + DELIM: "🀨", DELIM + r"expressionless" + DELIM: "πŸ˜‘", DELIM + r"face_in_clouds" + DELIM: "πŸ˜Άβ€πŸŒ«οΈ", DELIM + r"unamused" + DELIM: "πŸ˜’", DELIM + r"grimacing" + DELIM: "😬", DELIM + r"lying_face" + DELIM: "πŸ€₯", # # Face Sleepy # DELIM + r"relieved" + DELIM: "😌", DELIM + r"sleepy" + DELIM: "πŸ˜ͺ", DELIM + r"sleeping" + DELIM: "😴", DELIM + r"pensive" + DELIM: "πŸ˜”", DELIM + r"drooling_face" + DELIM: "🀀", # # Face Unwell # DELIM + r"mask" + DELIM: "😷", DELIM + r"face_with_head_bandage" + DELIM: "πŸ€•", DELIM + r"vomiting_face" + DELIM: "πŸ€", DELIM + r"hot_face" + DELIM: "πŸ₯΅", DELIM + r"woozy_face" + DELIM: "πŸ₯΄", DELIM + r"face_with_spiral_eyes" + DELIM: "πŸ˜΅β€πŸ’«", DELIM + r"face_with_thermometer" + DELIM: "πŸ€’", DELIM + r"nauseated_face" + DELIM: "🀒", DELIM + r"sneezing_face" + DELIM: "🀧", DELIM + r"cold_face" + DELIM: "πŸ₯Ά", DELIM + r"dizzy_face" + DELIM: "😡", DELIM + r"exploding_head" + DELIM: "🀯", # # Face Hat # DELIM + r"cowboy_hat_face" + DELIM: "🀠", DELIM + r"disguised_face" + DELIM: "πŸ₯Έ", DELIM + r"partying_face" + DELIM: "πŸ₯³", # # Face Glasses # DELIM + r"sunglasses" + DELIM: "😎", DELIM + r"monocle_face" + DELIM: "🧐", DELIM + r"nerd_face" + DELIM: "πŸ€“", # # Face Concerned # DELIM + r"confused" + DELIM: "πŸ˜•", DELIM + r"slightly_frowning_face" + DELIM: "πŸ™", DELIM + r"open_mouth" + DELIM: "πŸ˜", DELIM + r"astonished" + DELIM: "😲", DELIM + r"pleading_face" + DELIM: "πŸ₯Ί", DELIM + r"anguished" + DELIM: "😧", DELIM + r"cold_sweat" + DELIM: "😰", DELIM + r"cry" + DELIM: "😒", DELIM + r"scream" + DELIM: "😱", DELIM + r"persevere" + DELIM: "😣", DELIM + r"sweat" + DELIM: "πŸ˜“", DELIM + r"tired_face" + DELIM: "😫", DELIM + r"worried" + DELIM: "😟", DELIM + r"frowning_face" + DELIM: "☹️", DELIM + r"hushed" + DELIM: "😯", DELIM + r"flushed" + DELIM: "😳", DELIM + r"frowning" + DELIM: "😦", DELIM + r"fearful" + DELIM: "😨", DELIM + r"disappointed_relieved" + DELIM: "πŸ˜₯", DELIM + r"sob" + DELIM: "😭", DELIM + r"confounded" + DELIM: "πŸ˜–", DELIM + r"disappointed" + DELIM: "😞", DELIM + r"weary" + DELIM: "😩", DELIM + r"yawning_face" + DELIM: "πŸ₯±", # # Face Negative # DELIM + r"triumph" + DELIM: "😀", DELIM + r"angry" + DELIM: "😠", DELIM + r"smiling_imp" + DELIM: "😈", DELIM + r"skull" + DELIM: "πŸ’€", DELIM + r"(pout|rage)" + DELIM: "😑", DELIM + r"cursing_face" + DELIM: "🀬", DELIM + r"imp" + DELIM: "πŸ‘Ώ", DELIM + r"skull_and_crossbones" + DELIM: "☠️", # # Face Costume # DELIM + r"(hankey|poop|shit)" + DELIM: "πŸ’©", DELIM + r"japanese_ogre" + DELIM: "πŸ‘Ή", DELIM + r"ghost" + DELIM: "πŸ‘»", DELIM + r"space_invader" + DELIM: "πŸ‘Ύ", DELIM + r"clown_face" + DELIM: "🀑", DELIM + r"japanese_goblin" + DELIM: "πŸ‘Ί", DELIM + r"alien" + DELIM: "πŸ‘½", DELIM + r"robot" + DELIM: "πŸ€–", # # Cat Face # DELIM + r"smiley_cat" + DELIM: "😺", DELIM + r"joy_cat" + DELIM: "😹", DELIM + r"smirk_cat" + DELIM: "😼", DELIM + r"scream_cat" + DELIM: "πŸ™€", DELIM + r"pouting_cat" + DELIM: "😾", DELIM + r"smile_cat" + DELIM: "😸", DELIM + r"heart_eyes_cat" + DELIM: "😻", DELIM + r"kissing_cat" + DELIM: "😽", DELIM + r"crying_cat_face" + DELIM: "😿", # # Monkey Face # DELIM + r"see_no_evil" + DELIM: "πŸ™ˆ", DELIM + r"speak_no_evil" + DELIM: "πŸ™Š", DELIM + r"hear_no_evil" + DELIM: "πŸ™‰", # # Heart # DELIM + r"love_letter" + DELIM: "πŸ’Œ", DELIM + r"gift_heart" + DELIM: "πŸ’", DELIM + r"heartpulse" + DELIM: "πŸ’—", DELIM + r"revolving_hearts" + DELIM: "πŸ’ž", DELIM + r"heart_decoration" + DELIM: "πŸ’Ÿ", DELIM + r"broken_heart" + DELIM: "πŸ’”", DELIM + r"mending_heart" + DELIM: "β€οΈβ€πŸ©Ή", DELIM + r"orange_heart" + DELIM: "🧑", DELIM + r"green_heart" + DELIM: "πŸ’š", DELIM + r"purple_heart" + DELIM: "πŸ’œ", DELIM + r"black_heart" + DELIM: "πŸ–€", DELIM + r"cupid" + DELIM: "πŸ’˜", DELIM + r"sparkling_heart" + DELIM: "πŸ’–", DELIM + r"heartbeat" + DELIM: "πŸ’“", DELIM + r"two_hearts" + DELIM: "πŸ’•", DELIM + r"heavy_heart_exclamation" + DELIM: "❣️", DELIM + r"heart_on_fire" + DELIM: "❀️‍πŸ”₯", DELIM + r"heart" + DELIM: "❀️", DELIM + r"yellow_heart" + DELIM: "πŸ’›", DELIM + r"blue_heart" + DELIM: "πŸ’™", DELIM + r"brown_heart" + DELIM: "🀎", DELIM + r"white_heart" + DELIM: "🀍", # # Emotion # DELIM + r"kiss" + DELIM: "πŸ’‹", DELIM + r"anger" + DELIM: "πŸ’’", DELIM + r"dizzy" + DELIM: "πŸ’«", DELIM + r"dash" + DELIM: "πŸ’¨", DELIM + r"speech_balloon" + DELIM: "πŸ’¬", DELIM + r"left_speech_bubble" + DELIM: "πŸ—¨οΈ", DELIM + r"thought_balloon" + DELIM: "πŸ’­", DELIM + r"100" + DELIM: "πŸ’―", DELIM + r"(boom|collision)" + DELIM: "πŸ’₯", DELIM + r"sweat_drops" + DELIM: "πŸ’¦", DELIM + r"hole" + DELIM: "πŸ•³οΈ", DELIM + r"eye_speech_bubble" + DELIM: "πŸ‘οΈβ€πŸ—¨οΈ", DELIM + r"right_anger_bubble" + DELIM: "πŸ—―οΈ", DELIM + r"zzz" + DELIM: "πŸ’€", # # Hand Fingers Open # DELIM + r"wave" + DELIM: "πŸ‘‹", DELIM + r"raised_hand_with_fingers_splayed" + DELIM: "πŸ–οΈ", DELIM + r"vulcan_salute" + DELIM: "πŸ––", DELIM + r"raised_back_of_hand" + DELIM: "🀚", DELIM + r"(raised_)?hand" + DELIM: "βœ‹", # # Hand Fingers Partial # DELIM + r"ok_hand" + DELIM: "πŸ‘Œ", DELIM + r"pinched_fingers" + DELIM: "🀌", DELIM + r"pinching_hand" + DELIM: "🀏", DELIM + r"v" + DELIM: "✌️", DELIM + r"crossed_fingers" + DELIM: "🀞", DELIM + r"love_you_gesture" + DELIM: "🀟", DELIM + r"metal" + DELIM: "🀘", DELIM + r"call_me_hand" + DELIM: "πŸ€™", # # Hand Single Finger # DELIM + r"point_left" + DELIM: "πŸ‘ˆ", DELIM + r"point_right" + DELIM: "πŸ‘‰", DELIM + r"point_up_2" + DELIM: "πŸ‘†", DELIM + r"(fu|middle_finger)" + DELIM: "πŸ–•", DELIM + r"point_down" + DELIM: "πŸ‘‡", DELIM + r"point_up" + DELIM: "☝️", # # Hand Fingers Closed # DELIM + r"(\+1|thumbsup)" + DELIM: "πŸ‘", DELIM + r"(-1|thumbsdown)" + DELIM: "πŸ‘Ž", DELIM + r"fist" + DELIM: "✊", DELIM + r"(fist_(raised|oncoming)|(face)?punch)" + DELIM: "πŸ‘Š", DELIM + r"fist_left" + DELIM: "πŸ€›", DELIM + r"fist_right" + DELIM: "🀜", # # Hands # DELIM + r"clap" + DELIM: "πŸ‘", DELIM + r"raised_hands" + DELIM: "πŸ™Œ", DELIM + r"open_hands" + DELIM: "πŸ‘", DELIM + r"palms_up_together" + DELIM: "🀲", DELIM + r"handshake" + DELIM: "🀝", DELIM + r"pray" + DELIM: "πŸ™", # # Hand Prop # DELIM + r"writing_hand" + DELIM: "✍️", DELIM + r"nail_care" + DELIM: "πŸ’…", DELIM + r"selfie" + DELIM: "🀳", # # Body Parts # DELIM + r"muscle" + DELIM: "πŸ’ͺ", DELIM + r"mechanical_arm" + DELIM: "🦾", DELIM + r"mechanical_leg" + DELIM: "🦿", DELIM + r"leg" + DELIM: "🦡", DELIM + r"foot" + DELIM: "🦢", DELIM + r"ear" + DELIM: "πŸ‘‚", DELIM + r"ear_with_hearing_aid" + DELIM: "🦻", DELIM + r"nose" + DELIM: "πŸ‘ƒ", DELIM + r"brain" + DELIM: "🧠", DELIM + r"anatomical_heart" + DELIM: "πŸ«€", DELIM + r"lungs" + DELIM: "🫁", DELIM + r"tooth" + DELIM: "🦷", DELIM + r"bone" + DELIM: "🦴", DELIM + r"eyes" + DELIM: "πŸ‘€", DELIM + r"eye" + DELIM: "πŸ‘οΈ", DELIM + r"tongue" + DELIM: "πŸ‘…", DELIM + r"lips" + DELIM: "πŸ‘„", # # Person # DELIM + r"baby" + DELIM: "πŸ‘Ά", DELIM + r"child" + DELIM: "πŸ§’", DELIM + r"boy" + DELIM: "πŸ‘¦", DELIM + r"girl" + DELIM: "πŸ‘§", DELIM + r"adult" + DELIM: "πŸ§‘", DELIM + r"blond_haired_person" + DELIM: "πŸ‘±", DELIM + r"man" + DELIM: "πŸ‘¨", DELIM + r"bearded_person" + DELIM: "πŸ§”", DELIM + r"man_beard" + DELIM: "πŸ§”β€β™‚οΈ", DELIM + r"woman_beard" + DELIM: "πŸ§”β€β™€οΈ", DELIM + r"red_haired_man" + DELIM: "πŸ‘¨β€πŸ¦°", DELIM + r"curly_haired_man" + DELIM: "πŸ‘¨β€πŸ¦±", DELIM + r"white_haired_man" + DELIM: "πŸ‘¨β€πŸ¦³", DELIM + r"bald_man" + DELIM: "πŸ‘¨β€πŸ¦²", DELIM + r"woman" + DELIM: "πŸ‘©", DELIM + r"red_haired_woman" + DELIM: "πŸ‘©β€πŸ¦°", DELIM + r"person_red_hair" + DELIM: "πŸ§‘β€πŸ¦°", DELIM + r"curly_haired_woman" + DELIM: "πŸ‘©β€πŸ¦±", DELIM + r"person_curly_hair" + DELIM: "πŸ§‘β€πŸ¦±", DELIM + r"white_haired_woman" + DELIM: "πŸ‘©β€πŸ¦³", DELIM + r"person_white_hair" + DELIM: "πŸ§‘β€πŸ¦³", DELIM + r"bald_woman" + DELIM: "πŸ‘©β€πŸ¦²", DELIM + r"person_bald" + DELIM: "πŸ§‘β€πŸ¦²", DELIM + r"blond_(haired_)?woman" + DELIM: "πŸ‘±β€β™€οΈ", DELIM + r"blond_haired_man" + DELIM: "πŸ‘±β€β™‚οΈ", DELIM + r"older_adult" + DELIM: "πŸ§“", DELIM + r"older_man" + DELIM: "πŸ‘΄", DELIM + r"older_woman" + DELIM: "πŸ‘΅", # # Person Gesture # DELIM + r"frowning_person" + DELIM: "πŸ™", DELIM + r"frowning_man" + DELIM: "πŸ™β€β™‚οΈ", DELIM + r"frowning_woman" + DELIM: "πŸ™β€β™€οΈ", DELIM + r"pouting_face" + DELIM: "πŸ™Ž", DELIM + r"pouting_man" + DELIM: "πŸ™Žβ€β™‚οΈ", DELIM + r"pouting_woman" + DELIM: "πŸ™Žβ€β™€οΈ", DELIM + r"no_good" + DELIM: "πŸ™…", DELIM + r"(ng|no_good)_man" + DELIM: "πŸ™…β€β™‚οΈ", DELIM + r"(ng_woman|no_good_woman)" + DELIM: "πŸ™…β€β™€οΈ", DELIM + r"ok_person" + DELIM: "πŸ™†", DELIM + r"ok_man" + DELIM: "πŸ™†β€β™‚οΈ", DELIM + r"ok_woman" + DELIM: "πŸ™†β€β™€οΈ", DELIM + r"(information_desk|tipping_hand_)person" + DELIM: "πŸ’", DELIM + r"(sassy_man|tipping_hand_man)" + DELIM: "πŸ’β€β™‚οΈ", DELIM + r"(sassy_woman|tipping_hand_woman)" + DELIM: "πŸ’β€β™€οΈ", DELIM + r"raising_hand" + DELIM: "πŸ™‹", DELIM + r"raising_hand_man" + DELIM: "πŸ™‹β€β™‚οΈ", DELIM + r"raising_hand_woman" + DELIM: "πŸ™‹β€β™€οΈ", DELIM + r"deaf_person" + DELIM: "🧏", DELIM + r"deaf_man" + DELIM: "πŸ§β€β™‚οΈ", DELIM + r"deaf_woman" + DELIM: "πŸ§β€β™€οΈ", DELIM + r"bow" + DELIM: "πŸ™‡", DELIM + r"bowing_man" + DELIM: "πŸ™‡β€β™‚οΈ", DELIM + r"bowing_woman" + DELIM: "πŸ™‡β€β™€οΈ", DELIM + r"facepalm" + DELIM: "🀦", DELIM + r"man_facepalming" + DELIM: "πŸ€¦β€β™‚οΈ", DELIM + r"woman_facepalming" + DELIM: "πŸ€¦β€β™€οΈ", DELIM + r"shrug" + DELIM: "🀷", DELIM + r"man_shrugging" + DELIM: "πŸ€·β€β™‚οΈ", DELIM + r"woman_shrugging" + DELIM: "πŸ€·β€β™€οΈ", # # Person Role # DELIM + r"health_worker" + DELIM: "πŸ§‘β€βš•οΈ", DELIM + r"man_health_worker" + DELIM: "πŸ‘¨β€βš•οΈ", DELIM + r"woman_health_worker" + DELIM: "πŸ‘©β€βš•οΈ", DELIM + r"student" + DELIM: "πŸ§‘β€πŸŽ“", DELIM + r"man_student" + DELIM: "πŸ‘¨β€πŸŽ“", DELIM + r"woman_student" + DELIM: "πŸ‘©β€πŸŽ“", DELIM + r"teacher" + DELIM: "πŸ§‘β€πŸ«", DELIM + r"man_teacher" + DELIM: "πŸ‘¨β€πŸ«", DELIM + r"woman_teacher" + DELIM: "πŸ‘©β€πŸ«", DELIM + r"judge" + DELIM: "πŸ§‘β€βš–οΈ", DELIM + r"man_judge" + DELIM: "πŸ‘¨β€βš–οΈ", DELIM + r"woman_judge" + DELIM: "πŸ‘©β€βš–οΈ", DELIM + r"farmer" + DELIM: "πŸ§‘β€πŸŒΎ", DELIM + r"man_farmer" + DELIM: "πŸ‘¨β€πŸŒΎ", DELIM + r"woman_farmer" + DELIM: "πŸ‘©β€πŸŒΎ", DELIM + r"cook" + DELIM: "πŸ§‘β€πŸ³", DELIM + r"man_cook" + DELIM: "πŸ‘¨β€πŸ³", DELIM + r"woman_cook" + DELIM: "πŸ‘©β€πŸ³", DELIM + r"mechanic" + DELIM: "πŸ§‘β€πŸ”§", DELIM + r"man_mechanic" + DELIM: "πŸ‘¨β€πŸ”§", DELIM + r"woman_mechanic" + DELIM: "πŸ‘©β€πŸ”§", DELIM + r"factory_worker" + DELIM: "πŸ§‘β€πŸ­", DELIM + r"man_factory_worker" + DELIM: "πŸ‘¨β€πŸ­", DELIM + r"woman_factory_worker" + DELIM: "πŸ‘©β€πŸ­", DELIM + r"office_worker" + DELIM: "πŸ§‘β€πŸ’Ό", DELIM + r"man_office_worker" + DELIM: "πŸ‘¨β€πŸ’Ό", DELIM + r"woman_office_worker" + DELIM: "πŸ‘©β€πŸ’Ό", DELIM + r"scientist" + DELIM: "πŸ§‘β€πŸ”¬", DELIM + r"man_scientist" + DELIM: "πŸ‘¨β€πŸ”¬", DELIM + r"woman_scientist" + DELIM: "πŸ‘©β€πŸ”¬", DELIM + r"technologist" + DELIM: "πŸ§‘β€πŸ’»", DELIM + r"man_technologist" + DELIM: "πŸ‘¨β€πŸ’»", DELIM + r"woman_technologist" + DELIM: "πŸ‘©β€πŸ’»", DELIM + r"singer" + DELIM: "πŸ§‘β€πŸŽ€", DELIM + r"man_singer" + DELIM: "πŸ‘¨β€πŸŽ€", DELIM + r"woman_singer" + DELIM: "πŸ‘©β€πŸŽ€", DELIM + r"artist" + DELIM: "πŸ§‘β€πŸŽ¨", DELIM + r"man_artist" + DELIM: "πŸ‘¨β€πŸŽ¨", DELIM + r"woman_artist" + DELIM: "πŸ‘©β€πŸŽ¨", DELIM + r"pilot" + DELIM: "πŸ§‘β€βœˆοΈ", DELIM + r"man_pilot" + DELIM: "πŸ‘¨β€βœˆοΈ", DELIM + r"woman_pilot" + DELIM: "πŸ‘©β€βœˆοΈ", DELIM + r"astronaut" + DELIM: "πŸ§‘β€πŸš€", DELIM + r"man_astronaut" + DELIM: "πŸ‘¨β€πŸš€", DELIM + r"woman_astronaut" + DELIM: "πŸ‘©β€πŸš€", DELIM + r"firefighter" + DELIM: "πŸ§‘β€πŸš’", DELIM + r"man_firefighter" + DELIM: "πŸ‘¨β€πŸš’", DELIM + r"woman_firefighter" + DELIM: "πŸ‘©β€πŸš’", DELIM + r"cop" + DELIM: "πŸ‘", DELIM + r"police(_officer|man)" + DELIM: "πŸ‘‍♂️", DELIM + r"policewoman" + DELIM: "πŸ‘‍♀️", DELIM + r"detective" + DELIM: "πŸ•΅οΈ", DELIM + r"male_detective" + DELIM: "πŸ•΅οΈβ€β™‚οΈ", DELIM + r"female_detective" + DELIM: "πŸ•΅οΈβ€β™€οΈ", DELIM + r"guard" + DELIM: "πŸ’‚", DELIM + r"guardsman" + DELIM: "πŸ’‚β€β™‚οΈ", DELIM + r"guardswoman" + DELIM: "πŸ’‚β€β™€οΈ", DELIM + r"ninja" + DELIM: "πŸ₯·", DELIM + r"construction_worker" + DELIM: "πŸ‘·", DELIM + r"construction_worker_man" + DELIM: "πŸ‘·β€β™‚οΈ", DELIM + r"construction_worker_woman" + DELIM: "πŸ‘·β€β™€οΈ", DELIM + r"prince" + DELIM: "🀴", DELIM + r"princess" + DELIM: "πŸ‘Έ", DELIM + r"person_with_turban" + DELIM: "πŸ‘³", DELIM + r"man_with_turban" + DELIM: "πŸ‘³β€β™‚οΈ", DELIM + r"woman_with_turban" + DELIM: "πŸ‘³β€β™€οΈ", DELIM + r"man_with_gua_pi_mao" + DELIM: "πŸ‘²", DELIM + r"woman_with_headscarf" + DELIM: "πŸ§•", DELIM + r"person_in_tuxedo" + DELIM: "🀡", DELIM + r"man_in_tuxedo" + DELIM: "πŸ€΅β€β™‚οΈ", DELIM + r"woman_in_tuxedo" + DELIM: "πŸ€΅β€β™€οΈ", DELIM + r"person_with_veil" + DELIM: "πŸ‘°", DELIM + r"man_with_veil" + DELIM: "πŸ‘°β€β™‚οΈ", DELIM + r"(bride|woman)_with_veil" + DELIM: "πŸ‘°β€β™€οΈ", DELIM + r"pregnant_woman" + DELIM: "🀰", DELIM + r"breast_feeding" + DELIM: "🀱", DELIM + r"woman_feeding_baby" + DELIM: "πŸ‘©β€πŸΌ", DELIM + r"man_feeding_baby" + DELIM: "πŸ‘¨β€πŸΌ", DELIM + r"person_feeding_baby" + DELIM: "πŸ§‘β€πŸΌ", # # Person Fantasy # DELIM + r"angel" + DELIM: "πŸ‘Ό", DELIM + r"santa" + DELIM: "πŸŽ…", DELIM + r"mrs_claus" + DELIM: "🀢", DELIM + r"mx_claus" + DELIM: "πŸ§‘β€πŸŽ„", DELIM + r"superhero" + DELIM: "🦸", DELIM + r"superhero_man" + DELIM: "πŸ¦Έβ€β™‚οΈ", DELIM + r"superhero_woman" + DELIM: "πŸ¦Έβ€β™€οΈ", DELIM + r"supervillain" + DELIM: "🦹", DELIM + r"supervillain_man" + DELIM: "πŸ¦Ήβ€β™‚οΈ", DELIM + r"supervillain_woman" + DELIM: "πŸ¦Ήβ€β™€οΈ", DELIM + r"mage" + DELIM: "πŸ§™", DELIM + r"mage_man" + DELIM: "πŸ§™β€β™‚οΈ", DELIM + r"mage_woman" + DELIM: "πŸ§™β€β™€οΈ", DELIM + r"fairy" + DELIM: "🧚", DELIM + r"fairy_man" + DELIM: "πŸ§šβ€β™‚οΈ", DELIM + r"fairy_woman" + DELIM: "πŸ§šβ€β™€οΈ", DELIM + r"vampire" + DELIM: "πŸ§›", DELIM + r"vampire_man" + DELIM: "πŸ§›β€β™‚οΈ", DELIM + r"vampire_woman" + DELIM: "πŸ§›β€β™€οΈ", DELIM + r"merperson" + DELIM: "🧜", DELIM + r"merman" + DELIM: "πŸ§œβ€β™‚οΈ", DELIM + r"mermaid" + DELIM: "πŸ§œβ€β™€οΈ", DELIM + r"elf" + DELIM: "🧝", DELIM + r"elf_man" + DELIM: "πŸ§β€β™‚οΈ", DELIM + r"elf_woman" + DELIM: "πŸ§β€β™€οΈ", DELIM + r"genie" + DELIM: "🧞", DELIM + r"genie_man" + DELIM: "πŸ§žβ€β™‚οΈ", DELIM + r"genie_woman" + DELIM: "πŸ§žβ€β™€οΈ", DELIM + r"zombie" + DELIM: "🧟", DELIM + r"zombie_man" + DELIM: "πŸ§Ÿβ€β™‚οΈ", DELIM + r"zombie_woman" + DELIM: "πŸ§Ÿβ€β™€οΈ", # # Person Activity # DELIM + r"massage" + DELIM: "πŸ’†", DELIM + r"massage_man" + DELIM: "πŸ’†β€β™‚οΈ", DELIM + r"massage_woman" + DELIM: "πŸ’†β€β™€οΈ", DELIM + r"haircut" + DELIM: "πŸ’‡", DELIM + r"haircut_man" + DELIM: "πŸ’‡β€β™‚οΈ", DELIM + r"haircut_woman" + DELIM: "πŸ’‡β€β™€οΈ", DELIM + r"walking" + DELIM: "🚢", DELIM + r"walking_man" + DELIM: "πŸšΆβ€β™‚οΈ", DELIM + r"walking_woman" + DELIM: "πŸšΆβ€β™€οΈ", DELIM + r"standing_person" + DELIM: "🧍", DELIM + r"standing_man" + DELIM: "πŸ§β€β™‚οΈ", DELIM + r"standing_woman" + DELIM: "πŸ§β€β™€οΈ", DELIM + r"kneeling_person" + DELIM: "🧎", DELIM + r"kneeling_man" + DELIM: "πŸ§Žβ€β™‚οΈ", DELIM + r"kneeling_woman" + DELIM: "πŸ§Žβ€β™€οΈ", DELIM + r"person_with_probing_cane" + DELIM: "πŸ§‘β€πŸ¦―", DELIM + r"man_with_probing_cane" + DELIM: "πŸ‘¨β€πŸ¦―", DELIM + r"woman_with_probing_cane" + DELIM: "πŸ‘©β€πŸ¦―", DELIM + r"person_in_motorized_wheelchair" + DELIM: "πŸ§‘β€πŸ¦Ό", DELIM + r"man_in_motorized_wheelchair" + DELIM: "πŸ‘¨β€πŸ¦Ό", DELIM + r"woman_in_motorized_wheelchair" + DELIM: "πŸ‘©β€πŸ¦Ό", DELIM + r"person_in_manual_wheelchair" + DELIM: "πŸ§‘β€πŸ¦½", DELIM + r"man_in_manual_wheelchair" + DELIM: "πŸ‘¨β€πŸ¦½", DELIM + r"woman_in_manual_wheelchair" + DELIM: "πŸ‘©β€πŸ¦½", DELIM + r"runn(er|ing)" + DELIM: "πŸƒ", DELIM + r"running_man" + DELIM: "πŸƒβ€β™‚οΈ", DELIM + r"running_woman" + DELIM: "πŸƒβ€β™€οΈ", DELIM + r"(dancer|woman_dancing)" + DELIM: "πŸ’ƒ", DELIM + r"man_dancing" + DELIM: "πŸ•Ί", DELIM + r"business_suit_levitating" + DELIM: "πŸ•΄οΈ", DELIM + r"dancers" + DELIM: "πŸ‘―", DELIM + r"dancing_men" + DELIM: "πŸ‘―β€β™‚οΈ", DELIM + r"dancing_women" + DELIM: "πŸ‘―β€β™€οΈ", DELIM + r"sauna_person" + DELIM: "πŸ§–", DELIM + r"sauna_man" + DELIM: "πŸ§–β€β™‚οΈ", DELIM + r"sauna_woman" + DELIM: "πŸ§–β€β™€οΈ", DELIM + r"climbing" + DELIM: "πŸ§—", DELIM + r"climbing_man" + DELIM: "πŸ§—β€β™‚οΈ", DELIM + r"climbing_woman" + DELIM: "πŸ§—β€β™€οΈ", # # Person Sport # DELIM + r"person_fencing" + DELIM: "🀺", DELIM + r"horse_racing" + DELIM: "πŸ‡", DELIM + r"skier" + DELIM: "⛷️", DELIM + r"snowboarder" + DELIM: "πŸ‚", DELIM + r"golfing" + DELIM: "🏌️", DELIM + r"golfing_man" + DELIM: "πŸŒοΈβ€β™‚οΈ", DELIM + r"golfing_woman" + DELIM: "πŸŒοΈβ€β™€οΈ", DELIM + r"surfer" + DELIM: "πŸ„", DELIM + r"surfing_man" + DELIM: "πŸ„β€β™‚οΈ", DELIM + r"surfing_woman" + DELIM: "πŸ„β€β™€οΈ", DELIM + r"rowboat" + DELIM: "🚣", DELIM + r"rowing_man" + DELIM: "πŸš£β€β™‚οΈ", DELIM + r"rowing_woman" + DELIM: "πŸš£β€β™€οΈ", DELIM + r"swimmer" + DELIM: "🏊", DELIM + r"swimming_man" + DELIM: "πŸŠβ€β™‚οΈ", DELIM + r"swimming_woman" + DELIM: "πŸŠβ€β™€οΈ", DELIM + r"bouncing_ball_person" + DELIM: "⛹️", DELIM + r"(basketball|bouncing_ball)_man" + DELIM: "⛹️‍♂️", DELIM + r"(basketball|bouncing_ball)_woman" + DELIM: "⛹️‍♀️", DELIM + r"weight_lifting" + DELIM: "πŸ‹οΈ", DELIM + r"weight_lifting_man" + DELIM: "πŸ‹οΈβ€β™‚οΈ", DELIM + r"weight_lifting_woman" + DELIM: "πŸ‹οΈβ€β™€οΈ", DELIM + r"bicyclist" + DELIM: "🚴", DELIM + r"biking_man" + DELIM: "πŸš΄β€β™‚οΈ", DELIM + r"biking_woman" + DELIM: "πŸš΄β€β™€οΈ", DELIM + r"mountain_bicyclist" + DELIM: "🚡", DELIM + r"mountain_biking_man" + DELIM: "πŸš΅β€β™‚οΈ", DELIM + r"mountain_biking_woman" + DELIM: "πŸš΅β€β™€οΈ", DELIM + r"cartwheeling" + DELIM: "🀸", DELIM + r"man_cartwheeling" + DELIM: "πŸ€Έβ€β™‚οΈ", DELIM + r"woman_cartwheeling" + DELIM: "πŸ€Έβ€β™€οΈ", DELIM + r"wrestling" + DELIM: "🀼", DELIM + r"men_wrestling" + DELIM: "πŸ€Όβ€β™‚οΈ", DELIM + r"women_wrestling" + DELIM: "πŸ€Όβ€β™€οΈ", DELIM + r"water_polo" + DELIM: "🀽", DELIM + r"man_playing_water_polo" + DELIM: "πŸ€½β€β™‚οΈ", DELIM + r"woman_playing_water_polo" + DELIM: "πŸ€½β€β™€οΈ", DELIM + r"handball_person" + DELIM: "🀾", DELIM + r"man_playing_handball" + DELIM: "πŸ€Ύβ€β™‚οΈ", DELIM + r"woman_playing_handball" + DELIM: "πŸ€Ύβ€β™€οΈ", DELIM + r"juggling_person" + DELIM: "🀹", DELIM + r"man_juggling" + DELIM: "πŸ€Ήβ€β™‚οΈ", DELIM + r"woman_juggling" + DELIM: "πŸ€Ήβ€β™€οΈ", # # Person Resting # DELIM + r"lotus_position" + DELIM: "🧘", DELIM + r"lotus_position_man" + DELIM: "πŸ§˜β€β™‚οΈ", DELIM + r"lotus_position_woman" + DELIM: "πŸ§˜β€β™€οΈ", DELIM + r"bath" + DELIM: "πŸ›€", DELIM + r"sleeping_bed" + DELIM: "πŸ›Œ", # # Family # DELIM + r"people_holding_hands" + DELIM: "πŸ§‘β€πŸ€β€πŸ§‘", DELIM + r"two_women_holding_hands" + DELIM: "πŸ‘­", DELIM + r"couple" + DELIM: "πŸ‘«", DELIM + r"two_men_holding_hands" + DELIM: "πŸ‘¬", DELIM + r"couplekiss" + DELIM: "πŸ’", DELIM + r"couplekiss_man_woman" + DELIM: "πŸ‘©β€β€οΈβ€πŸ’‹β€πŸ‘¨", DELIM + r"couplekiss_man_man" + DELIM: "πŸ‘¨β€β€οΈβ€πŸ’‹β€πŸ‘¨", DELIM + r"couplekiss_woman_woman" + DELIM: "πŸ‘©β€β€οΈβ€πŸ’‹β€πŸ‘©", DELIM + r"couple_with_heart" + DELIM: "πŸ’‘", DELIM + r"couple_with_heart_woman_man" + DELIM: "πŸ‘©β€β€οΈβ€πŸ‘¨", DELIM + r"couple_with_heart_man_man" + DELIM: "πŸ‘¨β€β€οΈβ€πŸ‘¨", DELIM + r"couple_with_heart_woman_woman" + DELIM: "πŸ‘©β€β€οΈβ€πŸ‘©", DELIM + r"family_man_woman_boy" + DELIM: "πŸ‘¨β€πŸ‘©β€πŸ‘¦", DELIM + r"family_man_woman_girl" + DELIM: "πŸ‘¨β€πŸ‘©β€πŸ‘§", DELIM + r"family_man_woman_girl_boy" + DELIM: "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", DELIM + r"family_man_woman_boy_boy" + DELIM: "πŸ‘¨β€πŸ‘©β€πŸ‘¦β€πŸ‘¦", DELIM + r"family_man_woman_girl_girl" + DELIM: "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘§", DELIM + r"family_man_man_boy" + DELIM: "πŸ‘¨β€πŸ‘¨β€πŸ‘¦", DELIM + r"family_man_man_girl" + DELIM: "πŸ‘¨β€πŸ‘¨β€πŸ‘§", DELIM + r"family_man_man_girl_boy" + DELIM: "πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘¦", DELIM + r"family_man_man_boy_boy" + DELIM: "πŸ‘¨β€πŸ‘¨β€πŸ‘¦β€πŸ‘¦", DELIM + r"family_man_man_girl_girl" + DELIM: "πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘§", DELIM + r"family_woman_woman_boy" + DELIM: "πŸ‘©β€πŸ‘©β€πŸ‘¦", DELIM + r"family_woman_woman_girl" + DELIM: "πŸ‘©β€πŸ‘©β€πŸ‘§", DELIM + r"family_woman_woman_girl_boy" + DELIM: "πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦", DELIM + r"family_woman_woman_boy_boy" + DELIM: "πŸ‘©β€πŸ‘©β€πŸ‘¦β€πŸ‘¦", DELIM + r"family_woman_woman_girl_girl" + DELIM: "πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘§", DELIM + r"family_man_boy" + DELIM: "πŸ‘¨β€πŸ‘¦", DELIM + r"family_man_boy_boy" + DELIM: "πŸ‘¨β€πŸ‘¦β€πŸ‘¦", DELIM + r"family_man_girl" + DELIM: "πŸ‘¨β€πŸ‘§", DELIM + r"family_man_girl_boy" + DELIM: "πŸ‘¨β€πŸ‘§β€πŸ‘¦", DELIM + r"family_man_girl_girl" + DELIM: "πŸ‘¨β€πŸ‘§β€πŸ‘§", DELIM + r"family_woman_boy" + DELIM: "πŸ‘©β€πŸ‘¦", DELIM + r"family_woman_boy_boy" + DELIM: "πŸ‘©β€πŸ‘¦β€πŸ‘¦", DELIM + r"family_woman_girl" + DELIM: "πŸ‘©β€πŸ‘§", DELIM + r"family_woman_girl_boy" + DELIM: "πŸ‘©β€πŸ‘§β€πŸ‘¦", DELIM + r"family_woman_girl_girl" + DELIM: "πŸ‘©β€πŸ‘§β€πŸ‘§", # # Person Symbol # DELIM + r"speaking_head" + DELIM: "πŸ—£οΈ", DELIM + r"bust_in_silhouette" + DELIM: "πŸ‘€", DELIM + r"busts_in_silhouette" + DELIM: "πŸ‘₯", DELIM + r"people_hugging" + DELIM: "πŸ«‚", DELIM + r"family" + DELIM: "πŸ‘ͺ", DELIM + r"footprints" + DELIM: "πŸ‘£", # # Animal Mammal # DELIM + r"monkey_face" + DELIM: "🐡", DELIM + r"monkey" + DELIM: "πŸ’", DELIM + r"gorilla" + DELIM: "🦍", DELIM + r"orangutan" + DELIM: "🦧", DELIM + r"dog" + DELIM: "🐢", DELIM + r"dog2" + DELIM: "πŸ•", DELIM + r"guide_dog" + DELIM: "πŸ¦", DELIM + r"service_dog" + DELIM: "πŸ•β€πŸ¦Ί", DELIM + r"poodle" + DELIM: "🐩", DELIM + r"wolf" + DELIM: "🐺", DELIM + r"fox_face" + DELIM: "🦊", DELIM + r"raccoon" + DELIM: "🦝", DELIM + r"cat" + DELIM: "🐱", DELIM + r"cat2" + DELIM: "🐈", DELIM + r"black_cat" + DELIM: "πŸˆβ€β¬›", DELIM + r"lion" + DELIM: "🦁", DELIM + r"tiger" + DELIM: "🐯", DELIM + r"tiger2" + DELIM: "πŸ…", DELIM + r"leopard" + DELIM: "πŸ†", DELIM + r"horse" + DELIM: "🐴", DELIM + r"racehorse" + DELIM: "🐎", DELIM + r"unicorn" + DELIM: "πŸ¦„", DELIM + r"zebra" + DELIM: "πŸ¦“", DELIM + r"deer" + DELIM: "🦌", DELIM + r"bison" + DELIM: "🦬", DELIM + r"cow" + DELIM: "πŸ", DELIM + r"ox" + DELIM: "πŸ‚", DELIM + r"water_buffalo" + DELIM: "πŸƒ", DELIM + r"cow2" + DELIM: "πŸ„", DELIM + r"pig" + DELIM: "🐷", DELIM + r"pig2" + DELIM: "πŸ–", DELIM + r"boar" + DELIM: "πŸ—", DELIM + r"pig_nose" + DELIM: "🐽", DELIM + r"ram" + DELIM: "🐏", DELIM + r"sheep" + DELIM: "πŸ‘", DELIM + r"goat" + DELIM: "🐐", DELIM + r"dromedary_camel" + DELIM: "πŸͺ", DELIM + r"camel" + DELIM: "🐫", DELIM + r"llama" + DELIM: "πŸ¦™", DELIM + r"giraffe" + DELIM: "πŸ¦’", DELIM + r"elephant" + DELIM: "🐘", DELIM + r"mammoth" + DELIM: "🦣", DELIM + r"rhinoceros" + DELIM: "🦏", DELIM + r"hippopotamus" + DELIM: "πŸ¦›", DELIM + r"mouse" + DELIM: "🐭", DELIM + r"mouse2" + DELIM: "🐁", DELIM + r"rat" + DELIM: "πŸ€", DELIM + r"hamster" + DELIM: "🐹", DELIM + r"rabbit" + DELIM: "🐰", DELIM + r"rabbit2" + DELIM: "πŸ‡", DELIM + r"chipmunk" + DELIM: "🐿️", DELIM + r"beaver" + DELIM: "🦫", DELIM + r"hedgehog" + DELIM: "πŸ¦”", DELIM + r"bat" + DELIM: "πŸ¦‡", DELIM + r"bear" + DELIM: "🐻", DELIM + r"polar_bear" + DELIM: "πŸ»β€β„οΈ", DELIM + r"koala" + DELIM: "🐨", DELIM + r"panda_face" + DELIM: "🐼", DELIM + r"sloth" + DELIM: "πŸ¦₯", DELIM + r"otter" + DELIM: "🦦", DELIM + r"skunk" + DELIM: "🦨", DELIM + r"kangaroo" + DELIM: "🦘", DELIM + r"badger" + DELIM: "🦑", DELIM + r"(feet|paw_prints)" + DELIM: "🐾", # # Animal Bird # DELIM + r"turkey" + DELIM: "πŸ¦ƒ", DELIM + r"chicken" + DELIM: "πŸ”", DELIM + r"rooster" + DELIM: "πŸ“", DELIM + r"hatching_chick" + DELIM: "🐣", DELIM + r"baby_chick" + DELIM: "🐀", DELIM + r"hatched_chick" + DELIM: "πŸ₯", DELIM + r"bird" + DELIM: "🐦", DELIM + r"penguin" + DELIM: "🐧", DELIM + r"dove" + DELIM: "πŸ•ŠοΈ", DELIM + r"eagle" + DELIM: "πŸ¦…", DELIM + r"duck" + DELIM: "πŸ¦†", DELIM + r"swan" + DELIM: "🦒", DELIM + r"owl" + DELIM: "πŸ¦‰", DELIM + r"dodo" + DELIM: "🦀", DELIM + r"feather" + DELIM: "πŸͺΆ", DELIM + r"flamingo" + DELIM: "🦩", DELIM + r"peacock" + DELIM: "🦚", DELIM + r"parrot" + DELIM: "🦜", # # Animal Amphibian # DELIM + r"frog" + DELIM: "🐸", # # Animal Reptile # DELIM + r"crocodile" + DELIM: "🐊", DELIM + r"turtle" + DELIM: "🐒", DELIM + r"lizard" + DELIM: "🦎", DELIM + r"snake" + DELIM: "🐍", DELIM + r"dragon_face" + DELIM: "🐲", DELIM + r"dragon" + DELIM: "πŸ‰", DELIM + r"sauropod" + DELIM: "πŸ¦•", DELIM + r"t-rex" + DELIM: "πŸ¦–", # # Animal Marine # DELIM + r"whale" + DELIM: "🐳", DELIM + r"whale2" + DELIM: "πŸ‹", DELIM + r"dolphin" + DELIM: "🐬", DELIM + r"(seal|flipper)" + DELIM: "🦭", DELIM + r"fish" + DELIM: "🐟", DELIM + r"tropical_fish" + DELIM: "🐠", DELIM + r"blowfish" + DELIM: "🐑", DELIM + r"shark" + DELIM: "🦈", DELIM + r"octopus" + DELIM: "πŸ™", DELIM + r"shell" + DELIM: "🐚", # # Animal Bug # DELIM + r"snail" + DELIM: "🐌", DELIM + r"butterfly" + DELIM: "πŸ¦‹", DELIM + r"bug" + DELIM: "πŸ›", DELIM + r"ant" + DELIM: "🐜", DELIM + r"bee" + DELIM: "🐝", DELIM + r"honeybee" + DELIM: "πŸͺ²", DELIM + r"(lady_)?beetle" + DELIM: "🐞", DELIM + r"cricket" + DELIM: "πŸ¦—", DELIM + r"cockroach" + DELIM: "πŸͺ³", DELIM + r"spider" + DELIM: "πŸ•·οΈ", DELIM + r"spider_web" + DELIM: "πŸ•ΈοΈ", DELIM + r"scorpion" + DELIM: "πŸ¦‚", DELIM + r"mosquito" + DELIM: "🦟", DELIM + r"fly" + DELIM: "πŸͺ°", DELIM + r"worm" + DELIM: "πŸͺ±", DELIM + r"microbe" + DELIM: "🦠", # # Plant Flower # DELIM + r"bouquet" + DELIM: "πŸ’", DELIM + r"cherry_blossom" + DELIM: "🌸", DELIM + r"white_flower" + DELIM: "πŸ’", DELIM + r"rosette" + DELIM: "🏡️", DELIM + r"rose" + DELIM: "🌹", DELIM + r"wilted_flower" + DELIM: "πŸ₯€", DELIM + r"hibiscus" + DELIM: "🌺", DELIM + r"sunflower" + DELIM: "🌻", DELIM + r"blossom" + DELIM: "🌼", DELIM + r"tulip" + DELIM: "🌷", # # Plant Other # DELIM + r"seedling" + DELIM: "🌱", DELIM + r"potted_plant" + DELIM: "πŸͺ΄", DELIM + r"evergreen_tree" + DELIM: "🌲", DELIM + r"deciduous_tree" + DELIM: "🌳", DELIM + r"palm_tree" + DELIM: "🌴", DELIM + r"cactus" + DELIM: "🌡", DELIM + r"ear_of_rice" + DELIM: "🌾", DELIM + r"herb" + DELIM: "🌿", DELIM + r"shamrock" + DELIM: "☘️", DELIM + r"four_leaf_clover" + DELIM: "πŸ€", DELIM + r"maple_leaf" + DELIM: "🍁", DELIM + r"fallen_leaf" + DELIM: "πŸ‚", DELIM + r"leaves" + DELIM: "πŸƒ", DELIM + r"mushroom" + DELIM: "πŸ„", # # Food Fruit # DELIM + r"grapes" + DELIM: "πŸ‡", DELIM + r"melon" + DELIM: "🍈", DELIM + r"watermelon" + DELIM: "πŸ‰", DELIM + r"(orange|mandarin|tangerine)" + DELIM: "🍊", DELIM + r"lemon" + DELIM: "πŸ‹", DELIM + r"banana" + DELIM: "🍌", DELIM + r"pineapple" + DELIM: "🍍", DELIM + r"mango" + DELIM: "πŸ₯­", DELIM + r"apple" + DELIM: "🍎", DELIM + r"green_apple" + DELIM: "🍏", DELIM + r"pear" + DELIM: "🍐", DELIM + r"peach" + DELIM: "πŸ‘", DELIM + r"cherries" + DELIM: "πŸ’", DELIM + r"strawberry" + DELIM: "πŸ“", DELIM + r"blueberries" + DELIM: "🫐", DELIM + r"kiwi_fruit" + DELIM: "πŸ₯", DELIM + r"tomato" + DELIM: "πŸ…", DELIM + r"olive" + DELIM: "πŸ«’", DELIM + r"coconut" + DELIM: "πŸ₯₯", # # Food Vegetable # DELIM + r"avocado" + DELIM: "πŸ₯‘", DELIM + r"eggplant" + DELIM: "πŸ†", DELIM + r"potato" + DELIM: "πŸ₯”", DELIM + r"carrot" + DELIM: "πŸ₯•", DELIM + r"corn" + DELIM: "🌽", DELIM + r"hot_pepper" + DELIM: "🌢️", DELIM + r"bell_pepper" + DELIM: "πŸ«‘", DELIM + r"cucumber" + DELIM: "πŸ₯’", DELIM + r"leafy_green" + DELIM: "πŸ₯¬", DELIM + r"broccoli" + DELIM: "πŸ₯¦", DELIM + r"garlic" + DELIM: "πŸ§„", DELIM + r"onion" + DELIM: "πŸ§…", DELIM + r"peanuts" + DELIM: "πŸ₯œ", DELIM + r"chestnut" + DELIM: "🌰", # # Food Prepared # DELIM + r"bread" + DELIM: "🍞", DELIM + r"croissant" + DELIM: "πŸ₯", DELIM + r"baguette_bread" + DELIM: "πŸ₯–", DELIM + r"flatbread" + DELIM: "πŸ«“", DELIM + r"pretzel" + DELIM: "πŸ₯¨", DELIM + r"bagel" + DELIM: "πŸ₯―", DELIM + r"pancakes" + DELIM: "πŸ₯ž", DELIM + r"waffle" + DELIM: "πŸ§‡", DELIM + r"cheese" + DELIM: "πŸ§€", DELIM + r"meat_on_bone" + DELIM: "πŸ–", DELIM + r"poultry_leg" + DELIM: "πŸ—", DELIM + r"cut_of_meat" + DELIM: "πŸ₯©", DELIM + r"bacon" + DELIM: "πŸ₯“", DELIM + r"hamburger" + DELIM: "πŸ”", DELIM + r"fries" + DELIM: "🍟", DELIM + r"pizza" + DELIM: "πŸ•", DELIM + r"hotdog" + DELIM: "🌭", DELIM + r"sandwich" + DELIM: "πŸ₯ͺ", DELIM + r"taco" + DELIM: "πŸŒ", DELIM + r"burrito" + DELIM: "🌯", DELIM + r"tamale" + DELIM: "πŸ«”", DELIM + r"stuffed_flatbread" + DELIM: "πŸ₯™", DELIM + r"falafel" + DELIM: "πŸ§†", DELIM + r"egg" + DELIM: "πŸ₯š", DELIM + r"fried_egg" + DELIM: "🍳", DELIM + r"shallow_pan_of_food" + DELIM: "πŸ₯˜", DELIM + r"stew" + DELIM: "🍲", DELIM + r"fondue" + DELIM: "πŸ«•", DELIM + r"bowl_with_spoon" + DELIM: "πŸ₯£", DELIM + r"green_salad" + DELIM: "πŸ₯—", DELIM + r"popcorn" + DELIM: "🍿", DELIM + r"butter" + DELIM: "🧈", DELIM + r"salt" + DELIM: "πŸ§‚", DELIM + r"canned_food" + DELIM: "πŸ₯«", # # Food Asian # DELIM + r"bento" + DELIM: "🍱", DELIM + r"rice_cracker" + DELIM: "🍘", DELIM + r"rice_ball" + DELIM: "πŸ™", DELIM + r"rice" + DELIM: "🍚", DELIM + r"curry" + DELIM: "πŸ›", DELIM + r"ramen" + DELIM: "🍜", DELIM + r"spaghetti" + DELIM: "🍝", DELIM + r"sweet_potato" + DELIM: "🍠", DELIM + r"oden" + DELIM: "🍒", DELIM + r"sushi" + DELIM: "🍣", DELIM + r"fried_shrimp" + DELIM: "🍀", DELIM + r"fish_cake" + DELIM: "πŸ₯", DELIM + r"moon_cake" + DELIM: "πŸ₯", DELIM + r"dango" + DELIM: "🍑", DELIM + r"dumpling" + DELIM: "πŸ₯Ÿ", DELIM + r"fortune_cookie" + DELIM: "πŸ₯ ", DELIM + r"takeout_box" + DELIM: "πŸ₯‘", # # Food Marine # DELIM + r"crab" + DELIM: "πŸ¦€", DELIM + r"lobster" + DELIM: "🦞", DELIM + r"shrimp" + DELIM: "🦐", DELIM + r"squid" + DELIM: "πŸ¦‘", DELIM + r"oyster" + DELIM: "πŸ¦ͺ", # # Food Sweet # DELIM + r"icecream" + DELIM: "🍦", DELIM + r"shaved_ice" + DELIM: "🍧", DELIM + r"ice_cream" + DELIM: "🍨", DELIM + r"doughnut" + DELIM: "🍩", DELIM + r"cookie" + DELIM: "πŸͺ", DELIM + r"birthday" + DELIM: "πŸŽ‚", DELIM + r"cake" + DELIM: "🍰", DELIM + r"cupcake" + DELIM: "🧁", DELIM + r"pie" + DELIM: "πŸ₯§", DELIM + r"chocolate_bar" + DELIM: "🍫", DELIM + r"candy" + DELIM: "🍬", DELIM + r"lollipop" + DELIM: "🍭", DELIM + r"custard" + DELIM: "πŸ", DELIM + r"honey_pot" + DELIM: "🍯", # # Drink # DELIM + r"baby_bottle" + DELIM: "🍼", DELIM + r"milk_glass" + DELIM: "πŸ₯›", DELIM + r"coffee" + DELIM: "β˜•", DELIM + r"teapot" + DELIM: "πŸ«–", DELIM + r"tea" + DELIM: "🍡", DELIM + r"sake" + DELIM: "🍢", DELIM + r"champagne" + DELIM: "🍾", DELIM + r"wine_glass" + DELIM: "🍷", DELIM + r"cocktail" + DELIM: "🍸", DELIM + r"tropical_drink" + DELIM: "🍹", DELIM + r"beer" + DELIM: "🍺", DELIM + r"beers" + DELIM: "🍻", DELIM + r"clinking_glasses" + DELIM: "πŸ₯‚", DELIM + r"tumbler_glass" + DELIM: "πŸ₯ƒ", DELIM + r"cup_with_straw" + DELIM: "πŸ₯€", DELIM + r"bubble_tea" + DELIM: "πŸ§‹", DELIM + r"beverage_box" + DELIM: "πŸ§ƒ", DELIM + r"mate" + DELIM: "πŸ§‰", DELIM + r"ice_cube" + DELIM: "🧊", # # Dishware # DELIM + r"chopsticks" + DELIM: "πŸ₯’", DELIM + r"plate_with_cutlery" + DELIM: "🍽️", DELIM + r"fork_and_knife" + DELIM: "🍴", DELIM + r"spoon" + DELIM: "πŸ₯„", DELIM + r"(hocho|knife)" + DELIM: "πŸ”ͺ", DELIM + r"amphora" + DELIM: "🏺", # # Place Map # DELIM + r"earth_africa" + DELIM: "🌍", DELIM + r"earth_americas" + DELIM: "🌎", DELIM + r"earth_asia" + DELIM: "🌏", DELIM + r"globe_with_meridians" + DELIM: "🌐", DELIM + r"world_map" + DELIM: "πŸ—ΊοΈ", DELIM + r"japan" + DELIM: "πŸ—Ύ", DELIM + r"compass" + DELIM: "🧭", # # Place Geographic # DELIM + r"mountain_snow" + DELIM: "πŸ”οΈ", DELIM + r"mountain" + DELIM: "⛰️", DELIM + r"volcano" + DELIM: "πŸŒ‹", DELIM + r"mount_fuji" + DELIM: "πŸ—»", DELIM + r"camping" + DELIM: "πŸ•οΈ", DELIM + r"beach_umbrella" + DELIM: "πŸ–οΈ", DELIM + r"desert" + DELIM: "🏜️", DELIM + r"desert_island" + DELIM: "🏝️", DELIM + r"national_park" + DELIM: "🏞️", # # Place Building # DELIM + r"stadium" + DELIM: "🏟️", DELIM + r"classical_building" + DELIM: "πŸ›οΈ", DELIM + r"building_construction" + DELIM: "πŸ—οΈ", DELIM + r"bricks" + DELIM: "🧱", DELIM + r"rock" + DELIM: "πŸͺ¨", DELIM + r"wood" + DELIM: "πŸͺ΅", DELIM + r"hut" + DELIM: "πŸ›–", DELIM + r"houses" + DELIM: "🏘️", DELIM + r"derelict_house" + DELIM: "🏚️", DELIM + r"house" + DELIM: "🏠", DELIM + r"house_with_garden" + DELIM: "🏑", DELIM + r"office" + DELIM: "🏒", DELIM + r"post_office" + DELIM: "🏣", DELIM + r"european_post_office" + DELIM: "🏀", DELIM + r"hospital" + DELIM: "πŸ₯", DELIM + r"bank" + DELIM: "🏦", DELIM + r"hotel" + DELIM: "🏨", DELIM + r"love_hotel" + DELIM: "🏩", DELIM + r"convenience_store" + DELIM: "πŸͺ", DELIM + r"school" + DELIM: "🏫", DELIM + r"department_store" + DELIM: "🏬", DELIM + r"factory" + DELIM: "🏭", DELIM + r"japanese_castle" + DELIM: "🏯", DELIM + r"european_castle" + DELIM: "🏰", DELIM + r"wedding" + DELIM: "πŸ’’", DELIM + r"tokyo_tower" + DELIM: "πŸ—Ό", DELIM + r"statue_of_liberty" + DELIM: "πŸ—½", # # Place Religious # DELIM + r"church" + DELIM: "β›ͺ", DELIM + r"mosque" + DELIM: "πŸ•Œ", DELIM + r"hindu_temple" + DELIM: "πŸ›•", DELIM + r"synagogue" + DELIM: "πŸ•", DELIM + r"shinto_shrine" + DELIM: "⛩️", DELIM + r"kaaba" + DELIM: "πŸ•‹", # # Place Other # DELIM + r"fountain" + DELIM: "β›²", DELIM + r"tent" + DELIM: "β›Ί", DELIM + r"foggy" + DELIM: "🌁", DELIM + r"night_with_stars" + DELIM: "πŸŒƒ", DELIM + r"cityscape" + DELIM: "πŸ™οΈ", DELIM + r"sunrise_over_mountains" + DELIM: "πŸŒ„", DELIM + r"sunrise" + DELIM: "πŸŒ…", DELIM + r"city_sunset" + DELIM: "πŸŒ†", DELIM + r"city_sunrise" + DELIM: "πŸŒ‡", DELIM + r"bridge_at_night" + DELIM: "πŸŒ‰", DELIM + r"hotsprings" + DELIM: "♨️", DELIM + r"carousel_horse" + DELIM: "🎠", DELIM + r"ferris_wheel" + DELIM: "🎑", DELIM + r"roller_coaster" + DELIM: "🎒", DELIM + r"barber" + DELIM: "πŸ’ˆ", DELIM + r"circus_tent" + DELIM: "πŸŽͺ", # # Transport Ground # DELIM + r"steam_locomotive" + DELIM: "πŸš‚", DELIM + r"railway_car" + DELIM: "πŸšƒ", DELIM + r"bullettrain_side" + DELIM: "πŸš„", DELIM + r"bullettrain_front" + DELIM: "πŸš…", DELIM + r"train2" + DELIM: "πŸš†", DELIM + r"metro" + DELIM: "πŸš‡", DELIM + r"light_rail" + DELIM: "🚈", DELIM + r"station" + DELIM: "πŸš‰", DELIM + r"tram" + DELIM: "🚊", DELIM + r"monorail" + DELIM: "🚝", DELIM + r"mountain_railway" + DELIM: "🚞", DELIM + r"train" + DELIM: "πŸš‹", DELIM + r"bus" + DELIM: "🚌", DELIM + r"oncoming_bus" + DELIM: "🚍", DELIM + r"trolleybus" + DELIM: "🚎", DELIM + r"minibus" + DELIM: "🚐", DELIM + r"ambulance" + DELIM: "πŸš‘", DELIM + r"fire_engine" + DELIM: "πŸš’", DELIM + r"police_car" + DELIM: "πŸš“", DELIM + r"oncoming_police_car" + DELIM: "πŸš”", DELIM + r"taxi" + DELIM: "πŸš•", DELIM + r"oncoming_taxi" + DELIM: "πŸš–", DELIM + r"car" + DELIM: "πŸš—", DELIM + r"(red_car|oncoming_automobile)" + DELIM: "🚘", DELIM + r"blue_car" + DELIM: "πŸš™", DELIM + r"pickup_truck" + DELIM: "πŸ›»", DELIM + r"truck" + DELIM: "🚚", DELIM + r"articulated_lorry" + DELIM: "πŸš›", DELIM + r"tractor" + DELIM: "🚜", DELIM + r"racing_car" + DELIM: "🏎️", DELIM + r"motorcycle" + DELIM: "🏍️", DELIM + r"motor_scooter" + DELIM: "πŸ›΅", DELIM + r"manual_wheelchair" + DELIM: "🦽", DELIM + r"motorized_wheelchair" + DELIM: "🦼", DELIM + r"auto_rickshaw" + DELIM: "πŸ›Ί", DELIM + r"bike" + DELIM: "🚲", DELIM + r"kick_scooter" + DELIM: "πŸ›΄", DELIM + r"skateboard" + DELIM: "πŸ›Ή", DELIM + r"roller_skate" + DELIM: "πŸ›Ό", DELIM + r"busstop" + DELIM: "🚏", DELIM + r"motorway" + DELIM: "πŸ›£οΈ", DELIM + r"railway_track" + DELIM: "πŸ›€οΈ", DELIM + r"oil_drum" + DELIM: "πŸ›’οΈ", DELIM + r"fuelpump" + DELIM: "β›½", DELIM + r"rotating_light" + DELIM: "🚨", DELIM + r"traffic_light" + DELIM: "πŸš₯", DELIM + r"vertical_traffic_light" + DELIM: "🚦", DELIM + r"stop_sign" + DELIM: "πŸ›‘", DELIM + r"construction" + DELIM: "🚧", # # Transport Water # DELIM + r"anchor" + DELIM: "βš“", DELIM + r"(sailboat|boat)" + DELIM: "β›΅", DELIM + r"canoe" + DELIM: "πŸ›Ά", DELIM + r"speedboat" + DELIM: "🚀", DELIM + r"passenger_ship" + DELIM: "πŸ›³οΈ", DELIM + r"ferry" + DELIM: "⛴️", DELIM + r"motor_boat" + DELIM: "πŸ›₯️", DELIM + r"ship" + DELIM: "🚒", # # Transport Air # DELIM + r"airplane" + DELIM: "✈️", DELIM + r"small_airplane" + DELIM: "πŸ›©οΈ", DELIM + r"flight_departure" + DELIM: "πŸ›«", DELIM + r"flight_arrival" + DELIM: "πŸ›¬", DELIM + r"parachute" + DELIM: "πŸͺ‚", DELIM + r"seat" + DELIM: "πŸ’Ί", DELIM + r"helicopter" + DELIM: "🚁", DELIM + r"suspension_railway" + DELIM: "🚟", DELIM + r"mountain_cableway" + DELIM: "🚠", DELIM + r"aerial_tramway" + DELIM: "🚑", DELIM + r"artificial_satellite" + DELIM: "πŸ›°οΈ", DELIM + r"rocket" + DELIM: "πŸš€", DELIM + r"flying_saucer" + DELIM: "πŸ›Έ", # # Hotel # DELIM + r"bellhop_bell" + DELIM: "πŸ›ŽοΈ", DELIM + r"luggage" + DELIM: "🧳", # # Time # DELIM + r"hourglass" + DELIM: "βŒ›", DELIM + r"hourglass_flowing_sand" + DELIM: "⏳", DELIM + r"watch" + DELIM: "⌚", DELIM + r"alarm_clock" + DELIM: "⏰", DELIM + r"stopwatch" + DELIM: "⏱️", DELIM + r"timer_clock" + DELIM: "⏲️", DELIM + r"mantelpiece_clock" + DELIM: "πŸ•°οΈ", DELIM + r"clock12" + DELIM: "πŸ•›", DELIM + r"clock1230" + DELIM: "πŸ•§", DELIM + r"clock1" + DELIM: "πŸ•", DELIM + r"clock130" + DELIM: "πŸ•œ", DELIM + r"clock2" + DELIM: "πŸ•‘", DELIM + r"clock230" + DELIM: "πŸ•", DELIM + r"clock3" + DELIM: "πŸ•’", DELIM + r"clock330" + DELIM: "πŸ•ž", DELIM + r"clock4" + DELIM: "πŸ•“", DELIM + r"clock430" + DELIM: "πŸ•Ÿ", DELIM + r"clock5" + DELIM: "πŸ•”", DELIM + r"clock530" + DELIM: "πŸ• ", DELIM + r"clock6" + DELIM: "πŸ••", DELIM + r"clock630" + DELIM: "πŸ•‘", DELIM + r"clock7" + DELIM: "πŸ•–", DELIM + r"clock730" + DELIM: "πŸ•’", DELIM + r"clock8" + DELIM: "πŸ•—", DELIM + r"clock830" + DELIM: "πŸ•£", DELIM + r"clock9" + DELIM: "πŸ•˜", DELIM + r"clock930" + DELIM: "πŸ•€", DELIM + r"clock10" + DELIM: "πŸ•™", DELIM + r"clock1030" + DELIM: "πŸ•₯", DELIM + r"clock11" + DELIM: "πŸ•š", DELIM + r"clock1130" + DELIM: "πŸ•¦", # Sky & Weather DELIM + r"new_moon" + DELIM: "πŸŒ‘", DELIM + r"waxing_crescent_moon" + DELIM: "πŸŒ’", DELIM + r"first_quarter_moon" + DELIM: "πŸŒ“", DELIM + r"moon" + DELIM: "πŸŒ”", DELIM + r"(waxing_gibbous_moon|full_moon)" + DELIM: "πŸŒ•", DELIM + r"waning_gibbous_moon" + DELIM: "πŸŒ–", DELIM + r"last_quarter_moon" + DELIM: "πŸŒ—", DELIM + r"waning_crescent_moon" + DELIM: "🌘", DELIM + r"crescent_moon" + DELIM: "πŸŒ™", DELIM + r"new_moon_with_face" + DELIM: "🌚", DELIM + r"first_quarter_moon_with_face" + DELIM: "πŸŒ›", DELIM + r"last_quarter_moon_with_face" + DELIM: "🌜", DELIM + r"thermometer" + DELIM: "🌑️", DELIM + r"sunny" + DELIM: "β˜€οΈ", DELIM + r"full_moon_with_face" + DELIM: "🌝", DELIM + r"sun_with_face" + DELIM: "🌞", DELIM + r"ringed_planet" + DELIM: "πŸͺ", DELIM + r"star" + DELIM: "⭐", DELIM + r"star2" + DELIM: "🌟", DELIM + r"stars" + DELIM: "🌠", DELIM + r"milky_way" + DELIM: "🌌", DELIM + r"cloud" + DELIM: "☁️", DELIM + r"partly_sunny" + DELIM: "β›…", DELIM + r"cloud_with_lightning_and_rain" + DELIM: "β›ˆοΈ", DELIM + r"sun_behind_small_cloud" + DELIM: "🌀️", DELIM + r"sun_behind_large_cloud" + DELIM: "πŸŒ₯️", DELIM + r"sun_behind_rain_cloud" + DELIM: "🌦️", DELIM + r"cloud_with_rain" + DELIM: "🌧️", DELIM + r"cloud_with_snow" + DELIM: "🌨️", DELIM + r"cloud_with_lightning" + DELIM: "🌩️", DELIM + r"tornado" + DELIM: "πŸŒͺ️", DELIM + r"fog" + DELIM: "🌫️", DELIM + r"wind_face" + DELIM: "🌬️", DELIM + r"cyclone" + DELIM: "πŸŒ€", DELIM + r"rainbow" + DELIM: "🌈", DELIM + r"closed_umbrella" + DELIM: "πŸŒ‚", DELIM + r"open_umbrella" + DELIM: "β˜‚οΈ", DELIM + r"umbrella" + DELIM: "β˜”", DELIM + r"parasol_on_ground" + DELIM: "⛱️", DELIM + r"zap" + DELIM: "⚑", DELIM + r"snowflake" + DELIM: "❄️", DELIM + r"snowman_with_snow" + DELIM: "β˜ƒοΈ", DELIM + r"snowman" + DELIM: "β›„", DELIM + r"comet" + DELIM: "β˜„οΈ", DELIM + r"fire" + DELIM: "πŸ”₯", DELIM + r"droplet" + DELIM: "πŸ’§", DELIM + r"ocean" + DELIM: "🌊", # # Event # DELIM + r"jack_o_lantern" + DELIM: "πŸŽƒ", DELIM + r"christmas_tree" + DELIM: "πŸŽ„", DELIM + r"fireworks" + DELIM: "πŸŽ†", DELIM + r"sparkler" + DELIM: "πŸŽ‡", DELIM + r"firecracker" + DELIM: "🧨", DELIM + r"sparkles" + DELIM: "✨", DELIM + r"balloon" + DELIM: "🎈", DELIM + r"tada" + DELIM: "πŸŽ‰", DELIM + r"confetti_ball" + DELIM: "🎊", DELIM + r"tanabata_tree" + DELIM: "πŸŽ‹", DELIM + r"bamboo" + DELIM: "🎍", DELIM + r"dolls" + DELIM: "🎎", DELIM + r"flags" + DELIM: "🎏", DELIM + r"wind_chime" + DELIM: "🎐", DELIM + r"rice_scene" + DELIM: "πŸŽ‘", DELIM + r"red_envelope" + DELIM: "🧧", DELIM + r"ribbon" + DELIM: "πŸŽ€", DELIM + r"gift" + DELIM: "🎁", DELIM + r"reminder_ribbon" + DELIM: "πŸŽ—οΈ", DELIM + r"tickets" + DELIM: "🎟️", DELIM + r"ticket" + DELIM: "🎫", # # Award Medal # DELIM + r"medal_military" + DELIM: "πŸŽ–οΈ", DELIM + r"trophy" + DELIM: "πŸ†", DELIM + r"medal_sports" + DELIM: "πŸ…", DELIM + r"1st_place_medal" + DELIM: "πŸ₯‡", DELIM + r"2nd_place_medal" + DELIM: "πŸ₯ˆ", DELIM + r"3rd_place_medal" + DELIM: "πŸ₯‰", # # Sport # DELIM + r"soccer" + DELIM: "⚽", DELIM + r"baseball" + DELIM: "⚾", DELIM + r"softball" + DELIM: "πŸ₯Ž", DELIM + r"basketball" + DELIM: "πŸ€", DELIM + r"volleyball" + DELIM: "🏐", DELIM + r"football" + DELIM: "🏈", DELIM + r"rugby_football" + DELIM: "πŸ‰", DELIM + r"tennis" + DELIM: "🎾", DELIM + r"flying_disc" + DELIM: "πŸ₯", DELIM + r"bowling" + DELIM: "🎳", DELIM + r"cricket_game" + DELIM: "🏏", DELIM + r"field_hockey" + DELIM: "πŸ‘", DELIM + r"ice_hockey" + DELIM: "πŸ’", DELIM + r"lacrosse" + DELIM: "πŸ₯", DELIM + r"ping_pong" + DELIM: "πŸ“", DELIM + r"badminton" + DELIM: "🏸", DELIM + r"boxing_glove" + DELIM: "πŸ₯Š", DELIM + r"martial_arts_uniform" + DELIM: "πŸ₯‹", DELIM + r"goal_net" + DELIM: "πŸ₯…", DELIM + r"golf" + DELIM: "β›³", DELIM + r"ice_skate" + DELIM: "⛸️", DELIM + r"fishing_pole_and_fish" + DELIM: "🎣", DELIM + r"diving_mask" + DELIM: "🀿", DELIM + r"running_shirt_with_sash" + DELIM: "🎽", DELIM + r"ski" + DELIM: "🎿", DELIM + r"sled" + DELIM: "πŸ›·", DELIM + r"curling_stone" + DELIM: "πŸ₯Œ", # # Game # DELIM + r"dart" + DELIM: "🎯", DELIM + r"yo_yo" + DELIM: "πŸͺ€", DELIM + r"kite" + DELIM: "πŸͺ", DELIM + r"gun" + DELIM: "πŸ”«", DELIM + r"8ball" + DELIM: "🎱", DELIM + r"crystal_ball" + DELIM: "πŸ”", DELIM + r"magic_wand" + DELIM: "πŸͺ„", DELIM + r"video_game" + DELIM: "πŸŽ", DELIM + r"joystick" + DELIM: "πŸ•ΉοΈ", DELIM + r"slot_machine" + DELIM: "🎰", DELIM + r"game_die" + DELIM: "🎲", DELIM + r"jigsaw" + DELIM: "🧩", DELIM + r"teddy_bear" + DELIM: "🧸", DELIM + r"pinata" + DELIM: "πŸͺ…", DELIM + r"nesting_dolls" + DELIM: "πŸͺ†", DELIM + r"spades" + DELIM: "♠️", DELIM + r"hearts" + DELIM: "β™₯️", DELIM + r"diamonds" + DELIM: "♦️", DELIM + r"clubs" + DELIM: "♣️", DELIM + r"chess_pawn" + DELIM: "β™ŸοΈ", DELIM + r"black_joker" + DELIM: "πŸƒ", DELIM + r"mahjong" + DELIM: "πŸ€„", DELIM + r"flower_playing_cards" + DELIM: "🎴", # # Arts & Crafts # DELIM + r"performing_arts" + DELIM: "🎭", DELIM + r"framed_picture" + DELIM: "πŸ–ΌοΈ", DELIM + r"art" + DELIM: "🎨", DELIM + r"thread" + DELIM: "🧡", DELIM + r"sewing_needle" + DELIM: "πŸͺ‘", DELIM + r"yarn" + DELIM: "🧢", DELIM + r"knot" + DELIM: "πŸͺ’", # # Clothing # DELIM + r"eyeglasses" + DELIM: "πŸ‘“", DELIM + r"dark_sunglasses" + DELIM: "πŸ•ΆοΈ", DELIM + r"goggles" + DELIM: "πŸ₯½", DELIM + r"lab_coat" + DELIM: "πŸ₯Ό", DELIM + r"safety_vest" + DELIM: "🦺", DELIM + r"necktie" + DELIM: "πŸ‘”", DELIM + r"t?shirt" + DELIM: "πŸ‘•", DELIM + r"jeans" + DELIM: "πŸ‘–", DELIM + r"scarf" + DELIM: "🧣", DELIM + r"gloves" + DELIM: "🧀", DELIM + r"coat" + DELIM: "πŸ§₯", DELIM + r"socks" + DELIM: "🧦", DELIM + r"dress" + DELIM: "πŸ‘—", DELIM + r"kimono" + DELIM: "πŸ‘˜", DELIM + r"sari" + DELIM: "πŸ₯»", DELIM + r"one_piece_swimsuit" + DELIM: "🩱", DELIM + r"swim_brief" + DELIM: "🩲", DELIM + r"shorts" + DELIM: "🩳", DELIM + r"bikini" + DELIM: "πŸ‘™", DELIM + r"womans_clothes" + DELIM: "πŸ‘š", DELIM + r"purse" + DELIM: "πŸ‘›", DELIM + r"handbag" + DELIM: "πŸ‘œ", DELIM + r"pouch" + DELIM: "πŸ‘", DELIM + r"shopping" + DELIM: "πŸ›οΈ", DELIM + r"school_satchel" + DELIM: "πŸŽ’", DELIM + r"thong_sandal" + DELIM: "🩴", DELIM + r"(mans_)?shoe" + DELIM: "πŸ‘ž", DELIM + r"athletic_shoe" + DELIM: "πŸ‘Ÿ", DELIM + r"hiking_boot" + DELIM: "πŸ₯Ύ", DELIM + r"flat_shoe" + DELIM: "πŸ₯Ώ", DELIM + r"high_heel" + DELIM: "πŸ‘ ", DELIM + r"sandal" + DELIM: "πŸ‘‘", DELIM + r"ballet_shoes" + DELIM: "🩰", DELIM + r"boot" + DELIM: "πŸ‘’", DELIM + r"crown" + DELIM: "πŸ‘‘", DELIM + r"womans_hat" + DELIM: "πŸ‘’", DELIM + r"tophat" + DELIM: "🎩", DELIM + r"mortar_board" + DELIM: "πŸŽ“", DELIM + r"billed_cap" + DELIM: "🧒", DELIM + r"military_helmet" + DELIM: "πŸͺ–", DELIM + r"rescue_worker_helmet" + DELIM: "⛑️", DELIM + r"prayer_beads" + DELIM: "πŸ“Ώ", DELIM + r"lipstick" + DELIM: "πŸ’„", DELIM + r"ring" + DELIM: "πŸ’", DELIM + r"gem" + DELIM: "πŸ’Ž", # # Sound # DELIM + r"mute" + DELIM: "πŸ”‡", DELIM + r"speaker" + DELIM: "πŸ”ˆ", DELIM + r"sound" + DELIM: "πŸ”‰", DELIM + r"loud_sound" + DELIM: "πŸ”Š", DELIM + r"loudspeaker" + DELIM: "πŸ“’", DELIM + r"mega" + DELIM: "πŸ“£", DELIM + r"postal_horn" + DELIM: "πŸ“―", DELIM + r"bell" + DELIM: "πŸ””", DELIM + r"no_bell" + DELIM: "πŸ”•", # # Music # DELIM + r"musical_score" + DELIM: "🎼", DELIM + r"musical_note" + DELIM: "🎡", DELIM + r"notes" + DELIM: "🎢", DELIM + r"studio_microphone" + DELIM: "πŸŽ™οΈ", DELIM + r"level_slider" + DELIM: "🎚️", DELIM + r"control_knobs" + DELIM: "πŸŽ›οΈ", DELIM + r"microphone" + DELIM: "🎀", DELIM + r"headphones" + DELIM: "🎧", DELIM + r"radio" + DELIM: "πŸ“»", # # Musical Instrument # DELIM + r"saxophone" + DELIM: "🎷", DELIM + r"accordion" + DELIM: "πŸͺ—", DELIM + r"guitar" + DELIM: "🎸", DELIM + r"musical_keyboard" + DELIM: "🎹", DELIM + r"trumpet" + DELIM: "🎺", DELIM + r"violin" + DELIM: "🎻", DELIM + r"banjo" + DELIM: "πŸͺ•", DELIM + r"drum" + DELIM: "πŸ₯", DELIM + r"long_drum" + DELIM: "πŸͺ˜", # # Phone # DELIM + r"iphone" + DELIM: "πŸ“±", DELIM + r"calling" + DELIM: "πŸ“²", DELIM + r"phone" + DELIM: "☎️", DELIM + r"telephone(_receiver)?" + DELIM: "πŸ“ž", DELIM + r"pager" + DELIM: "πŸ“Ÿ", DELIM + r"fax" + DELIM: "πŸ“ ", # # Computer # DELIM + r"battery" + DELIM: "πŸ”‹", DELIM + r"electric_plug" + DELIM: "πŸ”Œ", DELIM + r"computer" + DELIM: "πŸ’»", DELIM + r"desktop_computer" + DELIM: "πŸ–₯️", DELIM + r"printer" + DELIM: "πŸ–¨οΈ", DELIM + r"keyboard" + DELIM: "⌨️", DELIM + r"computer_mouse" + DELIM: "πŸ–±οΈ", DELIM + r"trackball" + DELIM: "πŸ–²οΈ", DELIM + r"minidisc" + DELIM: "πŸ’½", DELIM + r"floppy_disk" + DELIM: "πŸ’Ύ", DELIM + r"cd" + DELIM: "πŸ’Ώ", DELIM + r"dvd" + DELIM: "πŸ“€", DELIM + r"abacus" + DELIM: "πŸ§", # # Light & Video # DELIM + r"movie_camera" + DELIM: "πŸŽ₯", DELIM + r"film_strip" + DELIM: "🎞️", DELIM + r"film_projector" + DELIM: "πŸ“½οΈ", DELIM + r"clapper" + DELIM: "🎬", DELIM + r"tv" + DELIM: "πŸ“Ί", DELIM + r"camera" + DELIM: "πŸ“·", DELIM + r"camera_flash" + DELIM: "πŸ“Έ", DELIM + r"video_camera" + DELIM: "πŸ“Ή", DELIM + r"vhs" + DELIM: "πŸ“Ό", DELIM + r"mag" + DELIM: "πŸ”", DELIM + r"mag_right" + DELIM: "πŸ”Ž", DELIM + r"candle" + DELIM: "πŸ•―οΈ", DELIM + r"bulb" + DELIM: "πŸ’‘", DELIM + r"flashlight" + DELIM: "πŸ”¦", DELIM + r"(izakaya_)?lantern" + DELIM: "πŸ", DELIM + r"diya_lamp" + DELIM: "πŸͺ”", # # Book Paper # DELIM + r"notebook_with_decorative_cover" + DELIM: "πŸ“”", DELIM + r"closed_book" + DELIM: "πŸ“•", DELIM + r"(open_)?book" + DELIM: "πŸ“–", DELIM + r"green_book" + DELIM: "πŸ“—", DELIM + r"blue_book" + DELIM: "πŸ“˜", DELIM + r"orange_book" + DELIM: "πŸ“™", DELIM + r"books" + DELIM: "πŸ“š", DELIM + r"notebook" + DELIM: "πŸ““", DELIM + r"ledger" + DELIM: "πŸ“’", DELIM + r"page_with_curl" + DELIM: "πŸ“ƒ", DELIM + r"scroll" + DELIM: "πŸ“œ", DELIM + r"page_facing_up" + DELIM: "πŸ“„", DELIM + r"newspaper" + DELIM: "πŸ“°", DELIM + r"newspaper_roll" + DELIM: "πŸ—žοΈ", DELIM + r"bookmark_tabs" + DELIM: "πŸ“‘", DELIM + r"bookmark" + DELIM: "πŸ”–", DELIM + r"label" + DELIM: "🏷️", # # Money # DELIM + r"moneybag" + DELIM: "πŸ’°", DELIM + r"coin" + DELIM: "πŸͺ™", DELIM + r"yen" + DELIM: "πŸ’΄", DELIM + r"dollar" + DELIM: "πŸ’΅", DELIM + r"euro" + DELIM: "πŸ’Ά", DELIM + r"pound" + DELIM: "πŸ’·", DELIM + r"money_with_wings" + DELIM: "πŸ’Έ", DELIM + r"credit_card" + DELIM: "πŸ’³", DELIM + r"receipt" + DELIM: "🧾", DELIM + r"chart" + DELIM: "πŸ’Ή", # # Mail # DELIM + r"envelope" + DELIM: "βœ‰οΈ", DELIM + r"e-?mail" + DELIM: "πŸ“§", DELIM + r"incoming_envelope" + DELIM: "πŸ“¨", DELIM + r"envelope_with_arrow" + DELIM: "πŸ“©", DELIM + r"outbox_tray" + DELIM: "πŸ“€", DELIM + r"inbox_tray" + DELIM: "πŸ“₯", DELIM + r"package" + DELIM: "πŸ“¦", DELIM + r"mailbox" + DELIM: "πŸ“«", DELIM + r"mailbox_closed" + DELIM: "πŸ“ͺ", DELIM + r"mailbox_with_mail" + DELIM: "πŸ“¬", DELIM + r"mailbox_with_no_mail" + DELIM: "πŸ“­", DELIM + r"postbox" + DELIM: "πŸ“", DELIM + r"ballot_box" + DELIM: "πŸ—³οΈ", # # Writing # DELIM + r"pencil2" + DELIM: "✏️", DELIM + r"black_nib" + DELIM: "βœ’οΈ", DELIM + r"fountain_pen" + DELIM: "πŸ–‹οΈ", DELIM + r"pen" + DELIM: "πŸ–ŠοΈ", DELIM + r"paintbrush" + DELIM: "πŸ–ŒοΈ", DELIM + r"crayon" + DELIM: "πŸ–οΈ", DELIM + r"(memo|pencil)" + DELIM: "πŸ“", # # Office # DELIM + r"briefcase" + DELIM: "πŸ’Ό", DELIM + r"file_folder" + DELIM: "πŸ“", DELIM + r"open_file_folder" + DELIM: "πŸ“‚", DELIM + r"card_index_dividers" + DELIM: "πŸ—‚οΈ", DELIM + r"date" + DELIM: "πŸ“…", DELIM + r"calendar" + DELIM: "πŸ“†", DELIM + r"spiral_notepad" + DELIM: "πŸ—’οΈ", DELIM + r"spiral_calendar" + DELIM: "πŸ—“οΈ", DELIM + r"card_index" + DELIM: "πŸ“‡", DELIM + r"chart_with_upwards_trend" + DELIM: "πŸ“ˆ", DELIM + r"chart_with_downwards_trend" + DELIM: "πŸ“‰", DELIM + r"bar_chart" + DELIM: "πŸ“Š", DELIM + r"clipboard" + DELIM: "πŸ“‹", DELIM + r"pushpin" + DELIM: "πŸ“Œ", DELIM + r"round_pushpin" + DELIM: "πŸ“", DELIM + r"paperclip" + DELIM: "πŸ“Ž", DELIM + r"paperclips" + DELIM: "πŸ–‡οΈ", DELIM + r"straight_ruler" + DELIM: "πŸ“", DELIM + r"triangular_ruler" + DELIM: "πŸ“", DELIM + r"scissors" + DELIM: "βœ‚οΈ", DELIM + r"card_file_box" + DELIM: "πŸ—ƒοΈ", DELIM + r"file_cabinet" + DELIM: "πŸ—„οΈ", DELIM + r"wastebasket" + DELIM: "πŸ—‘οΈ", # # Lock # DELIM + r"lock" + DELIM: "πŸ”’", DELIM + r"unlock" + DELIM: "πŸ”“", DELIM + r"lock_with_ink_pen" + DELIM: "πŸ”", DELIM + r"closed_lock_with_key" + DELIM: "πŸ”", DELIM + r"key" + DELIM: "πŸ”‘", DELIM + r"old_key" + DELIM: "πŸ—οΈ", # # Tool # DELIM + r"hammer" + DELIM: "πŸ”¨", DELIM + r"axe" + DELIM: "πŸͺ“", DELIM + r"pick" + DELIM: "⛏️", DELIM + r"hammer_and_pick" + DELIM: "βš’οΈ", DELIM + r"hammer_and_wrench" + DELIM: "πŸ› οΈ", DELIM + r"dagger" + DELIM: "πŸ—‘οΈ", DELIM + r"crossed_swords" + DELIM: "βš”οΈ", DELIM + r"bomb" + DELIM: "πŸ’£", DELIM + r"boomerang" + DELIM: "πŸͺƒ", DELIM + r"bow_and_arrow" + DELIM: "🏹", DELIM + r"shield" + DELIM: "πŸ›‘οΈ", DELIM + r"carpentry_saw" + DELIM: "πŸͺš", DELIM + r"wrench" + DELIM: "πŸ”§", DELIM + r"screwdriver" + DELIM: "πŸͺ›", DELIM + r"nut_and_bolt" + DELIM: "πŸ”©", DELIM + r"gear" + DELIM: "βš™οΈ", DELIM + r"clamp" + DELIM: "πŸ—œοΈ", DELIM + r"balance_scale" + DELIM: "βš–οΈ", DELIM + r"probing_cane" + DELIM: "🦯", DELIM + r"link" + DELIM: "πŸ”—", DELIM + r"chains" + DELIM: "⛓️", DELIM + r"hook" + DELIM: "πŸͺ", DELIM + r"toolbox" + DELIM: "🧰", DELIM + r"magnet" + DELIM: "🧲", DELIM + r"ladder" + DELIM: "πŸͺœ", # # Science # DELIM + r"alembic" + DELIM: "βš—οΈ", DELIM + r"test_tube" + DELIM: "πŸ§ͺ", DELIM + r"petri_dish" + DELIM: "🧫", DELIM + r"dna" + DELIM: "🧬", DELIM + r"microscope" + DELIM: "πŸ”¬", DELIM + r"telescope" + DELIM: "πŸ”­", DELIM + r"satellite" + DELIM: "πŸ“‘", # # Medical # DELIM + r"syringe" + DELIM: "πŸ’‰", DELIM + r"drop_of_blood" + DELIM: "🩸", DELIM + r"pill" + DELIM: "πŸ’Š", DELIM + r"adhesive_bandage" + DELIM: "🩹", DELIM + r"stethoscope" + DELIM: "🩺", # # Household # DELIM + r"door" + DELIM: "πŸšͺ", DELIM + r"elevator" + DELIM: "πŸ›—", DELIM + r"mirror" + DELIM: "πŸͺž", DELIM + r"window" + DELIM: "πŸͺŸ", DELIM + r"bed" + DELIM: "πŸ›οΈ", DELIM + r"couch_and_lamp" + DELIM: "πŸ›‹οΈ", DELIM + r"chair" + DELIM: "πŸͺ‘", DELIM + r"toilet" + DELIM: "🚽", DELIM + r"plunger" + DELIM: "πŸͺ ", DELIM + r"shower" + DELIM: "🚿", DELIM + r"bathtub" + DELIM: "πŸ›", DELIM + r"mouse_trap" + DELIM: "πŸͺ€", DELIM + r"razor" + DELIM: "πŸͺ’", DELIM + r"lotion_bottle" + DELIM: "🧴", DELIM + r"safety_pin" + DELIM: "🧷", DELIM + r"broom" + DELIM: "🧹", DELIM + r"basket" + DELIM: "🧺", DELIM + r"roll_of_paper" + DELIM: "🧻", DELIM + r"bucket" + DELIM: "πŸͺ£", DELIM + r"soap" + DELIM: "🧼", DELIM + r"toothbrush" + DELIM: "πŸͺ₯", DELIM + r"sponge" + DELIM: "🧽", DELIM + r"fire_extinguisher" + DELIM: "🧯", DELIM + r"shopping_cart" + DELIM: "πŸ›’", # # Other Object # DELIM + r"smoking" + DELIM: "🚬", DELIM + r"coffin" + DELIM: "⚰️", DELIM + r"headstone" + DELIM: "πŸͺ¦", DELIM + r"funeral_urn" + DELIM: "⚱️", DELIM + r"nazar_amulet" + DELIM: "🧿", DELIM + r"moyai" + DELIM: "πŸ—Ώ", DELIM + r"placard" + DELIM: "πŸͺ§", # # Transport Sign # DELIM + r"atm" + DELIM: "🏧", DELIM + r"put_litter_in_its_place" + DELIM: "πŸš", DELIM + r"potable_water" + DELIM: "🚰", DELIM + r"wheelchair" + DELIM: "β™Ώ", DELIM + r"mens" + DELIM: "🚹", DELIM + r"womens" + DELIM: "🚺", DELIM + r"restroom" + DELIM: "🚻", DELIM + r"baby_symbol" + DELIM: "🚼", DELIM + r"wc" + DELIM: "🚾", DELIM + r"passport_control" + DELIM: "πŸ›‚", DELIM + r"customs" + DELIM: "πŸ›ƒ", DELIM + r"baggage_claim" + DELIM: "πŸ›„", DELIM + r"left_luggage" + DELIM: "πŸ›…", # # Warning # DELIM + r"warning" + DELIM: "⚠️", DELIM + r"children_crossing" + DELIM: "🚸", DELIM + r"no_entry" + DELIM: "β›”", DELIM + r"no_entry_sign" + DELIM: "🚫", DELIM + r"no_bicycles" + DELIM: "🚳", DELIM + r"no_smoking" + DELIM: "🚭", DELIM + r"do_not_litter" + DELIM: "🚯", DELIM + r"non-potable_water" + DELIM: "🚱", DELIM + r"no_pedestrians" + DELIM: "🚷", DELIM + r"no_mobile_phones" + DELIM: "πŸ“΅", DELIM + r"underage" + DELIM: "πŸ”ž", DELIM + r"radioactive" + DELIM: "☒️", DELIM + r"biohazard" + DELIM: "☣️", # # Arrow # DELIM + r"arrow_up" + DELIM: "⬆️", DELIM + r"arrow_upper_right" + DELIM: "↗️", DELIM + r"arrow_right" + DELIM: "➑️", DELIM + r"arrow_lower_right" + DELIM: "β†˜οΈ", DELIM + r"arrow_down" + DELIM: "⬇️", DELIM + r"arrow_lower_left" + DELIM: "↙️", DELIM + r"arrow_left" + DELIM: "⬅️", DELIM + r"arrow_upper_left" + DELIM: "↖️", DELIM + r"arrow_up_down" + DELIM: "↕️", DELIM + r"left_right_arrow" + DELIM: "↔️", DELIM + r"leftwards_arrow_with_hook" + DELIM: "↩️", DELIM + r"arrow_right_hook" + DELIM: "β†ͺ️", DELIM + r"arrow_heading_up" + DELIM: "‴️", DELIM + r"arrow_heading_down" + DELIM: "‡️", DELIM + r"arrows_clockwise" + DELIM: "πŸ”ƒ", DELIM + r"arrows_counterclockwise" + DELIM: "πŸ”„", DELIM + r"back" + DELIM: "πŸ”™", DELIM + r"end" + DELIM: "πŸ”š", DELIM + r"on" + DELIM: "πŸ”›", DELIM + r"soon" + DELIM: "πŸ”œ", DELIM + r"top" + DELIM: "πŸ”", # # Religion # DELIM + r"place_of_worship" + DELIM: "πŸ›", DELIM + r"atom_symbol" + DELIM: "βš›οΈ", DELIM + r"om" + DELIM: "πŸ•‰οΈ", DELIM + r"star_of_david" + DELIM: "✑️", DELIM + r"wheel_of_dharma" + DELIM: "☸️", DELIM + r"yin_yang" + DELIM: "☯️", DELIM + r"latin_cross" + DELIM: "✝️", DELIM + r"orthodox_cross" + DELIM: "☦️", DELIM + r"star_and_crescent" + DELIM: "β˜ͺ️", DELIM + r"peace_symbol" + DELIM: "β˜οΈ", DELIM + r"menorah" + DELIM: "πŸ•Ž", DELIM + r"six_pointed_star" + DELIM: "πŸ”―", # # Zodiac # DELIM + r"aries" + DELIM: "β™ˆ", DELIM + r"taurus" + DELIM: "♉", DELIM + r"gemini" + DELIM: "β™Š", DELIM + r"cancer" + DELIM: "β™‹", DELIM + r"leo" + DELIM: "β™Œ", DELIM + r"virgo" + DELIM: "♍", DELIM + r"libra" + DELIM: "β™Ž", DELIM + r"scorpius" + DELIM: "♏", DELIM + r"sagittarius" + DELIM: "♐", DELIM + r"capricorn" + DELIM: "β™‘", DELIM + r"aquarius" + DELIM: "β™’", DELIM + r"pisces" + DELIM: "β™“", DELIM + r"ophiuchus" + DELIM: "β›Ž", # # Av Symbol # DELIM + r"twisted_rightwards_arrows" + DELIM: "πŸ”€", DELIM + r"repeat" + DELIM: "πŸ”", DELIM + r"repeat_one" + DELIM: "πŸ”‚", DELIM + r"arrow_forward" + DELIM: "▢️", DELIM + r"fast_forward" + DELIM: "⏩", DELIM + r"next_track_button" + DELIM: "⏭️", DELIM + r"play_or_pause_button" + DELIM: "⏯️", DELIM + r"arrow_backward" + DELIM: "◀️", DELIM + r"rewind" + DELIM: "βͺ", DELIM + r"previous_track_button" + DELIM: "βοΈ", DELIM + r"arrow_up_small" + DELIM: "πŸ”Ό", DELIM + r"arrow_double_up" + DELIM: "⏫", DELIM + r"arrow_down_small" + DELIM: "πŸ”½", DELIM + r"arrow_double_down" + DELIM: "⏬", DELIM + r"pause_button" + DELIM: "⏸️", DELIM + r"stop_button" + DELIM: "⏹️", DELIM + r"record_button" + DELIM: "⏺️", DELIM + r"eject_button" + DELIM: "⏏️", DELIM + r"cinema" + DELIM: "🎦", DELIM + r"low_brightness" + DELIM: "πŸ”…", DELIM + r"high_brightness" + DELIM: "πŸ”†", DELIM + r"signal_strength" + DELIM: "πŸ“Ά", DELIM + r"vibration_mode" + DELIM: "πŸ“³", DELIM + r"mobile_phone_off" + DELIM: "πŸ“΄", # # Gender # DELIM + r"female_sign" + DELIM: "♀️", DELIM + r"male_sign" + DELIM: "♂️", DELIM + r"transgender_symbol" + DELIM: "⚧️", # # Math # DELIM + r"heavy_multiplication_x" + DELIM: "βœ–οΈ", DELIM + r"heavy_plus_sign" + DELIM: "βž•", # noqa: RUF001 DELIM + r"heavy_minus_sign" + DELIM: "βž–", # noqa: RUF001 DELIM + r"heavy_division_sign" + DELIM: "βž—", DELIM + r"infinity" + DELIM: "♾️", # # Punctuation # DELIM + r"bangbang" + DELIM: "‼️", DELIM + r"interrobang" + DELIM: "⁉️", DELIM + r"question" + DELIM: "❓", DELIM + r"grey_question" + DELIM: "❔", DELIM + r"grey_exclamation" + DELIM: "❕", DELIM + r"(heavy_exclamation_mark|exclamation)" + DELIM: "❗", DELIM + r"wavy_dash" + DELIM: "〰️", # # Currency # DELIM + r"currency_exchange" + DELIM: "πŸ’±", DELIM + r"heavy_dollar_sign" + DELIM: "πŸ’²", # # Other Symbol # DELIM + r"medical_symbol" + DELIM: "βš•οΈ", DELIM + r"recycle" + DELIM: "♻️", DELIM + r"fleur_de_lis" + DELIM: "⚜️", DELIM + r"trident" + DELIM: "πŸ”±", DELIM + r"name_badge" + DELIM: "πŸ“›", DELIM + r"beginner" + DELIM: "πŸ”°", DELIM + r"o" + DELIM: "β­•", DELIM + r"white_check_mark" + DELIM: "βœ…", DELIM + r"ballot_box_with_check" + DELIM: "β˜‘οΈ", DELIM + r"heavy_check_mark" + DELIM: "βœ”οΈ", DELIM + r"x" + DELIM: "❌", DELIM + r"negative_squared_cross_mark" + DELIM: "❎", DELIM + r"curly_loop" + DELIM: "➰", DELIM + r"loop" + DELIM: "➿", DELIM + r"part_alternation_mark" + DELIM: "〽️", DELIM + r"eight_spoked_asterisk" + DELIM: "✳️", DELIM + r"eight_pointed_black_star" + DELIM: "✴️", DELIM + r"sparkle" + DELIM: "❇️", DELIM + r"copyright" + DELIM: "©️", DELIM + r"registered" + DELIM: "Β️", DELIM + r"tm" + DELIM: "ℒ️", # # Keycap # DELIM + r"hash" + DELIM: "#️⃣", DELIM + r"asterisk" + DELIM: "*️⃣", DELIM + r"zero" + DELIM: "0️⃣", DELIM + r"one" + DELIM: "1️⃣", DELIM + r"two" + DELIM: "2️⃣", DELIM + r"three" + DELIM: "3️⃣", DELIM + r"four" + DELIM: "4️⃣", DELIM + r"five" + DELIM: "5️⃣", DELIM + r"six" + DELIM: "6️⃣", DELIM + r"seven" + DELIM: "7️⃣", DELIM + r"eight" + DELIM: "8️⃣", DELIM + r"nine" + DELIM: "9️⃣", DELIM + r"keycap_ten" + DELIM: "πŸ”Ÿ", # # Alphanum # DELIM + r"capital_abcd" + DELIM: "πŸ” ", DELIM + r"abcd" + DELIM: "πŸ”‘", DELIM + r"1234" + DELIM: "πŸ”’", DELIM + r"symbols" + DELIM: "πŸ”£", DELIM + r"abc" + DELIM: "πŸ”€", DELIM + r"a" + DELIM: "πŸ…°οΈ", DELIM + r"ab" + DELIM: "πŸ†Ž", DELIM + r"b" + DELIM: "πŸ…±οΈ", DELIM + r"cl" + DELIM: "πŸ†‘", DELIM + r"cool" + DELIM: "πŸ†’", DELIM + r"free" + DELIM: "πŸ†“", DELIM + r"information_source" + DELIM: "ℹ️", # noqa: RUF001 DELIM + r"id" + DELIM: "πŸ†”", DELIM + r"m" + DELIM: "Ⓜ️", DELIM + r"new" + DELIM: "πŸ†•", DELIM + r"ng" + DELIM: "πŸ†–", DELIM + r"o2" + DELIM: "πŸ…ΎοΈ", DELIM + r"ok" + DELIM: "πŸ†—", DELIM + r"parking" + DELIM: "πŸ…ΏοΈ", DELIM + r"sos" + DELIM: "πŸ†˜", DELIM + r"up" + DELIM: "πŸ†™", DELIM + r"vs" + DELIM: "πŸ†š", DELIM + r"koko" + DELIM: "🈁", DELIM + r"sa" + DELIM: "πŸˆ‚οΈ", DELIM + r"u6708" + DELIM: "🈷️", DELIM + r"u6709" + DELIM: "🈢", DELIM + r"u6307" + DELIM: "🈯", DELIM + r"ideograph_advantage" + DELIM: "πŸ‰", DELIM + r"u5272" + DELIM: "🈹", DELIM + r"u7121" + DELIM: "🈚", DELIM + r"u7981" + DELIM: "🈲", DELIM + r"accept" + DELIM: "πŸ‰‘", DELIM + r"u7533" + DELIM: "🈸", DELIM + r"u5408" + DELIM: "🈴", DELIM + r"u7a7a" + DELIM: "🈳", DELIM + r"congratulations" + DELIM: "γŠ—οΈ", DELIM + r"secret" + DELIM: "γŠ™οΈ", DELIM + r"u55b6" + DELIM: "🈺", DELIM + r"u6e80" + DELIM: "🈡", # # Geometric # DELIM + r"red_circle" + DELIM: "πŸ”΄", DELIM + r"orange_circle" + DELIM: "🟠", DELIM + r"yellow_circle" + DELIM: "🟑", DELIM + r"green_circle" + DELIM: "🟒", DELIM + r"large_blue_circle" + DELIM: "πŸ”΅", DELIM + r"purple_circle" + DELIM: "🟣", DELIM + r"brown_circle" + DELIM: "🟀", DELIM + r"black_circle" + DELIM: "⚫", DELIM + r"white_circle" + DELIM: "βšͺ", DELIM + r"red_square" + DELIM: "πŸŸ₯", DELIM + r"orange_square" + DELIM: "🟧", DELIM + r"yellow_square" + DELIM: "🟨", DELIM + r"green_square" + DELIM: "🟩", DELIM + r"blue_square" + DELIM: "🟦", DELIM + r"purple_square" + DELIM: "πŸŸͺ", DELIM + r"brown_square" + DELIM: "🟫", DELIM + r"black_large_square" + DELIM: "⬛", DELIM + r"white_large_square" + DELIM: "⬜", DELIM + r"black_medium_square" + DELIM: "◼️", DELIM + r"white_medium_square" + DELIM: "◻️", DELIM + r"black_medium_small_square" + DELIM: "β—Ύ", DELIM + r"white_medium_small_square" + DELIM: "β—½", DELIM + r"black_small_square" + DELIM: "β–ͺ️", DELIM + r"white_small_square" + DELIM: "▫️", DELIM + r"large_orange_diamond" + DELIM: "πŸ”Ά", DELIM + r"large_blue_diamond" + DELIM: "πŸ”·", DELIM + r"small_orange_diamond" + DELIM: "πŸ”Έ", DELIM + r"small_blue_diamond" + DELIM: "πŸ”Ή", DELIM + r"small_red_triangle" + DELIM: "πŸ”Ί", DELIM + r"small_red_triangle_down" + DELIM: "πŸ”»", DELIM + r"diamond_shape_with_a_dot_inside" + DELIM: "πŸ’ ", DELIM + r"radio_button" + DELIM: "πŸ”˜", DELIM + r"white_square_button" + DELIM: "πŸ”³", DELIM + r"black_square_button" + DELIM: "πŸ”²", # # Flag # DELIM + r"checkered_flag" + DELIM: "🏁", DELIM + r"triangular_flag_on_post" + DELIM: "🚩", DELIM + r"crossed_flags" + DELIM: "🎌", DELIM + r"black_flag" + DELIM: "🏴", DELIM + r"white_flag" + DELIM: "🏳️", DELIM + r"rainbow_flag" + DELIM: "πŸ³οΈβ€πŸŒˆ", DELIM + r"transgender_flag" + DELIM: "πŸ³οΈβ€βš§οΈ", DELIM + r"pirate_flag" + DELIM: "πŸ΄β€β˜ οΈ", # # Country Flag # DELIM + r"ascension_island" + DELIM: "πŸ‡¦πŸ‡¨", DELIM + r"andorra" + DELIM: "πŸ‡¦πŸ‡©", DELIM + r"united_arab_emirates" + DELIM: "πŸ‡¦πŸ‡ͺ", DELIM + r"afghanistan" + DELIM: "πŸ‡¦πŸ‡«", DELIM + r"antigua_barbuda" + DELIM: "πŸ‡¦πŸ‡¬", DELIM + r"anguilla" + DELIM: "πŸ‡¦πŸ‡", DELIM + r"albania" + DELIM: "πŸ‡¦πŸ‡±", DELIM + r"armenia" + DELIM: "πŸ‡¦πŸ‡²", DELIM + r"angola" + DELIM: "πŸ‡¦πŸ‡΄", DELIM + r"antarctica" + DELIM: "πŸ‡¦πŸ‡Ά", DELIM + r"argentina" + DELIM: "πŸ‡¦πŸ‡·", DELIM + r"american_samoa" + DELIM: "πŸ‡¦πŸ‡Έ", DELIM + r"austria" + DELIM: "πŸ‡¦πŸ‡Ή", DELIM + r"australia" + DELIM: "πŸ‡¦πŸ‡Ί", DELIM + r"aruba" + DELIM: "πŸ‡¦πŸ‡Ό", DELIM + r"aland_islands" + DELIM: "πŸ‡¦πŸ‡½", DELIM + r"azerbaijan" + DELIM: "πŸ‡¦πŸ‡Ώ", DELIM + r"bosnia_herzegovina" + DELIM: "πŸ‡§πŸ‡¦", DELIM + r"barbados" + DELIM: "πŸ‡§πŸ‡§", DELIM + r"bangladesh" + DELIM: "πŸ‡§πŸ‡©", DELIM + r"belgium" + DELIM: "πŸ‡§πŸ‡ͺ", DELIM + r"burkina_faso" + DELIM: "πŸ‡§πŸ‡«", DELIM + r"bulgaria" + DELIM: "πŸ‡§πŸ‡¬", DELIM + r"bahrain" + DELIM: "πŸ‡§πŸ‡­", DELIM + r"burundi" + DELIM: "πŸ‡§πŸ‡", DELIM + r"benin" + DELIM: "πŸ‡§πŸ‡―", DELIM + r"st_barthelemy" + DELIM: "πŸ‡§πŸ‡±", DELIM + r"bermuda" + DELIM: "πŸ‡§πŸ‡²", DELIM + r"brunei" + DELIM: "πŸ‡§πŸ‡³", DELIM + r"bolivia" + DELIM: "πŸ‡§πŸ‡΄", DELIM + r"caribbean_netherlands" + DELIM: "πŸ‡§πŸ‡Ά", DELIM + r"brazil" + DELIM: "πŸ‡§πŸ‡·", DELIM + r"bahamas" + DELIM: "πŸ‡§πŸ‡Έ", DELIM + r"bhutan" + DELIM: "πŸ‡§πŸ‡Ή", DELIM + r"bouvet_island" + DELIM: "πŸ‡§πŸ‡»", DELIM + r"botswana" + DELIM: "πŸ‡§πŸ‡Ό", DELIM + r"belarus" + DELIM: "πŸ‡§πŸ‡Ύ", DELIM + r"belize" + DELIM: "πŸ‡§πŸ‡Ώ", DELIM + r"canada" + DELIM: "πŸ‡¨πŸ‡¦", DELIM + r"cocos_islands" + DELIM: "πŸ‡¨πŸ‡¨", DELIM + r"congo_kinshasa" + DELIM: "πŸ‡¨πŸ‡©", DELIM + r"central_african_republic" + DELIM: "πŸ‡¨πŸ‡«", DELIM + r"congo_brazzaville" + DELIM: "πŸ‡¨πŸ‡¬", DELIM + r"switzerland" + DELIM: "πŸ‡¨πŸ‡­", DELIM + r"cote_divoire" + DELIM: "πŸ‡¨πŸ‡", DELIM + r"cook_islands" + DELIM: "πŸ‡¨πŸ‡°", DELIM + r"chile" + DELIM: "πŸ‡¨πŸ‡±", DELIM + r"cameroon" + DELIM: "πŸ‡¨πŸ‡²", DELIM + r"cn" + DELIM: "πŸ‡¨πŸ‡³", DELIM + r"colombia" + DELIM: "πŸ‡¨πŸ‡΄", DELIM + r"clipperton_island" + DELIM: "πŸ‡¨πŸ‡΅", DELIM + r"costa_rica" + DELIM: "πŸ‡¨πŸ‡·", DELIM + r"cuba" + DELIM: "πŸ‡¨πŸ‡Ί", DELIM + r"cape_verde" + DELIM: "πŸ‡¨πŸ‡»", DELIM + r"curacao" + DELIM: "πŸ‡¨πŸ‡Ό", DELIM + r"christmas_island" + DELIM: "πŸ‡¨πŸ‡½", DELIM + r"cyprus" + DELIM: "πŸ‡¨πŸ‡Ύ", DELIM + r"czech_republic" + DELIM: "πŸ‡¨πŸ‡Ώ", DELIM + r"de" + DELIM: "πŸ‡©πŸ‡ͺ", DELIM + r"diego_garcia" + DELIM: "πŸ‡©πŸ‡¬", DELIM + r"djibouti" + DELIM: "πŸ‡©πŸ‡―", DELIM + r"denmark" + DELIM: "πŸ‡©πŸ‡°", DELIM + r"dominica" + DELIM: "πŸ‡©πŸ‡²", DELIM + r"dominican_republic" + DELIM: "πŸ‡©πŸ‡΄", DELIM + r"algeria" + DELIM: "πŸ‡©πŸ‡Ώ", DELIM + r"ceuta_melilla" + DELIM: "πŸ‡ͺπŸ‡¦", DELIM + r"ecuador" + DELIM: "πŸ‡ͺπŸ‡¨", DELIM + r"estonia" + DELIM: "πŸ‡ͺπŸ‡ͺ", DELIM + r"egypt" + DELIM: "πŸ‡ͺπŸ‡¬", DELIM + r"western_sahara" + DELIM: "πŸ‡ͺπŸ‡­", DELIM + r"eritrea" + DELIM: "πŸ‡ͺπŸ‡·", DELIM + r"es" + DELIM: "πŸ‡ͺπŸ‡Έ", DELIM + r"ethiopia" + DELIM: "πŸ‡ͺπŸ‡Ή", DELIM + r"(eu|european_union)" + DELIM: "πŸ‡ͺπŸ‡Ί", DELIM + r"finland" + DELIM: "πŸ‡«πŸ‡", DELIM + r"fiji" + DELIM: "πŸ‡«πŸ‡―", DELIM + r"falkland_islands" + DELIM: "πŸ‡«πŸ‡°", DELIM + r"micronesia" + DELIM: "πŸ‡«πŸ‡²", DELIM + r"faroe_islands" + DELIM: "πŸ‡«πŸ‡΄", DELIM + r"fr" + DELIM: "πŸ‡«πŸ‡·", DELIM + r"gabon" + DELIM: "πŸ‡¬πŸ‡¦", DELIM + r"(uk|gb)" + DELIM: "πŸ‡¬πŸ‡§", DELIM + r"grenada" + DELIM: "πŸ‡¬πŸ‡©", DELIM + r"georgia" + DELIM: "πŸ‡¬πŸ‡ͺ", DELIM + r"french_guiana" + DELIM: "πŸ‡¬πŸ‡«", DELIM + r"guernsey" + DELIM: "πŸ‡¬πŸ‡¬", DELIM + r"ghana" + DELIM: "πŸ‡¬πŸ‡­", DELIM + r"gibraltar" + DELIM: "πŸ‡¬πŸ‡", DELIM + r"greenland" + DELIM: "πŸ‡¬πŸ‡±", DELIM + r"gambia" + DELIM: "πŸ‡¬πŸ‡²", DELIM + r"guinea" + DELIM: "πŸ‡¬πŸ‡³", DELIM + r"guadeloupe" + DELIM: "πŸ‡¬πŸ‡΅", DELIM + r"equatorial_guinea" + DELIM: "πŸ‡¬πŸ‡Ά", DELIM + r"greece" + DELIM: "πŸ‡¬πŸ‡·", DELIM + r"south_georgia_south_sandwich_islands" + DELIM: "πŸ‡¬πŸ‡Έ", DELIM + r"guatemala" + DELIM: "πŸ‡¬πŸ‡Ή", DELIM + r"guam" + DELIM: "πŸ‡¬πŸ‡Ί", DELIM + r"guinea_bissau" + DELIM: "πŸ‡¬πŸ‡Ό", DELIM + r"guyana" + DELIM: "πŸ‡¬πŸ‡Ύ", DELIM + r"hong_kong" + DELIM: "πŸ‡­πŸ‡°", DELIM + r"heard_mcdonald_islands" + DELIM: "πŸ‡­πŸ‡²", DELIM + r"honduras" + DELIM: "πŸ‡­πŸ‡³", DELIM + r"croatia" + DELIM: "πŸ‡­πŸ‡·", DELIM + r"haiti" + DELIM: "πŸ‡­πŸ‡Ή", DELIM + r"hungary" + DELIM: "πŸ‡­πŸ‡Ί", DELIM + r"canary_islands" + DELIM: "πŸ‡πŸ‡¨", DELIM + r"indonesia" + DELIM: "πŸ‡πŸ‡©", DELIM + r"ireland" + DELIM: "πŸ‡πŸ‡ͺ", DELIM + r"israel" + DELIM: "πŸ‡πŸ‡±", DELIM + r"isle_of_man" + DELIM: "πŸ‡πŸ‡²", DELIM + r"india" + DELIM: "πŸ‡πŸ‡³", DELIM + r"british_indian_ocean_territory" + DELIM: "πŸ‡πŸ‡΄", DELIM + r"iraq" + DELIM: "πŸ‡πŸ‡Ά", DELIM + r"iran" + DELIM: "πŸ‡πŸ‡·", DELIM + r"iceland" + DELIM: "πŸ‡πŸ‡Έ", DELIM + r"it" + DELIM: "πŸ‡πŸ‡Ή", DELIM + r"jersey" + DELIM: "πŸ‡―πŸ‡ͺ", DELIM + r"jamaica" + DELIM: "πŸ‡―πŸ‡²", DELIM + r"jordan" + DELIM: "πŸ‡―πŸ‡΄", DELIM + r"jp" + DELIM: "πŸ‡―πŸ‡΅", DELIM + r"kenya" + DELIM: "πŸ‡°πŸ‡ͺ", DELIM + r"kyrgyzstan" + DELIM: "πŸ‡°πŸ‡¬", DELIM + r"cambodia" + DELIM: "πŸ‡°πŸ‡­", DELIM + r"kiribati" + DELIM: "πŸ‡°πŸ‡", DELIM + r"comoros" + DELIM: "πŸ‡°πŸ‡²", DELIM + r"st_kitts_nevis" + DELIM: "πŸ‡°πŸ‡³", DELIM + r"north_korea" + DELIM: "πŸ‡°πŸ‡΅", DELIM + r"kr" + DELIM: "πŸ‡°πŸ‡·", DELIM + r"kuwait" + DELIM: "πŸ‡°πŸ‡Ό", DELIM + r"cayman_islands" + DELIM: "πŸ‡°πŸ‡Ύ", DELIM + r"kazakhstan" + DELIM: "πŸ‡°πŸ‡Ώ", DELIM + r"laos" + DELIM: "πŸ‡±πŸ‡¦", DELIM + r"lebanon" + DELIM: "πŸ‡±πŸ‡§", DELIM + r"st_lucia" + DELIM: "πŸ‡±πŸ‡¨", DELIM + r"liechtenstein" + DELIM: "πŸ‡±πŸ‡", DELIM + r"sri_lanka" + DELIM: "πŸ‡±πŸ‡°", DELIM + r"liberia" + DELIM: "πŸ‡±πŸ‡·", DELIM + r"lesotho" + DELIM: "πŸ‡±πŸ‡Έ", DELIM + r"lithuania" + DELIM: "πŸ‡±πŸ‡Ή", DELIM + r"luxembourg" + DELIM: "πŸ‡±πŸ‡Ί", DELIM + r"latvia" + DELIM: "πŸ‡±πŸ‡»", DELIM + r"libya" + DELIM: "πŸ‡±πŸ‡Ύ", DELIM + r"morocco" + DELIM: "πŸ‡²πŸ‡¦", DELIM + r"monaco" + DELIM: "πŸ‡²πŸ‡¨", DELIM + r"moldova" + DELIM: "πŸ‡²πŸ‡©", DELIM + r"montenegro" + DELIM: "πŸ‡²πŸ‡ͺ", DELIM + r"st_martin" + DELIM: "πŸ‡²πŸ‡«", DELIM + r"madagascar" + DELIM: "πŸ‡²πŸ‡¬", DELIM + r"marshall_islands" + DELIM: "πŸ‡²πŸ‡­", DELIM + r"macedonia" + DELIM: "πŸ‡²πŸ‡°", DELIM + r"mali" + DELIM: "πŸ‡²πŸ‡±", DELIM + r"myanmar" + DELIM: "πŸ‡²πŸ‡²", DELIM + r"mongolia" + DELIM: "πŸ‡²πŸ‡³", DELIM + r"macau" + DELIM: "πŸ‡²πŸ‡΄", DELIM + r"northern_mariana_islands" + DELIM: "πŸ‡²πŸ‡΅", DELIM + r"martinique" + DELIM: "πŸ‡²πŸ‡Ά", DELIM + r"mauritania" + DELIM: "πŸ‡²πŸ‡·", DELIM + r"montserrat" + DELIM: "πŸ‡²πŸ‡Έ", DELIM + r"malta" + DELIM: "πŸ‡²πŸ‡Ή", DELIM + r"mauritius" + DELIM: "πŸ‡²πŸ‡Ί", DELIM + r"maldives" + DELIM: "πŸ‡²πŸ‡»", DELIM + r"malawi" + DELIM: "πŸ‡²πŸ‡Ό", DELIM + r"mexico" + DELIM: "πŸ‡²πŸ‡½", DELIM + r"malaysia" + DELIM: "πŸ‡²πŸ‡Ύ", DELIM + r"mozambique" + DELIM: "πŸ‡²πŸ‡Ώ", DELIM + r"namibia" + DELIM: "πŸ‡³πŸ‡¦", DELIM + r"new_caledonia" + DELIM: "πŸ‡³πŸ‡¨", DELIM + r"niger" + DELIM: "πŸ‡³πŸ‡ͺ", DELIM + r"norfolk_island" + DELIM: "πŸ‡³πŸ‡«", DELIM + r"nigeria" + DELIM: "πŸ‡³πŸ‡¬", DELIM + r"nicaragua" + DELIM: "πŸ‡³πŸ‡", DELIM + r"netherlands" + DELIM: "πŸ‡³πŸ‡±", DELIM + r"norway" + DELIM: "πŸ‡³πŸ‡΄", DELIM + r"nepal" + DELIM: "πŸ‡³πŸ‡΅", DELIM + r"nauru" + DELIM: "πŸ‡³πŸ‡·", DELIM + r"niue" + DELIM: "πŸ‡³πŸ‡Ί", DELIM + r"new_zealand" + DELIM: "πŸ‡³πŸ‡Ώ", DELIM + r"oman" + DELIM: "πŸ‡΄πŸ‡²", DELIM + r"panama" + DELIM: "πŸ‡΅πŸ‡¦", DELIM + r"peru" + DELIM: "πŸ‡΅πŸ‡ͺ", DELIM + r"french_polynesia" + DELIM: "πŸ‡΅πŸ‡«", DELIM + r"papua_new_guinea" + DELIM: "πŸ‡΅πŸ‡¬", DELIM + r"philippines" + DELIM: "πŸ‡΅πŸ‡­", DELIM + r"pakistan" + DELIM: "πŸ‡΅πŸ‡°", DELIM + r"poland" + DELIM: "πŸ‡΅πŸ‡±", DELIM + r"st_pierre_miquelon" + DELIM: "πŸ‡΅πŸ‡²", DELIM + r"pitcairn_islands" + DELIM: "πŸ‡΅πŸ‡³", DELIM + r"puerto_rico" + DELIM: "πŸ‡΅πŸ‡·", DELIM + r"palestinian_territories" + DELIM: "πŸ‡΅πŸ‡Έ", DELIM + r"portugal" + DELIM: "πŸ‡΅πŸ‡Ή", DELIM + r"palau" + DELIM: "πŸ‡΅πŸ‡Ό", DELIM + r"paraguay" + DELIM: "πŸ‡΅πŸ‡Ύ", DELIM + r"qatar" + DELIM: "πŸ‡ΆπŸ‡¦", DELIM + r"reunion" + DELIM: "πŸ‡·πŸ‡ͺ", DELIM + r"romania" + DELIM: "πŸ‡·πŸ‡΄", DELIM + r"serbia" + DELIM: "πŸ‡·πŸ‡Έ", DELIM + r"ru" + DELIM: "πŸ‡·πŸ‡Ί", DELIM + r"rwanda" + DELIM: "πŸ‡·πŸ‡Ό", DELIM + r"saudi_arabia" + DELIM: "πŸ‡ΈπŸ‡¦", DELIM + r"solomon_islands" + DELIM: "πŸ‡ΈπŸ‡§", DELIM + r"seychelles" + DELIM: "πŸ‡ΈπŸ‡¨", DELIM + r"sudan" + DELIM: "πŸ‡ΈπŸ‡©", DELIM + r"sweden" + DELIM: "πŸ‡ΈπŸ‡ͺ", DELIM + r"singapore" + DELIM: "πŸ‡ΈπŸ‡¬", DELIM + r"st_helena" + DELIM: "πŸ‡ΈπŸ‡­", DELIM + r"slovenia" + DELIM: "πŸ‡ΈπŸ‡", DELIM + r"svalbard_jan_mayen" + DELIM: "πŸ‡ΈπŸ‡―", DELIM + r"slovakia" + DELIM: "πŸ‡ΈπŸ‡°", DELIM + r"sierra_leone" + DELIM: "πŸ‡ΈπŸ‡±", DELIM + r"san_marino" + DELIM: "πŸ‡ΈπŸ‡²", DELIM + r"senegal" + DELIM: "πŸ‡ΈπŸ‡³", DELIM + r"somalia" + DELIM: "πŸ‡ΈπŸ‡΄", DELIM + r"suriname" + DELIM: "πŸ‡ΈπŸ‡·", DELIM + r"south_sudan" + DELIM: "πŸ‡ΈπŸ‡Έ", DELIM + r"sao_tome_principe" + DELIM: "πŸ‡ΈπŸ‡Ή", DELIM + r"el_salvador" + DELIM: "πŸ‡ΈπŸ‡»", DELIM + r"sint_maarten" + DELIM: "πŸ‡ΈπŸ‡½", DELIM + r"syria" + DELIM: "πŸ‡ΈπŸ‡Ύ", DELIM + r"swaziland" + DELIM: "πŸ‡ΈπŸ‡Ώ", DELIM + r"tristan_da_cunha" + DELIM: "πŸ‡ΉπŸ‡¦", DELIM + r"turks_caicos_islands" + DELIM: "πŸ‡ΉπŸ‡¨", DELIM + r"chad" + DELIM: "πŸ‡ΉπŸ‡©", DELIM + r"french_southern_territories" + DELIM: "πŸ‡ΉπŸ‡«", DELIM + r"togo" + DELIM: "πŸ‡ΉπŸ‡¬", DELIM + r"thailand" + DELIM: "πŸ‡ΉπŸ‡­", DELIM + r"tajikistan" + DELIM: "πŸ‡ΉπŸ‡―", DELIM + r"tokelau" + DELIM: "πŸ‡ΉπŸ‡°", DELIM + r"timor_leste" + DELIM: "πŸ‡ΉπŸ‡±", DELIM + r"turkmenistan" + DELIM: "πŸ‡ΉπŸ‡²", DELIM + r"tunisia" + DELIM: "πŸ‡ΉπŸ‡³", DELIM + r"tonga" + DELIM: "πŸ‡ΉπŸ‡΄", DELIM + r"tr" + DELIM: "πŸ‡ΉπŸ‡·", DELIM + r"trinidad_tobago" + DELIM: "πŸ‡ΉπŸ‡Ή", DELIM + r"tuvalu" + DELIM: "πŸ‡ΉπŸ‡»", DELIM + r"taiwan" + DELIM: "πŸ‡ΉπŸ‡Ό", DELIM + r"tanzania" + DELIM: "πŸ‡ΉπŸ‡Ώ", DELIM + r"ukraine" + DELIM: "πŸ‡ΊπŸ‡¦", DELIM + r"uganda" + DELIM: "πŸ‡ΊπŸ‡¬", DELIM + r"us_outlying_islands" + DELIM: "πŸ‡ΊπŸ‡²", DELIM + r"united_nations" + DELIM: "πŸ‡ΊπŸ‡³", DELIM + r"us" + DELIM: "πŸ‡ΊπŸ‡Έ", DELIM + r"uruguay" + DELIM: "πŸ‡ΊπŸ‡Ύ", DELIM + r"uzbekistan" + DELIM: "πŸ‡ΊπŸ‡Ώ", DELIM + r"vatican_city" + DELIM: "πŸ‡»πŸ‡¦", DELIM + r"st_vincent_grenadines" + DELIM: "πŸ‡»πŸ‡¨", DELIM + r"venezuela" + DELIM: "πŸ‡»πŸ‡ͺ", DELIM + r"british_virgin_islands" + DELIM: "πŸ‡»πŸ‡¬", DELIM + r"us_virgin_islands" + DELIM: "πŸ‡»πŸ‡", DELIM + r"vietnam" + DELIM: "πŸ‡»πŸ‡³", DELIM + r"vanuatu" + DELIM: "πŸ‡»πŸ‡Ί", DELIM + r"wallis_futuna" + DELIM: "πŸ‡ΌπŸ‡«", DELIM + r"samoa" + DELIM: "πŸ‡ΌπŸ‡Έ", DELIM + r"kosovo" + DELIM: "πŸ‡½πŸ‡°", DELIM + r"yemen" + DELIM: "πŸ‡ΎπŸ‡ͺ", DELIM + r"mayotte" + DELIM: "πŸ‡ΎπŸ‡Ή", DELIM + r"south_africa" + DELIM: "πŸ‡ΏπŸ‡¦", DELIM + r"zambia" + DELIM: "πŸ‡ΏπŸ‡²", DELIM + r"zimbabwe" + DELIM: "πŸ‡ΏπŸ‡Ό", # # Subdivision Flag # DELIM + r"england" + DELIM: "🏴󠁧󠁒σ ₯σ σ §σ Ώ", DELIM + r"scotland" + DELIM: "🏴󠁧󠁒󠁳󠁣󠁴󠁿", DELIM + r"wales" + DELIM: "🏴󠁧󠁒󠁷󠁬󠁳󠁿", } # Define our singletons EMOJI_COMPILED_MAP = None # List of (compiled_fullmatch_pattern, emoji) for resolving alternation keys _EMOJI_PATTERN_LIST = None def apply_emojis(content): """Takes the content and swaps any matched emoji's found with their utf-8 encoded mapping.""" global EMOJI_COMPILED_MAP, _EMOJI_PATTERN_LIST if EMOJI_COMPILED_MAP is None: t_start = time.time() # Perform our compilation EMOJI_COMPILED_MAP = re.compile( r"(" + "|".join(EMOJI_MAP.keys()) + r")", re.IGNORECASE ) # - EMOJI_MAP keys are regex patterns (e.g. ":(\+1|thumbsup):") # - x.group() from a match cannot be used as a dict key directly. # # Build a per-entry compiled pattern for fullmatch lookups so # alternation/optional-group keys resolve correctly. _EMOJI_PATTERN_LIST = [ (re.compile(r"(?:" + pat + r")", re.IGNORECASE), emoji) for pat, emoji in EMOJI_MAP.items() ] logger.trace(f"Emoji engine loaded in {time.time() - t_start:.4f}s") def _lookup(m): text = m.group() for pat, emoji in _EMOJI_PATTERN_LIST: if pat.fullmatch(text): return emoji return text try: return EMOJI_COMPILED_MAP.sub(_lookup, content) except TypeError: # No change; but force string return return "" caronc-apprise-182f859/apprise/exception.py000066400000000000000000000047201524161175200207320ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import errno class AppriseException(Exception): """Base Apprise Exception Class.""" def __init__(self, message, error_code=0): super().__init__(message) self.error_code = error_code class ApprisePluginException(AppriseException): """Class object for handling exceptions raised from within a plugin.""" def __init__(self, message, error_code=600): super().__init__(message, error_code=error_code) class AppriseDiskIOError(AppriseException): """Thrown when an disk i/o error occurs.""" def __init__(self, message, error_code=errno.EIO): super().__init__(message, error_code=error_code) class AppriseInvalidData(AppriseException): """Thrown when bad data was passed into an internal function.""" def __init__(self, message, error_code=errno.EINVAL): super().__init__(message, error_code=error_code) class AppriseFileNotFound(AppriseDiskIOError, FileNotFoundError): """Thrown when a persistent write occured in MEMORY mode.""" def __init__(self, message): super().__init__(message, error_code=errno.ENOENT) caronc-apprise-182f859/apprise/i18n/000077500000000000000000000000001524161175200171365ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/__init__.py000066400000000000000000000000001524161175200212350ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/en/000077500000000000000000000000001524161175200175405ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/en/LC_MESSAGES/000077500000000000000000000000001524161175200213255ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/en/LC_MESSAGES/apprise.po000066400000000000000000001573621524161175200233460ustar00rootroot00000000000000# English translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 1.13.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-08-20 09:55-0400\n" "PO-Revision-Date: 2019-05-24 20:00-0400\n" "Last-Translator: Chris Caron \n" "Language: en\n" "Language-Team: en \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:96 apprise/url.py:158 msgid "Verify SSL" msgstr "Verify SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "" #: apprise/url.py:178 #, fuzzy msgid "Socket Read Timeout" msgstr "Server Timeout" #: apprise/url.py:192 #, fuzzy msgid "Socket Connect Timeout" msgstr "Server Timeout" #: apprise/attachment/base.py:82 msgid "Cache Age" msgstr "" #: apprise/attachment/base.py:88 msgid "Forced Mime Type" msgstr "" #: apprise/attachment/base.py:92 msgid "Forced File Name" msgstr "" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "" #: apprise/attachment/http.py:47 apprise/config/http.py:55 msgid "Web Based" msgstr "" #: apprise/attachment/memory.py:44 apprise/config/memory.py:37 msgid "Memory" msgstr "" #: apprise/plugins/__init__.py:278 msgid "Schema" msgstr "Schema" #: apprise/plugins/__init__.py:398 msgid "No dependencies." msgstr "" #: apprise/plugins/__init__.py:401 msgid "Packages are required to function." msgstr "" #: apprise/plugins/__init__.py:405 msgid "Packages are recommended to improve functionality." msgstr "" #: apprise/plugins/africas_talking.py:132 #, fuzzy msgid "App User Name" msgstr "User Name" #: apprise/plugins/africas_talking.py:138 apprise/plugins/brevo.py:157 #: apprise/plugins/burstsms.py:104 apprise/plugins/clicksend.py:98 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:113 #: apprise/plugins/exotel.py:213 apprise/plugins/fcm/__init__.py:143 #: apprise/plugins/flowtriq.py:112 apprise/plugins/httpsms.py:77 #: apprise/plugins/jira.py:230 apprise/plugins/join.py:140 #: apprise/plugins/kavenegar.py:115 apprise/plugins/kumulos.py:87 #: apprise/plugins/mailersend.py:126 apprise/plugins/mailgun.py:146 #: apprise/plugins/messagebird.py:78 apprise/plugins/notifyre.py:130 #: apprise/plugins/octopush.py:136 apprise/plugins/one_signal.py:113 #: apprise/plugins/opsgenie.py:228 apprise/plugins/pagerduty.py:131 #: apprise/plugins/pinglet.py:137 apprise/plugins/pingram.py:175 #: apprise/plugins/postmark.py:117 apprise/plugins/prowl.py:121 #: apprise/plugins/resend.py:107 apprise/plugins/resend.py:145 #: apprise/plugins/sendgrid.py:116 apprise/plugins/seven.py:75 #: apprise/plugins/simplepush.py:101 apprise/plugins/smsmanager.py:106 #: apprise/plugins/smtp2go.py:118 apprise/plugins/sparkpost.py:169 #: apprise/plugins/splunk.py:165 apprise/plugins/techuluspush.py:97 #: apprise/plugins/trigv.py:142 apprise/plugins/twilio.py:194 #: apprise/plugins/vapid/__init__.py:152 apprise/plugins/vonage.py:80 msgid "API Key" msgstr "API Key" #: apprise/plugins/africas_talking.py:145 apprise/plugins/fortysixelks.py:106 #, fuzzy msgid "Target Phone" msgstr "Target Phone No" #: apprise/plugins/africas_talking.py:150 apprise/plugins/aprs.py:187 #: apprise/plugins/bark.py:178 apprise/plugins/brevo.py:174 #: apprise/plugins/bulksms.py:137 apprise/plugins/bulkvs.py:110 #: apprise/plugins/burstsms.py:131 apprise/plugins/clickatell.py:90 #: apprise/plugins/clicksend.py:112 apprise/plugins/d7networks.py:110 #: apprise/plugins/dapnet.py:138 apprise/plugins/dingtalk.py:111 #: apprise/plugins/eight00com.py:128 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:190 #: apprise/plugins/fcm/__init__.py:168 apprise/plugins/flock.py:125 #: apprise/plugins/fortysixelks.py:111 apprise/plugins/home_assistant.py:154 #: apprise/plugins/httpsms.py:97 apprise/plugins/humhub.py:149 #: apprise/plugins/irc/base.py:149 apprise/plugins/join.py:172 #: apprise/plugins/kavenegar.py:135 apprise/plugins/kook.py:176 #: apprise/plugins/line.py:93 apprise/plugins/mailersend.py:143 #: apprise/plugins/mailgun.py:157 apprise/plugins/mastodon.py:202 #: apprise/plugins/matrix/base.py:332 apprise/plugins/mattermost.py:194 #: apprise/plugins/messagebird.py:99 apprise/plugins/mqtt.py:177 #: apprise/plugins/msg91.py:123 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:101 apprise/plugins/notifiarr.py:103 #: apprise/plugins/notifyre.py:143 apprise/plugins/ntfy.py:241 #: apprise/plugins/octopush.py:153 apprise/plugins/office365.py:287 #: apprise/plugins/one_signal.py:141 apprise/plugins/pingram.py:198 #: apprise/plugins/plivo.py:114 apprise/plugins/postmark.py:133 #: apprise/plugins/pushbullet.py:105 apprise/plugins/pushed.py:107 #: apprise/plugins/pushover.py:252 apprise/plugins/pushsafer.py:376 #: apprise/plugins/pushy.py:97 apprise/plugins/reddit.py:170 #: apprise/plugins/resend.py:124 apprise/plugins/revolt.py:112 #: apprise/plugins/ringcentral.py:219 apprise/plugins/rocketchat.py:167 #: apprise/plugins/sendgrid.py:133 apprise/plugins/sendpulse.py:139 #: apprise/plugins/serwersms.py:161 apprise/plugins/seven.py:88 #: apprise/plugins/sfr.py:113 apprise/plugins/signal_api.py:138 #: apprise/plugins/sinch.py:140 apprise/plugins/slack.py:281 #: apprise/plugins/smpp.py:128 apprise/plugins/smsc.py:156 #: apprise/plugins/smseagle.py:172 apprise/plugins/smsmanager.py:119 #: apprise/plugins/sns.py:170 apprise/plugins/telegram.py:372 #: apprise/plugins/threema.py:115 apprise/plugins/trigv.py:155 #: apprise/plugins/twilio.py:174 apprise/plugins/twist.py:123 #: apprise/plugins/twitter.py:193 apprise/plugins/vapid/__init__.py:158 #: apprise/plugins/voipms.py:108 apprise/plugins/vonage.py:108 #: apprise/plugins/wechat.py:218 apprise/plugins/whatsapp.py:166 #: apprise/plugins/wxpusher.py:139 apprise/plugins/xmpp/base.py:134 #: apprise/plugins/zulip.py:153 msgid "Targets" msgstr "Targets" #: apprise/plugins/africas_talking.py:166 #, fuzzy msgid "From" msgstr "Rooms" #: apprise/plugins/africas_talking.py:172 #, fuzzy msgid "SMS Mode" msgstr "Secure Mode" #: apprise/plugins/africas_talking.py:181 apprise/plugins/bulksms.py:170 #: apprise/plugins/bulkvs.py:131 apprise/plugins/burstsms.py:166 #: apprise/plugins/clicksend.py:130 apprise/plugins/d7networks.py:144 #: apprise/plugins/dapnet.py:167 apprise/plugins/exotel.py:222 #: apprise/plugins/home_assistant.py:171 apprise/plugins/jira.py:282 #: apprise/plugins/mailgun.py:194 apprise/plugins/mastodon.py:247 #: apprise/plugins/octopush.py:173 apprise/plugins/one_signal.py:185 #: apprise/plugins/opsgenie.py:307 apprise/plugins/plivo.py:137 #: apprise/plugins/signal_api.py:160 apprise/plugins/smseagle.py:211 #: apprise/plugins/smsmanager.py:152 apprise/plugins/smtp2go.py:151 #: apprise/plugins/sparkpost.py:209 apprise/plugins/twitter.py:218 #, fuzzy msgid "Batch Mode" msgstr "Webhook Mode" #: apprise/plugins/apprise_api.py:102 apprise/plugins/bark.py:152 #: apprise/plugins/custom_form.py:131 apprise/plugins/custom_json.py:113 #: apprise/plugins/custom_xml.py:113 apprise/plugins/emby.py:85 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:119 #: apprise/plugins/flowtriq.py:118 apprise/plugins/fluxer.py:159 #: apprise/plugins/gotify.py:129 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:123 apprise/plugins/humhub.py:138 #: apprise/plugins/irc/base.py:117 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:460 apprise/plugins/mastodon.py:180 #: apprise/plugins/matrix/base.py:287 apprise/plugins/mattermost.py:160 #: apprise/plugins/misskey.py:117 apprise/plugins/mqtt.py:149 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74 #: apprise/plugins/notica.py:126 apprise/plugins/notifico.py:166 #: apprise/plugins/ntfy.py:211 apprise/plugins/parseplatform.py:90 #: apprise/plugins/pinglet.py:143 apprise/plugins/pushdeer.py:75 #: apprise/plugins/pushjet.py:71 apprise/plugins/rocketchat.py:120 #: apprise/plugins/rsyslog.py:180 apprise/plugins/signal_api.py:97 #: apprise/plugins/smseagle.py:135 apprise/plugins/sogs.py:207 #: apprise/plugins/synology.py:84 apprise/plugins/workflows.py:138 #: apprise/plugins/xmpp/base.py:101 msgid "Hostname" msgstr "Hostname" #: apprise/plugins/apprise_api.py:107 apprise/plugins/bark.py:157 #: apprise/plugins/custom_form.py:136 apprise/plugins/custom_json.py:118 #: apprise/plugins/custom_xml.py:118 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:124 apprise/plugins/flowtriq.py:123 #: apprise/plugins/fluxer.py:163 apprise/plugins/gotify.py:140 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:128 #: apprise/plugins/humhub.py:143 apprise/plugins/irc/base.py:122 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:464 #: apprise/plugins/mastodon.py:190 apprise/plugins/matrix/base.py:292 #: apprise/plugins/mattermost.py:176 apprise/plugins/misskey.py:127 #: apprise/plugins/mqtt.py:154 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130 #: apprise/plugins/notifico.py:170 apprise/plugins/ntfy.py:215 #: apprise/plugins/parseplatform.py:95 apprise/plugins/pinglet.py:148 #: apprise/plugins/pushdeer.py:79 apprise/plugins/pushjet.py:76 #: apprise/plugins/rocketchat.py:125 apprise/plugins/rsyslog.py:185 #: apprise/plugins/signal_api.py:102 apprise/plugins/smpp.py:108 #: apprise/plugins/smseagle.py:140 apprise/plugins/sogs.py:212 #: apprise/plugins/synology.py:89 apprise/plugins/trigv.py:163 #: apprise/plugins/workflows.py:143 apprise/plugins/xmpp/base.py:106 msgid "Port" msgstr "Port" #: apprise/plugins/apprise_api.py:113 apprise/plugins/bark.py:163 #: apprise/plugins/bluesky.py:119 apprise/plugins/custom_form.py:142 #: apprise/plugins/custom_json.py:124 apprise/plugins/custom_xml.py:124 #: apprise/plugins/emby.py:97 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:78 apprise/plugins/home_assistant.py:134 #: apprise/plugins/jira.py:236 apprise/plugins/kodi.py:107 #: apprise/plugins/lametric.py:471 apprise/plugins/matrix/base.py:298 #: apprise/plugins/nextcloud.py:127 apprise/plugins/nextcloudtalk.py:85 #: apprise/plugins/notica.py:136 apprise/plugins/notifico.py:176 #: apprise/plugins/ntfy.py:221 apprise/plugins/opsgenie.py:234 #: apprise/plugins/pushjet.py:88 apprise/plugins/rocketchat.py:131 #: apprise/plugins/serwersms.py:131 apprise/plugins/signal_api.py:108 #: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:95 msgid "Username" msgstr "Username" #: apprise/plugins/apprise_api.py:117 apprise/plugins/aprs.py:172 #: apprise/plugins/bark.py:167 apprise/plugins/bluesky.py:124 #: apprise/plugins/bulksms.py:117 apprise/plugins/bulkvs.py:90 #: apprise/plugins/custom_form.py:146 apprise/plugins/custom_json.py:128 #: apprise/plugins/custom_xml.py:128 apprise/plugins/dapnet.py:123 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:101 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:83 #: apprise/plugins/growl.py:151 apprise/plugins/home_assistant.py:138 #: apprise/plugins/humhub.py:133 apprise/plugins/irc/base.py:132 #: apprise/plugins/kodi.py:111 apprise/plugins/matrix/base.py:302 #: apprise/plugins/mqtt.py:165 apprise/plugins/nextcloud.py:131 #: apprise/plugins/nextcloudtalk.py:90 apprise/plugins/notica.py:140 #: apprise/plugins/notifico.py:180 apprise/plugins/ntfy.py:225 #: apprise/plugins/pushjet.py:92 apprise/plugins/reddit.py:145 #: apprise/plugins/rocketchat.py:135 apprise/plugins/serwersms.py:136 #: apprise/plugins/signal_api.py:112 apprise/plugins/simplepush.py:108 #: apprise/plugins/smpp.py:97 apprise/plugins/smsc.py:143 #: apprise/plugins/synology.py:99 apprise/plugins/twist.py:101 #: apprise/plugins/voipms.py:88 apprise/plugins/xmpp/base.py:117 msgid "Password" msgstr "Password" #: apprise/plugins/apprise_api.py:122 apprise/plugins/chanify.py:74 #: apprise/plugins/dingtalk.py:93 apprise/plugins/exotel.py:166 #: apprise/plugins/feishu.py:80 apprise/plugins/gotify.py:123 #: apprise/plugins/kook.py:170 apprise/plugins/mattermost.py:166 #: apprise/plugins/notica.py:119 apprise/plugins/notifiarr.py:91 #: apprise/plugins/ntfy.py:230 apprise/plugins/pushme.py:62 #: apprise/plugins/ryver.py:99 apprise/plugins/serverchan.py:70 #: apprise/plugins/slack.py:331 apprise/plugins/synology.py:104 #: apprise/plugins/zulip.py:136 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:136 apprise/plugins/jira.py:301 #: apprise/plugins/ntfy.py:289 apprise/plugins/opsgenie.py:294 #: apprise/plugins/pagertree.py:133 #, fuzzy msgid "Tags" msgstr "Targets" #: apprise/plugins/apprise_api.py:140 msgid "Query Method" msgstr "" #: apprise/plugins/apprise_api.py:154 apprise/plugins/custom_form.py:175 #: apprise/plugins/custom_json.py:151 apprise/plugins/custom_xml.py:151 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:122 apprise/plugins/notica.py:156 #: apprise/plugins/pagertree.py:142 apprise/plugins/synology.py:129 msgid "HTTP Header" msgstr "HTTP Header" #: apprise/plugins/aprs.py:167 apprise/plugins/bulksms.py:112 #: apprise/plugins/bulkvs.py:85 apprise/plugins/clicksend.py:93 #: apprise/plugins/dapnet.py:118 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:160 #: apprise/plugins/reddit.py:140 apprise/plugins/sendpulse.py:110 #: apprise/plugins/smtp2go.py:108 apprise/plugins/sparkpost.py:159 msgid "User Name" msgstr "User Name" #: apprise/plugins/aprs.py:178 apprise/plugins/aprs.py:199 #: apprise/plugins/dapnet.py:129 apprise/plugins/dapnet.py:162 #, fuzzy msgid "Target Callsign" msgstr "Target Emails" #: apprise/plugins/aprs.py:204 msgid "Resend Delay" msgstr "" #: apprise/plugins/aprs.py:211 msgid "Locale" msgstr "" #: apprise/plugins/bark.py:172 apprise/plugins/fcm/__init__.py:157 #: apprise/plugins/home_assistant.py:149 apprise/plugins/pushbullet.py:89 #: apprise/plugins/pushover.py:240 apprise/plugins/pushsafer.py:366 #: apprise/plugins/pushy.py:85 msgid "Target Device" msgstr "Target Device" #: apprise/plugins/bark.py:191 apprise/plugins/lametric.py:516 #: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:269 #: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110 msgid "Sound" msgstr "Sound" #: apprise/plugins/bark.py:196 apprise/plugins/pushward.py:152 msgid "Level" msgstr "" #: apprise/plugins/bark.py:201 apprise/plugins/pushward.py:182 msgid "Volume" msgstr "" #: apprise/plugins/bark.py:207 apprise/plugins/ntfy.py:271 #: apprise/plugins/pagerduty.py:172 msgid "Click" msgstr "" #: apprise/plugins/bark.py:211 apprise/plugins/pushy.py:114 msgid "Badge" msgstr "" #: apprise/plugins/bark.py:216 msgid "Category" msgstr "" #: apprise/plugins/bark.py:220 apprise/plugins/join.py:158 #: apprise/plugins/pagerduty.py:163 msgid "Group" msgstr "Group" #: apprise/plugins/bark.py:224 apprise/plugins/dbus.py:228 #: apprise/plugins/discord.py:210 apprise/plugins/fcm/__init__.py:197 #: apprise/plugins/flock.py:136 apprise/plugins/fluxer.py:250 #: apprise/plugins/glib.py:188 apprise/plugins/gnome.py:154 #: apprise/plugins/growl.py:175 apprise/plugins/join.py:183 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:108 #: apprise/plugins/macosx.py:115 apprise/plugins/matrix/base.py:343 #: apprise/plugins/mattermost.py:217 apprise/plugins/notifiarr.py:125 #: apprise/plugins/ntfy.py:257 apprise/plugins/one_signal.py:164 #: apprise/plugins/pagerduty.py:191 apprise/plugins/ryver.py:124 #: apprise/plugins/slack.py:299 apprise/plugins/telegram.py:383 #: apprise/plugins/vapid/__init__.py:204 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:175 msgid "Include Image" msgstr "Include Image" #: apprise/plugins/bark.py:230 apprise/plugins/mattermost.py:213 #: apprise/plugins/revolt.py:129 msgid "Icon URL" msgstr "" #: apprise/plugins/bark.py:234 apprise/plugins/streamlabs.py:119 msgid "Call" msgstr "" #: apprise/plugins/bark.py:239 apprise/plugins/pushover.py:301 #, fuzzy msgid "Encryption Key" msgstr "Application Key" #: apprise/plugins/base.py:305 msgid "Overflow Mode" msgstr "Overflow Mode" #: apprise/plugins/base.py:320 msgid "Notify Format" msgstr "Notify Format" #: apprise/plugins/base.py:330 #, fuzzy msgid "Interpret Emojis" msgstr "Target Emails" #: apprise/plugins/base.py:340 msgid "Persistent Storage" msgstr "" #: apprise/plugins/base.py:350 #, fuzzy msgid "Timezone" msgstr "Server Timeout" #: apprise/plugins/base.py:359 #, fuzzy msgid "Service Retry" msgstr "Device ID" #: apprise/plugins/base.py:367 #, fuzzy msgid "Inter-Retry Wait" msgstr "Target Emails" #: apprise/plugins/base.py:375 #, fuzzy msgid "Optional Service" msgstr "Device ID" #: apprise/plugins/blink1.py:151 msgid "blink(1)" msgstr "" #: apprise/plugins/blink1.py:178 #, fuzzy msgid "Serial Number" msgstr "Device ID" #: apprise/plugins/blink1.py:188 #, fuzzy msgid "Duration (ms)" msgstr "Duration" #: apprise/plugins/blink1.py:195 msgid "Fade Time (ms)" msgstr "" #: apprise/plugins/blink1.py:202 msgid "LED Number" msgstr "" #: apprise/plugins/brevo.py:164 apprise/plugins/mailersend.py:133 #: apprise/plugins/postmark.py:123 apprise/plugins/resend.py:114 #: apprise/plugins/sendgrid.py:123 #, fuzzy msgid "Source Email" msgstr "Source JID" #: apprise/plugins/brevo.py:169 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:138 apprise/plugins/mailgun.py:152 #: apprise/plugins/office365.py:282 apprise/plugins/one_signal.py:124 #: apprise/plugins/pingram.py:182 apprise/plugins/postmark.py:128 #: apprise/plugins/pushbullet.py:100 apprise/plugins/pushsafer.py:371 #: apprise/plugins/resend.py:119 apprise/plugins/sendgrid.py:128 #: apprise/plugins/sendpulse.py:134 apprise/plugins/slack.py:264 #: apprise/plugins/threema.py:105 msgid "Target Email" msgstr "Target Email" #: apprise/plugins/brevo.py:185 apprise/plugins/postmark.py:149 #: apprise/plugins/ses.py:205 #, fuzzy msgid "Reply To Email" msgstr "To Email" #: apprise/plugins/brevo.py:193 apprise/plugins/email/base.py:249 #: apprise/plugins/mailersend.py:157 apprise/plugins/mailgun.py:186 #: apprise/plugins/office365.py:307 apprise/plugins/pingram.py:245 #: apprise/plugins/postmark.py:157 apprise/plugins/resend.py:158 #: apprise/plugins/sendgrid.py:153 apprise/plugins/sendpulse.py:169 #: apprise/plugins/ses.py:232 apprise/plugins/smtp2go.py:143 #: apprise/plugins/sparkpost.py:201 msgid "Carbon Copy" msgstr "" #: apprise/plugins/brevo.py:197 apprise/plugins/email/base.py:253 #: apprise/plugins/mailersend.py:161 apprise/plugins/mailgun.py:190 #: apprise/plugins/office365.py:311 apprise/plugins/pingram.py:249 #: apprise/plugins/postmark.py:161 apprise/plugins/resend.py:162 #: apprise/plugins/sendgrid.py:157 apprise/plugins/sendpulse.py:173 #: apprise/plugins/ses.py:236 apprise/plugins/smtp2go.py:147 #: apprise/plugins/sparkpost.py:205 msgid "Blind Carbon Copy" msgstr "" #: apprise/plugins/bulksms.py:123 apprise/plugins/bulkvs.py:103 #: apprise/plugins/burstsms.py:124 apprise/plugins/clickatell.py:83 #: apprise/plugins/clicksend.py:105 apprise/plugins/d7networks.py:103 #: apprise/plugins/dingtalk.py:106 apprise/plugins/eight00com.py:121 #: apprise/plugins/evolution.py:135 apprise/plugins/exotel.py:183 #: apprise/plugins/httpsms.py:90 apprise/plugins/kavenegar.py:128 #: apprise/plugins/messagebird.py:92 apprise/plugins/msg91.py:116 #: apprise/plugins/notifyre.py:136 apprise/plugins/octopush.py:146 #: apprise/plugins/plivo.py:107 apprise/plugins/ringcentral.py:213 #: apprise/plugins/serwersms.py:148 apprise/plugins/seven.py:81 #: apprise/plugins/signal_api.py:124 apprise/plugins/sinch.py:127 #: apprise/plugins/smpp.py:121 apprise/plugins/smsc.py:149 #: apprise/plugins/smseagle.py:151 apprise/plugins/smsmanager.py:112 #: apprise/plugins/sns.py:157 apprise/plugins/threema.py:98 #: apprise/plugins/twilio.py:161 apprise/plugins/voipms.py:101 #: apprise/plugins/vonage.py:101 apprise/plugins/whatsapp.py:147 msgid "Target Phone No" msgstr "Target Phone No" #: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:246 #, fuzzy msgid "Target Group" msgstr "Target Topic" #: apprise/plugins/bulksms.py:149 apprise/plugins/bulkvs.py:96 #: apprise/plugins/bulkvs.py:122 apprise/plugins/clickatell.py:78 #: apprise/plugins/eight00com.py:114 apprise/plugins/eight00com.py:142 #: apprise/plugins/fortysixelks.py:100 apprise/plugins/httpsms.py:83 #: apprise/plugins/httpsms.py:115 apprise/plugins/ringcentral.py:207 #: apprise/plugins/signal_api.py:117 apprise/plugins/sinch.py:120 #: apprise/plugins/smpp.py:114 apprise/plugins/smsmanager.py:134 #: apprise/plugins/twilio.py:154 apprise/plugins/voipms.py:94 #: apprise/plugins/vonage.py:94 msgid "From Phone No" msgstr "From Phone No" #: apprise/plugins/bulksms.py:155 #, fuzzy msgid "Route Group" msgstr "Group" #: apprise/plugins/bulksms.py:162 apprise/plugins/d7networks.py:136 #: apprise/plugins/exotel.py:228 msgid "Unicode Characters" msgstr "" #: apprise/plugins/burstsms.py:111 apprise/plugins/threema.py:92 #: apprise/plugins/vonage.py:87 #, fuzzy msgid "API Secret" msgstr "Application Secret" #: apprise/plugins/burstsms.py:118 apprise/plugins/smsc.py:171 #, fuzzy msgid "Sender ID" msgstr "To User ID" #: apprise/plugins/burstsms.py:152 msgid "Country" msgstr "" #: apprise/plugins/burstsms.py:161 msgid "validity" msgstr "" #: apprise/plugins/chanify.py:47 msgid "Chanify" msgstr "" #: apprise/plugins/chime.py:130 apprise/plugins/discord.py:144 #: apprise/plugins/fluxer.py:174 apprise/plugins/ifttt.py:103 #: apprise/plugins/zoom.py:139 msgid "Webhook ID" msgstr "Webhook ID" #: apprise/plugins/chime.py:136 apprise/plugins/discord.py:150 #: apprise/plugins/fluxer.py:181 apprise/plugins/google_chat.py:127 #: apprise/plugins/stackfield.py:92 apprise/plugins/webexteams.py:171 msgid "Webhook Token" msgstr "Webhook Token" #: apprise/plugins/clickatell.py:45 msgid "Clickatell" msgstr "" #: apprise/plugins/clickatell.py:72 apprise/plugins/eight00com.py:108 #: apprise/plugins/rocketchat.py:140 #, fuzzy msgid "API Token" msgstr "API Key" #: apprise/plugins/custom_form.py:158 apprise/plugins/custom_json.py:140 #: apprise/plugins/custom_xml.py:140 msgid "Fetch Method" msgstr "" #: apprise/plugins/custom_form.py:164 msgid "Attach File As" msgstr "" #: apprise/plugins/custom_form.py:179 apprise/plugins/custom_json.py:155 #: apprise/plugins/custom_xml.py:155 apprise/plugins/pagertree.py:146 msgid "Payload Extras" msgstr "" #: apprise/plugins/custom_form.py:183 apprise/plugins/custom_json.py:159 #: apprise/plugins/custom_xml.py:159 msgid "GET Params" msgstr "" #: apprise/plugins/d7networks.py:97 #, fuzzy msgid "API Access Token" msgstr "Access Token" #: apprise/plugins/d7networks.py:127 apprise/plugins/seven.py:105 msgid "Originating Address" msgstr "" #: apprise/plugins/dapnet.py:150 apprise/plugins/exotel.py:240 #: apprise/plugins/gotify.py:153 apprise/plugins/growl.py:163 #: apprise/plugins/jira.py:287 apprise/plugins/join.py:189 #: apprise/plugins/lametric.py:494 apprise/plugins/ntfy.py:283 #: apprise/plugins/opsgenie.py:280 apprise/plugins/pinglet.py:177 #: apprise/plugins/prowl.py:141 apprise/plugins/pushover.py:263 #: apprise/plugins/pushsafer.py:387 apprise/plugins/smseagle.py:187 msgid "Priority" msgstr "Priority" #: apprise/plugins/dapnet.py:156 msgid "Transmitter Groups" msgstr "" #: apprise/plugins/dbus.py:156 msgid "libdbus-1.so.x must be installed." msgstr "" #: apprise/plugins/dbus.py:160 msgid "DBus Notification" msgstr "" #: apprise/plugins/dbus.py:204 apprise/plugins/glib.py:165 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:128 #: apprise/plugins/trigv.py:189 msgid "Urgency" msgstr "Urgency" #: apprise/plugins/dbus.py:216 apprise/plugins/glib.py:176 msgid "X-Axis" msgstr "X-Axis" #: apprise/plugins/dbus.py:222 apprise/plugins/glib.py:182 msgid "Y-Axis" msgstr "Y-Axis" #: apprise/plugins/dingtalk.py:100 apprise/plugins/signl4.py:76 #, fuzzy msgid "Secret" msgstr "Secret Key" #: apprise/plugins/discord.py:139 apprise/plugins/flock.py:106 #: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106 #: apprise/plugins/slack.py:216 apprise/plugins/viber.py:99 #: apprise/plugins/zulip.py:124 msgid "Bot Name" msgstr "Bot Name" #: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:201 msgid "Text To Speech" msgstr "Text To Speech" #: apprise/plugins/discord.py:168 apprise/plugins/fluxer.py:206 msgid "Avatar Image" msgstr "Avatar Image" #: apprise/plugins/discord.py:173 apprise/plugins/fluxer.py:211 #: apprise/plugins/ntfy.py:263 #, fuzzy msgid "Avatar URL" msgstr "Avatar Image" #: apprise/plugins/discord.py:177 apprise/plugins/fluxer.py:215 #: apprise/plugins/pushover.py:275 apprise/plugins/trigv.py:180 msgid "URL" msgstr "" #: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:222 msgid "Thread ID" msgstr "" #: apprise/plugins/discord.py:190 apprise/plugins/fluxer.py:230 msgid "Display Footer" msgstr "Display Footer" #: apprise/plugins/discord.py:195 apprise/plugins/fluxer.py:235 msgid "Footer Logo" msgstr "Footer Logo" #: apprise/plugins/discord.py:200 apprise/plugins/fluxer.py:240 #, fuzzy msgid "Use Fields" msgstr "To User ID" #: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:245 msgid "Discord Flags" msgstr "" #: apprise/plugins/discord.py:219 apprise/plugins/fluxer.py:256 msgid "Ping Users/Roles" msgstr "" #: apprise/plugins/discord.py:223 apprise/plugins/slack.py:338 #: apprise/plugins/workflows.py:200 msgid "Template Path" msgstr "" #: apprise/plugins/discord.py:231 msgid "Batch Attachments" msgstr "" #: apprise/plugins/discord.py:241 apprise/plugins/mailgun.py:208 #: apprise/plugins/pingram.py:258 apprise/plugins/slack.py:348 #: apprise/plugins/sparkpost.py:223 apprise/plugins/telegram.py:437 #: apprise/plugins/workflows.py:221 #, fuzzy msgid "Template Tokens" msgstr "Remove Tokens" #: apprise/plugins/dot.py:142 #, fuzzy msgid "Device Serial Number" msgstr "Device ID" #: apprise/plugins/dot.py:155 #, fuzzy msgid "API Mode" msgstr "API Key" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "" #: apprise/plugins/dot.py:180 msgid "Link" msgstr "" #: apprise/plugins/dot.py:184 #, fuzzy msgid "Border" msgstr "Modal" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "" #: apprise/plugins/dot.py:203 #, fuzzy msgid "Task Key" msgstr "Secret Key" #: apprise/plugins/emby.py:112 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/gotify.py:134 #: apprise/plugins/mattermost.py:172 apprise/plugins/notica.py:145 #: apprise/plugins/pinglet.py:154 msgid "Path" msgstr "" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Server Timeout" #: apprise/plugins/evolution.py:130 #, fuzzy msgid "Instance Name" msgstr "Device ID" #: apprise/plugins/exotel.py:160 apprise/plugins/sinch.py:106 #: apprise/plugins/twilio.py:140 msgid "Account SID" msgstr "Account SID" #: apprise/plugins/exotel.py:172 #, fuzzy msgid "From Phone No / Sender ID" msgstr "From Phone No" #: apprise/plugins/exotel.py:233 apprise/plugins/jira.py:275 #: apprise/plugins/mailgun.py:176 apprise/plugins/opsgenie.py:273 #: apprise/plugins/pagerduty.py:176 apprise/plugins/pingram.py:220 #: apprise/plugins/sparkpost.py:191 msgid "Region Name" msgstr "Region Name" #: apprise/plugins/feishu.py:49 msgid "Feishu" msgstr "" #: apprise/plugins/flock.py:99 apprise/plugins/twitter.py:175 msgid "Access Key" msgstr "Access Key" #: apprise/plugins/flock.py:111 msgid "To User ID" msgstr "To User ID" #: apprise/plugins/flock.py:118 msgid "To Channel ID" msgstr "To Channel ID" #: apprise/plugins/flowtriq.py:129 apprise/plugins/matrix/base.py:365 #, fuzzy msgid "Webhook Path" msgstr "Webhook" #: apprise/plugins/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188 #: apprise/plugins/fluxer.py:195 apprise/plugins/kook.py:196 #: apprise/plugins/lametric.py:510 apprise/plugins/mattermost.py:229 #: apprise/plugins/notifyre.py:157 apprise/plugins/ntfy.py:297 #: apprise/plugins/office365.py:319 apprise/plugins/pingram.py:226 #: apprise/plugins/sns.py:202 apprise/plugins/vapid/__init__.py:169 #: apprise/plugins/webexteams.py:197 apprise/plugins/zoom.py:158 #, fuzzy msgid "Mode" msgstr "Modal" #: apprise/plugins/fluxer.py:226 #, fuzzy msgid "Thread Name" msgstr "Bot Name" #: apprise/plugins/fortysixelks.py:58 msgid "46elks" msgstr "" #: apprise/plugins/fortysixelks.py:89 #, fuzzy msgid "API Username" msgstr "User Name" #: apprise/plugins/fortysixelks.py:94 #, fuzzy msgid "API Password" msgstr "Password" #: apprise/plugins/freemobile.py:48 msgid "Free-Mobile" msgstr "" #: apprise/plugins/glib.py:123 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "" #: apprise/plugins/glib.py:127 #, fuzzy msgid "GLib Notification" msgstr "Duration" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "" #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "" #: apprise/plugins/google_chat.py:115 msgid "Workspace" msgstr "" #: apprise/plugins/google_chat.py:121 #, fuzzy msgid "Webhook Key" msgstr "Webhook Token" #: apprise/plugins/google_chat.py:133 #, fuzzy msgid "Thread Key" msgstr "Secret Key" #: apprise/plugins/groupme.py:123 #, fuzzy msgid "Bot ID" msgstr "Webhook ID" #: apprise/plugins/groupme.py:130 apprise/plugins/line.py:82 #: apprise/plugins/mastodon.py:185 apprise/plugins/matrix/base.py:307 #: apprise/plugins/misskey.py:122 apprise/plugins/pushbullet.py:83 #: apprise/plugins/pushover.py:234 apprise/plugins/smseagle.py:146 #: apprise/plugins/spugpush.py:68 apprise/plugins/streamlabs.py:105 #: apprise/plugins/whatsapp.py:121 msgid "Access Token" msgstr "Access Token" #: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:195 #: apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Version" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "" #: apprise/plugins/home_assistant.py:143 #, fuzzy msgid "Long-Lived Access Token" msgstr "Access Token" #: apprise/plugins/home_assistant.py:166 msgid "Notification ID" msgstr "" #: apprise/plugins/home_assistant.py:183 msgid "Path Prefix" msgstr "" #: apprise/plugins/humhub.py:127 #, fuzzy msgid "Token or Username" msgstr "Username" #: apprise/plugins/ifttt.py:109 msgid "Events" msgstr "Events" #: apprise/plugins/ifttt.py:129 msgid "Add Tokens" msgstr "Add Tokens" #: apprise/plugins/ifttt.py:133 msgid "Remove Tokens" msgstr "Remove Tokens" #: apprise/plugins/jira.py:240 apprise/plugins/opsgenie.py:238 #, fuzzy msgid "Target Escalation" msgstr "Target Chat ID" #: apprise/plugins/jira.py:246 apprise/plugins/opsgenie.py:244 #, fuzzy msgid "Target Schedule" msgstr "Target Channel" #: apprise/plugins/irc/base.py:137 apprise/plugins/jira.py:252 #: apprise/plugins/line.py:88 apprise/plugins/mastodon.py:196 #: apprise/plugins/matrix/base.py:314 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:129 apprise/plugins/opsgenie.py:250 #: apprise/plugins/pushed.py:95 apprise/plugins/rocketchat.py:156 #: apprise/plugins/slack.py:269 apprise/plugins/twitter.py:187 #: apprise/plugins/wechat.py:198 apprise/plugins/xmpp/base.py:123 #: apprise/plugins/zulip.py:143 msgid "Target User" msgstr "Target User" #: apprise/plugins/jira.py:258 apprise/plugins/opsgenie.py:256 #, fuzzy msgid "Target Team" msgstr "Target Email" #: apprise/plugins/jira.py:264 apprise/plugins/opsgenie.py:262 #, fuzzy msgid "Targets " msgstr "Targets" #: apprise/plugins/jira.py:293 apprise/plugins/opsgenie.py:286 msgid "Entity" msgstr "" #: apprise/plugins/jira.py:297 apprise/plugins/opsgenie.py:290 msgid "Alias" msgstr "" #: apprise/plugins/jira.py:308 apprise/plugins/opsgenie.py:298 #: apprise/plugins/pagertree.py:118 apprise/plugins/splunk.py:202 #, fuzzy msgid "Action" msgstr "Duration" #: apprise/plugins/jira.py:319 apprise/plugins/opsgenie.py:317 #, fuzzy msgid "Details" msgstr "Target Emails" #: apprise/plugins/jira.py:323 apprise/plugins/opsgenie.py:321 #: apprise/plugins/splunk.py:213 msgid "Action Mapping" msgstr "" #: apprise/plugins/join.py:147 msgid "Device ID" msgstr "Device ID" #: apprise/plugins/join.py:153 #, fuzzy msgid "Device Name" msgstr "Device ID" #: apprise/plugins/kavenegar.py:122 apprise/plugins/messagebird.py:85 #: apprise/plugins/notifyre.py:163 apprise/plugins/plivo.py:100 #, fuzzy msgid "Source Phone No" msgstr "Target Phone No" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:141 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Duration" #: apprise/plugins/kumulos.py:99 #, fuzzy msgid "Server Key" msgstr "Secret Key" #: apprise/plugins/lametric.py:436 #, fuzzy msgid "Device API Key" msgstr "Device ID" #: apprise/plugins/lametric.py:442 apprise/plugins/one_signal.py:102 #: apprise/plugins/parseplatform.py:101 msgid "App ID" msgstr "" #: apprise/plugins/lametric.py:448 #, fuzzy msgid "App Version" msgstr "Version" #: apprise/plugins/lametric.py:455 #, fuzzy msgid "App Access Token" msgstr "Access Token" #: apprise/plugins/lametric.py:500 msgid "Custom Icon" msgstr "" #: apprise/plugins/lametric.py:504 msgid "Icon Type" msgstr "" #: apprise/plugins/lametric.py:521 msgid "Cycles" msgstr "" #: apprise/plugins/lark.py:47 msgid "Lark (Feishu)" msgstr "" #: apprise/plugins/lark.py:67 apprise/plugins/revolt.py:98 #: apprise/plugins/telegram.py:357 msgid "Bot Token" msgstr "Bot Token" #: apprise/plugins/macosx.py:65 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" #: apprise/plugins/macosx.py:72 msgid "MacOSX Notification" msgstr "" #: apprise/plugins/macosx.py:128 msgid "Open/Click URL" msgstr "" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:165 #: apprise/plugins/office365.py:315 apprise/plugins/pingram.py:231 #: apprise/plugins/resend.py:150 msgid "Reply To" msgstr "" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:141 #: apprise/plugins/sendpulse.py:115 apprise/plugins/smtp2go.py:113 #: apprise/plugins/sparkpost.py:164 msgid "Domain" msgstr "Domain" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:168 #: apprise/plugins/postmark.py:144 apprise/plugins/resend.py:140 #: apprise/plugins/ses.py:210 apprise/plugins/smtp2go.py:135 #: apprise/plugins/sparkpost.py:186 msgid "From Name" msgstr "From Name" #: apprise/plugins/email/base.py:262 apprise/plugins/mailgun.py:204 #: apprise/plugins/smtp2go.py:161 apprise/plugins/sparkpost.py:219 #, fuzzy msgid "Email Header" msgstr "HTTP Header" #: apprise/plugins/mastodon.py:216 apprise/plugins/misskey.py:143 msgid "Visibility" msgstr "" #: apprise/plugins/mastodon.py:222 msgid "Spoiler Text" msgstr "" #: apprise/plugins/mastodon.py:226 msgid "Idempotency-Key" msgstr "" #: apprise/plugins/mastodon.py:230 msgid "Language Code" msgstr "" #: apprise/plugins/mastodon.py:234 apprise/plugins/twitter.py:210 msgid "Cache Results" msgstr "" #: apprise/plugins/mastodon.py:239 msgid "Sensitive Attachments" msgstr "" #: apprise/plugins/mastodon.py:255 msgid "Ping Users/Tags" msgstr "" #: apprise/plugins/irc/base.py:128 apprise/plugins/mattermost.py:156 #: apprise/plugins/xmpp/base.py:112 #, fuzzy msgid "User" msgstr "Username" #: apprise/plugins/irc/base.py:143 apprise/plugins/mattermost.py:182 #: apprise/plugins/notifiarr.py:97 apprise/plugins/pushbullet.py:94 #: apprise/plugins/pushed.py:101 apprise/plugins/rocketchat.py:150 #: apprise/plugins/slack.py:275 apprise/plugins/trigv.py:150 #: apprise/plugins/twist.py:112 apprise/plugins/xmpp/base.py:128 msgid "Target Channel" msgstr "Target Channel" #: apprise/plugins/mattermost.py:188 apprise/plugins/twist.py:118 #, fuzzy msgid "Target Channel ID" msgstr "Target Channel" #: apprise/plugins/mqtt.py:171 #, fuzzy msgid "Target Queue" msgstr "Target User" #: apprise/plugins/mqtt.py:188 msgid "QOS" msgstr "" #: apprise/plugins/mqtt.py:201 apprise/plugins/office365.py:269 #: apprise/plugins/ringcentral.py:192 apprise/plugins/sendpulse.py:120 #, fuzzy msgid "Client ID" msgstr "Account SID" #: apprise/plugins/mqtt.py:205 msgid "Use Session" msgstr "" #: apprise/plugins/mqtt.py:210 msgid "Retain Messages" msgstr "" #: apprise/plugins/msg91.py:102 apprise/plugins/sendpulse.py:156 msgid "Template ID" msgstr "" #: apprise/plugins/msg91.py:109 #, fuzzy msgid "Authentication Key" msgstr "Application Key" #: apprise/plugins/msg91.py:138 msgid "Short URL" msgstr "" #: apprise/plugins/msg91.py:148 apprise/plugins/whatsapp.py:203 msgid "Template Mapping" msgstr "" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:113 msgid "URL Prefix" msgstr "" #: apprise/plugins/nextcloudtalk.py:43 msgid "Nextcloud Talk" msgstr "" #: apprise/plugins/nextcloudtalk.py:96 #, fuzzy msgid "Room ID" msgstr "Target Room ID" #: apprise/plugins/notifiarr.py:121 msgid "Discord Event ID" msgstr "" #: apprise/plugins/notifiarr.py:131 apprise/plugins/pagerduty.py:145 #, fuzzy msgid "Source" msgstr "Source JID" #: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:187 #, fuzzy msgid "Project ID" msgstr "Target JID" #: apprise/plugins/notifico.py:196 msgid "Message Hook" msgstr "" #: apprise/plugins/notifico.py:211 msgid "IRC Colors" msgstr "" #: apprise/plugins/notifico.py:217 msgid "Prefix" msgstr "" #: apprise/plugins/notifyre.py:168 #, fuzzy msgid "Campaign Name" msgstr "User Name" #: apprise/plugins/notifyre.py:172 apprise/plugins/whatsapp.py:128 msgid "Template Name" msgstr "" #: apprise/plugins/notifyre.py:176 #, fuzzy msgid "Client Reference" msgstr "Access Secret" #: apprise/plugins/notifyre.py:180 msgid "High Quality" msgstr "" #: apprise/plugins/notifyre.py:185 #, fuzzy msgid "Fax Header" msgstr "HTTP Header" #: apprise/plugins/ntfy.py:235 apprise/plugins/pinglet.py:165 msgid "Topic" msgstr "" #: apprise/plugins/ntfy.py:253 msgid "Attach" msgstr "" #: apprise/plugins/ntfy.py:267 msgid "Attach Filename" msgstr "" #: apprise/plugins/ntfy.py:275 msgid "Delay" msgstr "" #: apprise/plugins/ntfy.py:279 apprise/plugins/twist.py:107 #, fuzzy msgid "Email" msgstr "To Email" #: apprise/plugins/ntfy.py:293 #, fuzzy msgid "Actions" msgstr "Duration" #: apprise/plugins/ntfy.py:306 #, fuzzy msgid "Authentication Type" msgstr "Authorization Token" #: apprise/plugins/octopush.py:130 #, fuzzy msgid "API Login" msgstr "API Key" #: apprise/plugins/octopush.py:142 #, fuzzy msgid "Sender" msgstr "To User ID" #: apprise/plugins/octopush.py:178 msgid "Accept Replies" msgstr "" #: apprise/plugins/octopush.py:183 msgid "Purpose" msgstr "" #: apprise/plugins/octopush.py:189 msgid "Type" msgstr "" #: apprise/plugins/office365.py:257 #, fuzzy msgid "Tenant Domain" msgstr "Domain" #: apprise/plugins/office365.py:264 msgid "Account Email or Object ID" msgstr "" #: apprise/plugins/office365.py:276 apprise/plugins/ringcentral.py:199 #: apprise/plugins/sendpulse.py:127 #, fuzzy msgid "Client Secret" msgstr "Access Secret" #: apprise/plugins/office365.py:325 msgid "Save to Sent Items" msgstr "" #: apprise/plugins/one_signal.py:108 apprise/plugins/sendgrid.py:146 msgid "Template" msgstr "" #: apprise/plugins/one_signal.py:119 #, fuzzy msgid "Target Player ID" msgstr "Target Tag ID" #: apprise/plugins/one_signal.py:135 #, fuzzy msgid "Include Segment" msgstr "Include Image" #: apprise/plugins/one_signal.py:155 msgid "Subtitle" msgstr "" #: apprise/plugins/one_signal.py:159 apprise/plugins/sfr.py:125 #: apprise/plugins/whatsapp.py:141 msgid "Language" msgstr "" #: apprise/plugins/one_signal.py:170 msgid "Enable Contents" msgstr "" #: apprise/plugins/one_signal.py:176 msgid "Decode Template Args" msgstr "" #: apprise/plugins/one_signal.py:195 msgid "Custom Data" msgstr "" #: apprise/plugins/one_signal.py:199 msgid "Postback Data" msgstr "" #: apprise/plugins/pagerduty.py:138 apprise/plugins/pushward.py:134 #: apprise/plugins/spike.py:68 #, fuzzy msgid "Integration Key" msgstr "Application Key" #: apprise/plugins/pagerduty.py:151 #, fuzzy msgid "Component" msgstr "From Phone No" #: apprise/plugins/pagerduty.py:167 msgid "Class" msgstr "" #: apprise/plugins/pagerduty.py:185 msgid "Severity" msgstr "" #: apprise/plugins/pagerduty.py:202 #, fuzzy msgid "Custom Details" msgstr "To Email" #: apprise/plugins/pagertree.py:105 msgid "Integration ID" msgstr "" #: apprise/plugins/pagertree.py:124 msgid "Third Party ID" msgstr "" #: apprise/plugins/pagertree.py:150 msgid "Meta Extras" msgstr "" #: apprise/plugins/parseplatform.py:107 #, fuzzy msgid "Master Key" msgstr "User Key" #: apprise/plugins/parseplatform.py:120 #, fuzzy msgid "Device" msgstr "Device ID" #: apprise/plugins/pinglet.py:160 #, fuzzy msgid "Namespace" msgstr "Username" #: apprise/plugins/pinglet.py:191 #, fuzzy msgid "Badges" msgstr "Targets" #: apprise/plugins/pinglet.py:195 msgid "Metadata" msgstr "" #: apprise/plugins/matrix/base.py:377 apprise/plugins/pingram.py:169 msgid "Message Type" msgstr "" #: apprise/plugins/pingram.py:187 #, fuzzy msgid "Target ID" msgstr "Target User" #: apprise/plugins/pingram.py:193 #, fuzzy msgid "Target SMS" msgstr "Targets" #: apprise/plugins/pingram.py:215 msgid "Channels" msgstr "Channels" #: apprise/plugins/email/base.py:178 apprise/plugins/pingram.py:236 #: apprise/plugins/sendpulse.py:150 apprise/plugins/ses.py:163 msgid "From Email" msgstr "From Email" #: apprise/plugins/plivo.py:88 #, fuzzy msgid "Auth ID" msgstr "Account SID" #: apprise/plugins/plivo.py:94 apprise/plugins/sinch.py:113 #: apprise/plugins/twilio.py:147 msgid "Auth Token" msgstr "Auth Token" #: apprise/plugins/prowl.py:128 msgid "Provider Key" msgstr "Provider Key" #: apprise/plugins/pushdeer.py:85 #, fuzzy msgid "Pushkey" msgstr "User Key" #: apprise/plugins/pushed.py:83 msgid "Application Key" msgstr "Application Key" #: apprise/plugins/pushed.py:89 apprise/plugins/reddit.py:158 msgid "Application Secret" msgstr "Application Secret" #: apprise/plugins/pushjet.py:82 msgid "Secret Key" msgstr "Secret Key" #: apprise/plugins/pushme.py:81 apprise/plugins/signal_api.py:152 #: apprise/plugins/smseagle.py:193 msgid "Show Status" msgstr "" #: apprise/plugins/pushover.py:228 msgid "User Key" msgstr "User Key" #: apprise/plugins/pushover.py:280 msgid "URL Title" msgstr "" #: apprise/plugins/pushover.py:285 msgid "Emergency Retry Interval" msgstr "" #: apprise/plugins/pushover.py:291 msgid "Expire" msgstr "" #: apprise/plugins/pushover.py:308 msgid "E2EE" msgstr "" #: apprise/plugins/pushplus.py:172 msgid "Pushplus" msgstr "" #: apprise/plugins/pushplus.py:210 apprise/plugins/qq.py:66 #, fuzzy msgid "User Token" msgstr "User Key" #: apprise/plugins/pushplus.py:219 msgid "Group Topics" msgstr "" #: apprise/plugins/pushplus.py:240 #, fuzzy msgid "Channel" msgstr "Channels" #: apprise/plugins/pushplus.py:257 #, fuzzy msgid "Webhook Name" msgstr "Webhook Mode" #: apprise/plugins/pushsafer.py:360 #, fuzzy msgid "Private Key" msgstr "Provider Key" #: apprise/plugins/pushsafer.py:397 #, fuzzy msgid "Vibration" msgstr "Duration" #: apprise/plugins/pushward.py:158 msgid "Info Level" msgstr "" #: apprise/plugins/pushward.py:164 #, fuzzy msgid "Success Level" msgstr "Access Secret" #: apprise/plugins/pushward.py:170 msgid "Warning Level" msgstr "" #: apprise/plugins/pushward.py:176 msgid "Failure Level" msgstr "" #: apprise/plugins/pushy.py:79 #, fuzzy msgid "Secret API Key" msgstr "Secret Key" #: apprise/plugins/fcm/__init__.py:162 apprise/plugins/pushy.py:91 #: apprise/plugins/sns.py:163 apprise/plugins/wxpusher.py:128 msgid "Target Topic" msgstr "Target Topic" #: apprise/plugins/qq.py:46 msgid "QQ Push" msgstr "" #: apprise/plugins/reddit.py:151 #, fuzzy msgid "Application ID" msgstr "Application Key" #: apprise/plugins/reddit.py:165 #, fuzzy msgid "Target Subreddit" msgstr "Target User" #: apprise/plugins/reddit.py:182 msgid "Kind" msgstr "" #: apprise/plugins/reddit.py:188 msgid "Flair ID" msgstr "" #: apprise/plugins/reddit.py:193 msgid "Flair Text" msgstr "" #: apprise/plugins/reddit.py:198 msgid "NSFW" msgstr "" #: apprise/plugins/reddit.py:204 msgid "Is Ad?" msgstr "" #: apprise/plugins/reddit.py:210 msgid "Send Replies" msgstr "" #: apprise/plugins/reddit.py:216 msgid "Is Spoiler" msgstr "" #: apprise/plugins/reddit.py:222 msgid "Resubmit Flag" msgstr "" #: apprise/plugins/resend.py:135 #, fuzzy msgid "From Address" msgstr "From Name" #: apprise/plugins/revolt.py:104 #, fuzzy msgid "Channel ID" msgstr "To Channel ID" #: apprise/plugins/revolt.py:131 msgid "Embed URL" msgstr "" #: apprise/plugins/ringcentral.py:186 #, fuzzy msgid "Token / Password" msgstr "Password" #: apprise/plugins/ringcentral.py:239 #, fuzzy msgid "Environment" msgstr "Events" #: apprise/plugins/ringcentral.py:252 #, fuzzy msgid "Authentication Mode" msgstr "Application Key" #: apprise/plugins/rocketchat.py:146 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:320 apprise/plugins/rocketchat.py:162 msgid "Target Room ID" msgstr "Target Room ID" #: apprise/plugins/matrix/base.py:359 apprise/plugins/rocketchat.py:178 #: apprise/plugins/ryver.py:118 msgid "Webhook Mode" msgstr "Webhook Mode" #: apprise/plugins/rocketchat.py:183 msgid "Use Avatar" msgstr "Use Avatar" #: apprise/plugins/rsyslog.py:173 apprise/plugins/syslog.py:144 msgid "Facility" msgstr "" #: apprise/plugins/rsyslog.py:203 apprise/plugins/syslog.py:161 msgid "Log PID" msgstr "" #: apprise/plugins/ryver.py:93 apprise/plugins/zulip.py:130 msgid "Organization" msgstr "Organization" #: apprise/plugins/sendgrid.py:166 apprise/plugins/sendpulse.py:182 msgid "Template Data" msgstr "" #: apprise/plugins/serwersms.py:142 apprise/plugins/sfr.py:131 #, fuzzy msgid "Sender Name" msgstr "User Name" #: apprise/plugins/serwersms.py:155 apprise/plugins/signal_api.py:131 #: apprise/plugins/smseagle.py:158 apprise/plugins/whatsapp.py:159 #, fuzzy msgid "Target Group ID" msgstr "Target Room ID" #: apprise/plugins/ses.py:157 apprise/plugins/sns.py:132 #, fuzzy msgid "Session Token" msgstr "Access Token" #: apprise/plugins/ses.py:169 apprise/plugins/sns.py:138 msgid "Access Key ID" msgstr "Access Key ID" #: apprise/plugins/ses.py:175 apprise/plugins/sns.py:144 msgid "Secret Access Key" msgstr "Secret Access Key" #: apprise/plugins/ses.py:181 apprise/plugins/sinch.py:157 #: apprise/plugins/sns.py:150 msgid "Region" msgstr "Region" #: apprise/plugins/ses.py:188 apprise/plugins/smtp2go.py:124 #: apprise/plugins/sparkpost.py:175 msgid "Target Emails" msgstr "Target Emails" #: apprise/plugins/seven.py:113 apprise/plugins/smseagle.py:203 msgid "Flash" msgstr "" #: apprise/plugins/seven.py:117 msgid "Label" msgstr "" #: apprise/plugins/sfr.py:58 msgid "SociΓ©tΓ© FranΓ§aise du RadiotΓ©lΓ©phone" msgstr "" #: apprise/plugins/sfr.py:90 #, fuzzy msgid "Service ID" msgstr "Device ID" #: apprise/plugins/sfr.py:95 #, fuzzy msgid "Service Password" msgstr "Password" #: apprise/plugins/sfr.py:101 #, fuzzy msgid "Space ID" msgstr "Source JID" #: apprise/plugins/sfr.py:107 msgid "Recipient Phone Number" msgstr "" #: apprise/plugins/sfr.py:138 msgid "Media Type" msgstr "" #: apprise/plugins/sfr.py:145 #, fuzzy msgid "Timeout" msgstr "Server Timeout" #: apprise/plugins/sfr.py:151 #, fuzzy msgid "TTS Voice" msgstr "Target Device" #: apprise/plugins/signl4.py:89 #, fuzzy msgid "Service" msgstr "Device ID" #: apprise/plugins/signl4.py:93 #, fuzzy msgid "Location" msgstr "Duration" #: apprise/plugins/signl4.py:97 msgid "Alerting Scenario" msgstr "" #: apprise/plugins/signl4.py:101 msgid "Filtering" msgstr "" #: apprise/plugins/signl4.py:106 #, fuzzy msgid "External ID" msgstr "To User ID" #: apprise/plugins/signl4.py:110 #, fuzzy msgid "Status" msgstr "Targets" #: apprise/plugins/simplepush.py:113 msgid "Salt" msgstr "" #: apprise/plugins/simplepush.py:126 #, fuzzy msgid "Event" msgstr "Events" #: apprise/plugins/sinch.py:134 apprise/plugins/twilio.py:168 msgid "Target Short Code" msgstr "Target Short Code" #: apprise/plugins/slack.py:224 #, fuzzy msgid "OAuth Access Token" msgstr "Access Token" #: apprise/plugins/slack.py:233 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:242 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:251 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:258 msgid "Target Encoded ID" msgstr "Target Encoded ID" #: apprise/plugins/slack.py:287 #, fuzzy msgid "Workflow Path" msgstr "Overflow Mode" #: apprise/plugins/slack.py:305 #, fuzzy msgid "Include Footer" msgstr "Include Image" #: apprise/plugins/slack.py:313 msgid "Use Blocks" msgstr "" #: apprise/plugins/slack.py:319 #, fuzzy msgid "Include Timestamp" msgstr "Include Image" #: apprise/plugins/slack.py:325 apprise/plugins/twitter.py:204 #, fuzzy msgid "Message Mode" msgstr "Secure Mode" #: apprise/plugins/smpp.py:61 msgid "SMPP" msgstr "" #: apprise/plugins/smpp.py:103 #, fuzzy msgid "Host" msgstr "Hostname" #: apprise/plugins/smsc.py:138 #, fuzzy msgid "Login" msgstr "API Key" #: apprise/plugins/smsc.py:175 msgid "Transliterate" msgstr "" #: apprise/plugins/smseagle.py:165 #, fuzzy msgid "Target Contact" msgstr "Target Chat ID" #: apprise/plugins/smseagle.py:198 msgid "Test Only" msgstr "" #: apprise/plugins/smsmanager.py:143 msgid "Gateway" msgstr "" #: apprise/plugins/sogs.py:219 #, fuzzy msgid "Public Key" msgstr "API Key" #: apprise/plugins/sogs.py:226 #, fuzzy msgid "Seed" msgstr "To User ID" #: apprise/plugins/sogs.py:233 #, fuzzy msgid "Rooms" msgstr "Target Room ID" #: apprise/plugins/spike.py:48 msgid "Spike.sh" msgstr "" #: apprise/plugins/splunk.py:117 msgid "Splunk On-Call" msgstr "" #: apprise/plugins/splunk.py:172 #, fuzzy msgid "Target Routing Key" msgstr "Target Tag ID" #: apprise/plugins/splunk.py:179 msgid "Entity ID" msgstr "" #: apprise/plugins/spugpush.py:48 msgid "SpugPush" msgstr "" #: apprise/plugins/streamlabs.py:125 msgid "Alert Type" msgstr "" #: apprise/plugins/streamlabs.py:131 msgid "Image Link" msgstr "" #: apprise/plugins/streamlabs.py:136 #, fuzzy msgid "Sound Link" msgstr "Sound" #: apprise/plugins/streamlabs.py:147 msgid "Special Text Color" msgstr "" #: apprise/plugins/streamlabs.py:153 msgid "Amount" msgstr "" #: apprise/plugins/streamlabs.py:159 #, fuzzy msgid "Currency" msgstr "Urgency" #: apprise/plugins/streamlabs.py:165 #, fuzzy msgid "Name" msgstr "Username" #: apprise/plugins/streamlabs.py:171 msgid "Identifier" msgstr "" #: apprise/plugins/synology.py:117 msgid "Upload" msgstr "" #: apprise/plugins/syslog.py:167 msgid "Log to STDERR" msgstr "" #: apprise/plugins/telegram.py:366 msgid "Target Chat ID" msgstr "Target Chat ID" #: apprise/plugins/telegram.py:389 msgid "Detect Bot Owner" msgstr "Detect Bot Owner" #: apprise/plugins/telegram.py:395 msgid "Silent Notification" msgstr "" #: apprise/plugins/telegram.py:400 msgid "Web Page Preview" msgstr "" #: apprise/plugins/telegram.py:405 msgid "Topic Thread ID" msgstr "" #: apprise/plugins/telegram.py:412 #, fuzzy msgid "Markdown Version" msgstr "Version" #: apprise/plugins/telegram.py:421 msgid "Content Placement" msgstr "" #: apprise/plugins/telegram.py:427 msgid "Rich Message Template Path" msgstr "" #: apprise/plugins/threema.py:85 msgid "Gateway ID" msgstr "" #: apprise/plugins/threema.py:110 #, fuzzy msgid "Target Threema ID" msgstr "Target Tag ID" #: apprise/plugins/trigv.py:159 #, fuzzy msgid "API Hostname" msgstr "Hostname" #: apprise/plugins/trigv.py:185 #, fuzzy msgid "Image URL" msgstr "Target User" #: apprise/plugins/trigv.py:195 #, fuzzy msgid "Event type" msgstr "Events" #: apprise/plugins/trigv.py:199 msgid "Priority (Pushover compatibility)" msgstr "" #: apprise/plugins/twilio.py:200 msgid "Notification Method: sms or call" msgstr "" #: apprise/plugins/twitter.py:163 msgid "Consumer Key" msgstr "Consumer Key" #: apprise/plugins/twitter.py:169 msgid "Consumer Secret" msgstr "Consumer Secret" #: apprise/plugins/twitter.py:181 msgid "Access Secret" msgstr "Access Secret" #: apprise/plugins/viber.py:49 msgid "Viber" msgstr "" #: apprise/plugins/viber.py:81 #, fuzzy msgid "Authentication Token" msgstr "Application Key" #: apprise/plugins/viber.py:87 msgid "Receiver IDs" msgstr "" #: apprise/plugins/viber.py:105 #, fuzzy msgid "Bot Avatar URL" msgstr "Avatar Image" #: apprise/plugins/voipms.py:83 #, fuzzy msgid "User Email" msgstr "From Email" #: apprise/plugins/vapid/__init__.py:179 apprise/plugins/vonage.py:133 msgid "ttl" msgstr "" #: apprise/plugins/webexteams.py:178 #, fuzzy msgid "Bot Access Token" msgstr "Access Token" #: apprise/plugins/webexteams.py:184 #, fuzzy msgid "Room IDs" msgstr "Target Room ID" #: apprise/plugins/wechat.py:174 msgid "Corp ID" msgstr "" #: apprise/plugins/wechat.py:181 #, fuzzy msgid "App Secret" msgstr "Application Secret" #: apprise/plugins/wechat.py:189 #, fuzzy msgid "Agent ID" msgstr "Target User" #: apprise/plugins/wechat.py:205 #, fuzzy msgid "Target Department" msgstr "Target Device" #: apprise/plugins/wechat.py:212 #, fuzzy msgid "Target Tag" msgstr "Target Email" #: apprise/plugins/wecombot.py:99 #, fuzzy msgid "Bot Webhook Key" msgstr "Webhook Token" #: apprise/plugins/whatsapp.py:134 #, fuzzy msgid "From Phone ID" msgstr "From Phone No" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "" #: apprise/plugins/workflows.py:150 #, fuzzy msgid "Workflow ID" msgstr "Overflow Mode" #: apprise/plugins/workflows.py:158 msgid "Signature" msgstr "" #: apprise/plugins/workflows.py:181 msgid "Use Power Automate URL" msgstr "" #: apprise/plugins/workflows.py:188 msgid "Power Automate Routing ID" msgstr "" #: apprise/plugins/workflows.py:195 msgid "Wrap Text" msgstr "" #: apprise/plugins/workflows.py:210 #, fuzzy msgid "API Version" msgstr "Version" #: apprise/plugins/wxpusher.py:121 #, fuzzy msgid "App Token" msgstr "Auth Token" #: apprise/plugins/wxpusher.py:133 #, fuzzy msgid "Target User ID" msgstr "Target User" #: apprise/plugins/zoom.py:145 #, fuzzy msgid "Verification Token" msgstr "Application Key" #: apprise/plugins/zulip.py:148 #, fuzzy msgid "Target Stream" msgstr "Target User" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "SMTP Server" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:149 msgid "Secure Mode" msgstr "Secure Mode" #: apprise/plugins/email/base.py:205 #, fuzzy msgid "PGP Security Mode" msgstr "Secure Mode" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "" #: apprise/plugins/email/base.py:225 #, fuzzy msgid "PGP Private Key Path" msgstr "Provider Key" #: apprise/plugins/email/base.py:233 msgid "Web Key Directory" msgstr "" #: apprise/plugins/email/base.py:239 msgid "Inline Attachments" msgstr "" #: apprise/plugins/email/base.py:244 msgid "To Email" msgstr "To Email" #: apprise/plugins/fcm/__init__.py:148 msgid "OAuth2 KeyFile" msgstr "" #: apprise/plugins/fcm/__init__.py:193 msgid "Custom Image URL" msgstr "" #: apprise/plugins/fcm/__init__.py:205 msgid "Notification Color" msgstr "" #: apprise/plugins/fcm/__init__.py:215 msgid "Data Entries" msgstr "" #: apprise/plugins/irc/base.py:159 #, fuzzy msgid "Real Name" msgstr "Bot Name" #: apprise/plugins/irc/base.py:160 #, fuzzy msgid "Nickname" msgstr "Username" #: apprise/plugins/irc/base.py:162 #, fuzzy msgid "Join Channels" msgstr "Channels" #: apprise/plugins/irc/base.py:167 #, fuzzy msgid "Auth Mode" msgstr "Webhook Mode" #: apprise/plugins/matrix/base.py:326 msgid "Target Room Alias" msgstr "Target Room Alias" #: apprise/plugins/matrix/base.py:349 #, fuzzy msgid "Server Discovery" msgstr "Server Timeout" #: apprise/plugins/matrix/base.py:354 msgid "Force Home Server on Room IDs" msgstr "" #: apprise/plugins/matrix/base.py:371 msgid "Matrix API Verion" msgstr "" #: apprise/plugins/matrix/base.py:383 msgid "End-to-End Encryption" msgstr "" #: apprise/plugins/vapid/__init__.py:193 msgid "PEM Private KeyFile" msgstr "" #: apprise/plugins/vapid/__init__.py:199 msgid "Subscripion File" msgstr "" #: apprise/plugins/xmpp/base.py:144 #, fuzzy msgid "XMPP Server" msgstr "SMTP Server" #: apprise/plugins/xmpp/base.py:156 #, fuzzy msgid "Get Roster" msgstr "Target User" #: apprise/plugins/xmpp/base.py:161 msgid "Use Subject" msgstr "" #: apprise/plugins/xmpp/base.py:166 #, fuzzy msgid "Keep Connection Alive" msgstr "Server Timeout" #: apprise/plugins/xmpp/base.py:172 #, fuzzy msgid "MUC Nickname" msgstr "Username" #: apprise/plugins/xmpp/base.py:176 msgid "SCRAM-PLUS Channel Binding" msgstr "" caronc-apprise-182f859/apprise/i18n/es/000077500000000000000000000000001524161175200175455ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/es/LC_MESSAGES/000077500000000000000000000000001524161175200213325ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/es/LC_MESSAGES/apprise.po000066400000000000000000001676471524161175200233620ustar00rootroot00000000000000# Spanish translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 1.11.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-08-20 09:55-0400\n" "PO-Revision-Date: 2019-05-24 20:00-0400\n" "Last-Translator: Chris Caron \n" "Language: es\n" "Language-Team: es \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:96 apprise/url.py:158 msgid "Verify SSL" msgstr "Verificar SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "" #: apprise/url.py:178 #, fuzzy msgid "Socket Read Timeout" msgstr "Tiempo de Espera de Lectura del Socket" #: apprise/url.py:192 #, fuzzy msgid "Socket Connect Timeout" msgstr "Tiempo de Espera de Conexion del Socket" #: apprise/attachment/base.py:82 msgid "Cache Age" msgstr "Antiguedad del Cache" #: apprise/attachment/base.py:88 msgid "Forced Mime Type" msgstr "Tipo MIME Forzado" #: apprise/attachment/base.py:92 msgid "Forced File Name" msgstr "Nombre de Archivo Forzado" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Archivo Local" #: apprise/attachment/http.py:47 apprise/config/http.py:55 msgid "Web Based" msgstr "Basado en Web" #: apprise/attachment/memory.py:44 apprise/config/memory.py:37 msgid "Memory" msgstr "Memoria" #: apprise/plugins/__init__.py:278 msgid "Schema" msgstr "Esquema" #: apprise/plugins/__init__.py:398 msgid "No dependencies." msgstr "Sin dependencias." #: apprise/plugins/__init__.py:401 msgid "Packages are required to function." msgstr "Se requieren paquetes para funcionar." #: apprise/plugins/__init__.py:405 msgid "Packages are recommended to improve functionality." msgstr "Se recomiendan paquetes para mejorar la funcionalidad." #: apprise/plugins/africas_talking.py:132 #, fuzzy msgid "App User Name" msgstr "Nombre de Usuario de la Aplicacion" #: apprise/plugins/africas_talking.py:138 apprise/plugins/brevo.py:157 #: apprise/plugins/burstsms.py:104 apprise/plugins/clicksend.py:98 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:113 #: apprise/plugins/exotel.py:213 apprise/plugins/fcm/__init__.py:143 #: apprise/plugins/flowtriq.py:112 apprise/plugins/httpsms.py:77 #: apprise/plugins/jira.py:230 apprise/plugins/join.py:140 #: apprise/plugins/kavenegar.py:115 apprise/plugins/kumulos.py:87 #: apprise/plugins/mailersend.py:126 apprise/plugins/mailgun.py:146 #: apprise/plugins/messagebird.py:78 apprise/plugins/notifyre.py:130 #: apprise/plugins/octopush.py:136 apprise/plugins/one_signal.py:113 #: apprise/plugins/opsgenie.py:228 apprise/plugins/pagerduty.py:131 #: apprise/plugins/pinglet.py:137 apprise/plugins/pingram.py:175 #: apprise/plugins/postmark.py:117 apprise/plugins/prowl.py:121 #: apprise/plugins/resend.py:107 apprise/plugins/resend.py:145 #: apprise/plugins/sendgrid.py:116 apprise/plugins/seven.py:75 #: apprise/plugins/simplepush.py:101 apprise/plugins/smsmanager.py:106 #: apprise/plugins/smtp2go.py:118 apprise/plugins/sparkpost.py:169 #: apprise/plugins/splunk.py:165 apprise/plugins/techuluspush.py:97 #: apprise/plugins/trigv.py:142 apprise/plugins/twilio.py:194 #: apprise/plugins/vapid/__init__.py:152 apprise/plugins/vonage.py:80 msgid "API Key" msgstr "Clave de API" #: apprise/plugins/africas_talking.py:145 apprise/plugins/fortysixelks.py:106 #, fuzzy msgid "Target Phone" msgstr "Telefono de Destino" #: apprise/plugins/africas_talking.py:150 apprise/plugins/aprs.py:187 #: apprise/plugins/bark.py:178 apprise/plugins/brevo.py:174 #: apprise/plugins/bulksms.py:137 apprise/plugins/bulkvs.py:110 #: apprise/plugins/burstsms.py:131 apprise/plugins/clickatell.py:90 #: apprise/plugins/clicksend.py:112 apprise/plugins/d7networks.py:110 #: apprise/plugins/dapnet.py:138 apprise/plugins/dingtalk.py:111 #: apprise/plugins/eight00com.py:128 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:190 #: apprise/plugins/fcm/__init__.py:168 apprise/plugins/flock.py:125 #: apprise/plugins/fortysixelks.py:111 apprise/plugins/home_assistant.py:154 #: apprise/plugins/httpsms.py:97 apprise/plugins/humhub.py:149 #: apprise/plugins/irc/base.py:149 apprise/plugins/join.py:172 #: apprise/plugins/kavenegar.py:135 apprise/plugins/kook.py:176 #: apprise/plugins/line.py:93 apprise/plugins/mailersend.py:143 #: apprise/plugins/mailgun.py:157 apprise/plugins/mastodon.py:202 #: apprise/plugins/matrix/base.py:332 apprise/plugins/mattermost.py:194 #: apprise/plugins/messagebird.py:99 apprise/plugins/mqtt.py:177 #: apprise/plugins/msg91.py:123 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:101 apprise/plugins/notifiarr.py:103 #: apprise/plugins/notifyre.py:143 apprise/plugins/ntfy.py:241 #: apprise/plugins/octopush.py:153 apprise/plugins/office365.py:287 #: apprise/plugins/one_signal.py:141 apprise/plugins/pingram.py:198 #: apprise/plugins/plivo.py:114 apprise/plugins/postmark.py:133 #: apprise/plugins/pushbullet.py:105 apprise/plugins/pushed.py:107 #: apprise/plugins/pushover.py:252 apprise/plugins/pushsafer.py:376 #: apprise/plugins/pushy.py:97 apprise/plugins/reddit.py:170 #: apprise/plugins/resend.py:124 apprise/plugins/revolt.py:112 #: apprise/plugins/ringcentral.py:219 apprise/plugins/rocketchat.py:167 #: apprise/plugins/sendgrid.py:133 apprise/plugins/sendpulse.py:139 #: apprise/plugins/serwersms.py:161 apprise/plugins/seven.py:88 #: apprise/plugins/sfr.py:113 apprise/plugins/signal_api.py:138 #: apprise/plugins/sinch.py:140 apprise/plugins/slack.py:281 #: apprise/plugins/smpp.py:128 apprise/plugins/smsc.py:156 #: apprise/plugins/smseagle.py:172 apprise/plugins/smsmanager.py:119 #: apprise/plugins/sns.py:170 apprise/plugins/telegram.py:372 #: apprise/plugins/threema.py:115 apprise/plugins/trigv.py:155 #: apprise/plugins/twilio.py:174 apprise/plugins/twist.py:123 #: apprise/plugins/twitter.py:193 apprise/plugins/vapid/__init__.py:158 #: apprise/plugins/voipms.py:108 apprise/plugins/vonage.py:108 #: apprise/plugins/wechat.py:218 apprise/plugins/whatsapp.py:166 #: apprise/plugins/wxpusher.py:139 apprise/plugins/xmpp/base.py:134 #: apprise/plugins/zulip.py:153 msgid "Targets" msgstr "Destinatarios" #: apprise/plugins/africas_talking.py:166 #, fuzzy msgid "From" msgstr "De" #: apprise/plugins/africas_talking.py:172 #, fuzzy msgid "SMS Mode" msgstr "Modo SMS" #: apprise/plugins/africas_talking.py:181 apprise/plugins/bulksms.py:170 #: apprise/plugins/bulkvs.py:131 apprise/plugins/burstsms.py:166 #: apprise/plugins/clicksend.py:130 apprise/plugins/d7networks.py:144 #: apprise/plugins/dapnet.py:167 apprise/plugins/exotel.py:222 #: apprise/plugins/home_assistant.py:171 apprise/plugins/jira.py:282 #: apprise/plugins/mailgun.py:194 apprise/plugins/mastodon.py:247 #: apprise/plugins/octopush.py:173 apprise/plugins/one_signal.py:185 #: apprise/plugins/opsgenie.py:307 apprise/plugins/plivo.py:137 #: apprise/plugins/signal_api.py:160 apprise/plugins/smseagle.py:211 #: apprise/plugins/smsmanager.py:152 apprise/plugins/smtp2go.py:151 #: apprise/plugins/sparkpost.py:209 apprise/plugins/twitter.py:218 #, fuzzy msgid "Batch Mode" msgstr "Modo por Lotes" #: apprise/plugins/apprise_api.py:102 apprise/plugins/bark.py:152 #: apprise/plugins/custom_form.py:131 apprise/plugins/custom_json.py:113 #: apprise/plugins/custom_xml.py:113 apprise/plugins/emby.py:85 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:119 #: apprise/plugins/flowtriq.py:118 apprise/plugins/fluxer.py:159 #: apprise/plugins/gotify.py:129 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:123 apprise/plugins/humhub.py:138 #: apprise/plugins/irc/base.py:117 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:460 apprise/plugins/mastodon.py:180 #: apprise/plugins/matrix/base.py:287 apprise/plugins/mattermost.py:160 #: apprise/plugins/misskey.py:117 apprise/plugins/mqtt.py:149 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74 #: apprise/plugins/notica.py:126 apprise/plugins/notifico.py:166 #: apprise/plugins/ntfy.py:211 apprise/plugins/parseplatform.py:90 #: apprise/plugins/pinglet.py:143 apprise/plugins/pushdeer.py:75 #: apprise/plugins/pushjet.py:71 apprise/plugins/rocketchat.py:120 #: apprise/plugins/rsyslog.py:180 apprise/plugins/signal_api.py:97 #: apprise/plugins/smseagle.py:135 apprise/plugins/sogs.py:207 #: apprise/plugins/synology.py:84 apprise/plugins/workflows.py:138 #: apprise/plugins/xmpp/base.py:101 msgid "Hostname" msgstr "Nombre del Host" #: apprise/plugins/apprise_api.py:107 apprise/plugins/bark.py:157 #: apprise/plugins/custom_form.py:136 apprise/plugins/custom_json.py:118 #: apprise/plugins/custom_xml.py:118 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:124 apprise/plugins/flowtriq.py:123 #: apprise/plugins/fluxer.py:163 apprise/plugins/gotify.py:140 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:128 #: apprise/plugins/humhub.py:143 apprise/plugins/irc/base.py:122 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:464 #: apprise/plugins/mastodon.py:190 apprise/plugins/matrix/base.py:292 #: apprise/plugins/mattermost.py:176 apprise/plugins/misskey.py:127 #: apprise/plugins/mqtt.py:154 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130 #: apprise/plugins/notifico.py:170 apprise/plugins/ntfy.py:215 #: apprise/plugins/parseplatform.py:95 apprise/plugins/pinglet.py:148 #: apprise/plugins/pushdeer.py:79 apprise/plugins/pushjet.py:76 #: apprise/plugins/rocketchat.py:125 apprise/plugins/rsyslog.py:185 #: apprise/plugins/signal_api.py:102 apprise/plugins/smpp.py:108 #: apprise/plugins/smseagle.py:140 apprise/plugins/sogs.py:212 #: apprise/plugins/synology.py:89 apprise/plugins/trigv.py:163 #: apprise/plugins/workflows.py:143 apprise/plugins/xmpp/base.py:106 msgid "Port" msgstr "Puerto" #: apprise/plugins/apprise_api.py:113 apprise/plugins/bark.py:163 #: apprise/plugins/bluesky.py:119 apprise/plugins/custom_form.py:142 #: apprise/plugins/custom_json.py:124 apprise/plugins/custom_xml.py:124 #: apprise/plugins/emby.py:97 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:78 apprise/plugins/home_assistant.py:134 #: apprise/plugins/jira.py:236 apprise/plugins/kodi.py:107 #: apprise/plugins/lametric.py:471 apprise/plugins/matrix/base.py:298 #: apprise/plugins/nextcloud.py:127 apprise/plugins/nextcloudtalk.py:85 #: apprise/plugins/notica.py:136 apprise/plugins/notifico.py:176 #: apprise/plugins/ntfy.py:221 apprise/plugins/opsgenie.py:234 #: apprise/plugins/pushjet.py:88 apprise/plugins/rocketchat.py:131 #: apprise/plugins/serwersms.py:131 apprise/plugins/signal_api.py:108 #: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:95 msgid "Username" msgstr "Nombre de Usuario" #: apprise/plugins/apprise_api.py:117 apprise/plugins/aprs.py:172 #: apprise/plugins/bark.py:167 apprise/plugins/bluesky.py:124 #: apprise/plugins/bulksms.py:117 apprise/plugins/bulkvs.py:90 #: apprise/plugins/custom_form.py:146 apprise/plugins/custom_json.py:128 #: apprise/plugins/custom_xml.py:128 apprise/plugins/dapnet.py:123 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:101 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:83 #: apprise/plugins/growl.py:151 apprise/plugins/home_assistant.py:138 #: apprise/plugins/humhub.py:133 apprise/plugins/irc/base.py:132 #: apprise/plugins/kodi.py:111 apprise/plugins/matrix/base.py:302 #: apprise/plugins/mqtt.py:165 apprise/plugins/nextcloud.py:131 #: apprise/plugins/nextcloudtalk.py:90 apprise/plugins/notica.py:140 #: apprise/plugins/notifico.py:180 apprise/plugins/ntfy.py:225 #: apprise/plugins/pushjet.py:92 apprise/plugins/reddit.py:145 #: apprise/plugins/rocketchat.py:135 apprise/plugins/serwersms.py:136 #: apprise/plugins/signal_api.py:112 apprise/plugins/simplepush.py:108 #: apprise/plugins/smpp.py:97 apprise/plugins/smsc.py:143 #: apprise/plugins/synology.py:99 apprise/plugins/twist.py:101 #: apprise/plugins/voipms.py:88 apprise/plugins/xmpp/base.py:117 msgid "Password" msgstr "Contrasena" #: apprise/plugins/apprise_api.py:122 apprise/plugins/chanify.py:74 #: apprise/plugins/dingtalk.py:93 apprise/plugins/exotel.py:166 #: apprise/plugins/feishu.py:80 apprise/plugins/gotify.py:123 #: apprise/plugins/kook.py:170 apprise/plugins/mattermost.py:166 #: apprise/plugins/notica.py:119 apprise/plugins/notifiarr.py:91 #: apprise/plugins/ntfy.py:230 apprise/plugins/pushme.py:62 #: apprise/plugins/ryver.py:99 apprise/plugins/serverchan.py:70 #: apprise/plugins/slack.py:331 apprise/plugins/synology.py:104 #: apprise/plugins/zulip.py:136 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:136 apprise/plugins/jira.py:301 #: apprise/plugins/ntfy.py:289 apprise/plugins/opsgenie.py:294 #: apprise/plugins/pagertree.py:133 #, fuzzy msgid "Tags" msgstr "Etiquetas" #: apprise/plugins/apprise_api.py:140 msgid "Query Method" msgstr "Metodo de Consulta" #: apprise/plugins/apprise_api.py:154 apprise/plugins/custom_form.py:175 #: apprise/plugins/custom_json.py:151 apprise/plugins/custom_xml.py:151 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:122 apprise/plugins/notica.py:156 #: apprise/plugins/pagertree.py:142 apprise/plugins/synology.py:129 msgid "HTTP Header" msgstr "Cabecera HTTP" #: apprise/plugins/aprs.py:167 apprise/plugins/bulksms.py:112 #: apprise/plugins/bulkvs.py:85 apprise/plugins/clicksend.py:93 #: apprise/plugins/dapnet.py:118 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:160 #: apprise/plugins/reddit.py:140 apprise/plugins/sendpulse.py:110 #: apprise/plugins/smtp2go.py:108 apprise/plugins/sparkpost.py:159 msgid "User Name" msgstr "Nombre de Usuario" #: apprise/plugins/aprs.py:178 apprise/plugins/aprs.py:199 #: apprise/plugins/dapnet.py:129 apprise/plugins/dapnet.py:162 #, fuzzy msgid "Target Callsign" msgstr "Indicativo de Destino" #: apprise/plugins/aprs.py:204 msgid "Resend Delay" msgstr "Retraso de Reenvio" #: apprise/plugins/aprs.py:211 msgid "Locale" msgstr "Configuracion Regional" #: apprise/plugins/bark.py:172 apprise/plugins/fcm/__init__.py:157 #: apprise/plugins/home_assistant.py:149 apprise/plugins/pushbullet.py:89 #: apprise/plugins/pushover.py:240 apprise/plugins/pushsafer.py:366 #: apprise/plugins/pushy.py:85 msgid "Target Device" msgstr "Dispositivo de Destino" #: apprise/plugins/bark.py:191 apprise/plugins/lametric.py:516 #: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:269 #: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110 msgid "Sound" msgstr "Sonido" #: apprise/plugins/bark.py:196 apprise/plugins/pushward.py:152 msgid "Level" msgstr "Nivel" #: apprise/plugins/bark.py:201 apprise/plugins/pushward.py:182 msgid "Volume" msgstr "Volumen" #: apprise/plugins/bark.py:207 apprise/plugins/ntfy.py:271 #: apprise/plugins/pagerduty.py:172 msgid "Click" msgstr "Clic" #: apprise/plugins/bark.py:211 apprise/plugins/pushy.py:114 msgid "Badge" msgstr "Insignia" #: apprise/plugins/bark.py:216 msgid "Category" msgstr "Categoria" #: apprise/plugins/bark.py:220 apprise/plugins/join.py:158 #: apprise/plugins/pagerduty.py:163 msgid "Group" msgstr "Grupo" #: apprise/plugins/bark.py:224 apprise/plugins/dbus.py:228 #: apprise/plugins/discord.py:210 apprise/plugins/fcm/__init__.py:197 #: apprise/plugins/flock.py:136 apprise/plugins/fluxer.py:250 #: apprise/plugins/glib.py:188 apprise/plugins/gnome.py:154 #: apprise/plugins/growl.py:175 apprise/plugins/join.py:183 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:108 #: apprise/plugins/macosx.py:115 apprise/plugins/matrix/base.py:343 #: apprise/plugins/mattermost.py:217 apprise/plugins/notifiarr.py:125 #: apprise/plugins/ntfy.py:257 apprise/plugins/one_signal.py:164 #: apprise/plugins/pagerduty.py:191 apprise/plugins/ryver.py:124 #: apprise/plugins/slack.py:299 apprise/plugins/telegram.py:383 #: apprise/plugins/vapid/__init__.py:204 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:175 msgid "Include Image" msgstr "Incluir Imagen" #: apprise/plugins/bark.py:230 apprise/plugins/mattermost.py:213 #: apprise/plugins/revolt.py:129 msgid "Icon URL" msgstr "URL del Icono" #: apprise/plugins/bark.py:234 apprise/plugins/streamlabs.py:119 msgid "Call" msgstr "Llamada" #: apprise/plugins/bark.py:239 apprise/plugins/pushover.py:301 #, fuzzy msgid "Encryption Key" msgstr "Cifrado PGP" #: apprise/plugins/base.py:305 msgid "Overflow Mode" msgstr "Modo de Desbordamiento" #: apprise/plugins/base.py:320 msgid "Notify Format" msgstr "Formato de Notificacion" #: apprise/plugins/base.py:330 #, fuzzy msgid "Interpret Emojis" msgstr "Interpretar Emojis" #: apprise/plugins/base.py:340 msgid "Persistent Storage" msgstr "Almacenamiento Persistente" #: apprise/plugins/base.py:350 #, fuzzy msgid "Timezone" msgstr "Zona Horaria" #: apprise/plugins/base.py:359 #, fuzzy msgid "Service Retry" msgstr "Servicio" #: apprise/plugins/base.py:367 #, fuzzy msgid "Inter-Retry Wait" msgstr "Interpretar Emojis" #: apprise/plugins/base.py:375 #, fuzzy msgid "Optional Service" msgstr "Servicio" #: apprise/plugins/blink1.py:151 #, fuzzy msgid "blink(1)" msgstr "Enlace" #: apprise/plugins/blink1.py:178 #, fuzzy msgid "Serial Number" msgstr "Numero de Serie del Dispositivo" #: apprise/plugins/blink1.py:188 #, fuzzy msgid "Duration (ms)" msgstr "Duracion" #: apprise/plugins/blink1.py:195 msgid "Fade Time (ms)" msgstr "" #: apprise/plugins/blink1.py:202 msgid "LED Number" msgstr "" #: apprise/plugins/brevo.py:164 apprise/plugins/mailersend.py:133 #: apprise/plugins/postmark.py:123 apprise/plugins/resend.py:114 #: apprise/plugins/sendgrid.py:123 #, fuzzy msgid "Source Email" msgstr "Correo de Origen" #: apprise/plugins/brevo.py:169 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:138 apprise/plugins/mailgun.py:152 #: apprise/plugins/office365.py:282 apprise/plugins/one_signal.py:124 #: apprise/plugins/pingram.py:182 apprise/plugins/postmark.py:128 #: apprise/plugins/pushbullet.py:100 apprise/plugins/pushsafer.py:371 #: apprise/plugins/resend.py:119 apprise/plugins/sendgrid.py:128 #: apprise/plugins/sendpulse.py:134 apprise/plugins/slack.py:264 #: apprise/plugins/threema.py:105 msgid "Target Email" msgstr "Correo de Destino" #: apprise/plugins/brevo.py:185 apprise/plugins/postmark.py:149 #: apprise/plugins/ses.py:205 #, fuzzy msgid "Reply To Email" msgstr "Responder a Correo" #: apprise/plugins/brevo.py:193 apprise/plugins/email/base.py:249 #: apprise/plugins/mailersend.py:157 apprise/plugins/mailgun.py:186 #: apprise/plugins/office365.py:307 apprise/plugins/pingram.py:245 #: apprise/plugins/postmark.py:157 apprise/plugins/resend.py:158 #: apprise/plugins/sendgrid.py:153 apprise/plugins/sendpulse.py:169 #: apprise/plugins/ses.py:232 apprise/plugins/smtp2go.py:143 #: apprise/plugins/sparkpost.py:201 msgid "Carbon Copy" msgstr "Copia de Carbono" #: apprise/plugins/brevo.py:197 apprise/plugins/email/base.py:253 #: apprise/plugins/mailersend.py:161 apprise/plugins/mailgun.py:190 #: apprise/plugins/office365.py:311 apprise/plugins/pingram.py:249 #: apprise/plugins/postmark.py:161 apprise/plugins/resend.py:162 #: apprise/plugins/sendgrid.py:157 apprise/plugins/sendpulse.py:173 #: apprise/plugins/ses.py:236 apprise/plugins/smtp2go.py:147 #: apprise/plugins/sparkpost.py:205 msgid "Blind Carbon Copy" msgstr "Copia de Carbono Oculta" #: apprise/plugins/bulksms.py:123 apprise/plugins/bulkvs.py:103 #: apprise/plugins/burstsms.py:124 apprise/plugins/clickatell.py:83 #: apprise/plugins/clicksend.py:105 apprise/plugins/d7networks.py:103 #: apprise/plugins/dingtalk.py:106 apprise/plugins/eight00com.py:121 #: apprise/plugins/evolution.py:135 apprise/plugins/exotel.py:183 #: apprise/plugins/httpsms.py:90 apprise/plugins/kavenegar.py:128 #: apprise/plugins/messagebird.py:92 apprise/plugins/msg91.py:116 #: apprise/plugins/notifyre.py:136 apprise/plugins/octopush.py:146 #: apprise/plugins/plivo.py:107 apprise/plugins/ringcentral.py:213 #: apprise/plugins/serwersms.py:148 apprise/plugins/seven.py:81 #: apprise/plugins/signal_api.py:124 apprise/plugins/sinch.py:127 #: apprise/plugins/smpp.py:121 apprise/plugins/smsc.py:149 #: apprise/plugins/smseagle.py:151 apprise/plugins/smsmanager.py:112 #: apprise/plugins/sns.py:157 apprise/plugins/threema.py:98 #: apprise/plugins/twilio.py:161 apprise/plugins/voipms.py:101 #: apprise/plugins/vonage.py:101 apprise/plugins/whatsapp.py:147 msgid "Target Phone No" msgstr "Numero de Telefono de Destino" #: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:246 #, fuzzy msgid "Target Group" msgstr "Grupo de Destino" #: apprise/plugins/bulksms.py:149 apprise/plugins/bulkvs.py:96 #: apprise/plugins/bulkvs.py:122 apprise/plugins/clickatell.py:78 #: apprise/plugins/eight00com.py:114 apprise/plugins/eight00com.py:142 #: apprise/plugins/fortysixelks.py:100 apprise/plugins/httpsms.py:83 #: apprise/plugins/httpsms.py:115 apprise/plugins/ringcentral.py:207 #: apprise/plugins/signal_api.py:117 apprise/plugins/sinch.py:120 #: apprise/plugins/smpp.py:114 apprise/plugins/smsmanager.py:134 #: apprise/plugins/twilio.py:154 apprise/plugins/voipms.py:94 #: apprise/plugins/vonage.py:94 msgid "From Phone No" msgstr "Numero de Telefono de Origen" #: apprise/plugins/bulksms.py:155 #, fuzzy msgid "Route Group" msgstr "Grupo de Enrutamiento" #: apprise/plugins/bulksms.py:162 apprise/plugins/d7networks.py:136 #: apprise/plugins/exotel.py:228 msgid "Unicode Characters" msgstr "Caracteres Unicode" #: apprise/plugins/burstsms.py:111 apprise/plugins/threema.py:92 #: apprise/plugins/vonage.py:87 #, fuzzy msgid "API Secret" msgstr "Secreto de API" #: apprise/plugins/burstsms.py:118 apprise/plugins/smsc.py:171 #, fuzzy msgid "Sender ID" msgstr "ID del Remitente" #: apprise/plugins/burstsms.py:152 msgid "Country" msgstr "Pais" #: apprise/plugins/burstsms.py:161 msgid "validity" msgstr "validez" #: apprise/plugins/chanify.py:47 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/chime.py:130 apprise/plugins/discord.py:144 #: apprise/plugins/fluxer.py:174 apprise/plugins/ifttt.py:103 #: apprise/plugins/zoom.py:139 msgid "Webhook ID" msgstr "ID del Webhook" #: apprise/plugins/chime.py:136 apprise/plugins/discord.py:150 #: apprise/plugins/fluxer.py:181 apprise/plugins/google_chat.py:127 #: apprise/plugins/stackfield.py:92 apprise/plugins/webexteams.py:171 msgid "Webhook Token" msgstr "Token del Webhook" #: apprise/plugins/clickatell.py:45 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:72 apprise/plugins/eight00com.py:108 #: apprise/plugins/rocketchat.py:140 #, fuzzy msgid "API Token" msgstr "Token de API" #: apprise/plugins/custom_form.py:158 apprise/plugins/custom_json.py:140 #: apprise/plugins/custom_xml.py:140 msgid "Fetch Method" msgstr "Metodo de Obtencion" #: apprise/plugins/custom_form.py:164 msgid "Attach File As" msgstr "Adjuntar Archivo Como" #: apprise/plugins/custom_form.py:179 apprise/plugins/custom_json.py:155 #: apprise/plugins/custom_xml.py:155 apprise/plugins/pagertree.py:146 msgid "Payload Extras" msgstr "Extras del Payload" #: apprise/plugins/custom_form.py:183 apprise/plugins/custom_json.py:159 #: apprise/plugins/custom_xml.py:159 msgid "GET Params" msgstr "Parametros GET" #: apprise/plugins/d7networks.py:97 #, fuzzy msgid "API Access Token" msgstr "Token de Acceso a la API" #: apprise/plugins/d7networks.py:127 apprise/plugins/seven.py:105 msgid "Originating Address" msgstr "Direccion de Origen" #: apprise/plugins/dapnet.py:150 apprise/plugins/exotel.py:240 #: apprise/plugins/gotify.py:153 apprise/plugins/growl.py:163 #: apprise/plugins/jira.py:287 apprise/plugins/join.py:189 #: apprise/plugins/lametric.py:494 apprise/plugins/ntfy.py:283 #: apprise/plugins/opsgenie.py:280 apprise/plugins/pinglet.py:177 #: apprise/plugins/prowl.py:141 apprise/plugins/pushover.py:263 #: apprise/plugins/pushsafer.py:387 apprise/plugins/smseagle.py:187 msgid "Priority" msgstr "Prioridad" #: apprise/plugins/dapnet.py:156 msgid "Transmitter Groups" msgstr "Grupos de Transmisores" #: apprise/plugins/dbus.py:156 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x debe estar instalado." #: apprise/plugins/dbus.py:160 msgid "DBus Notification" msgstr "Notificacion DBus" #: apprise/plugins/dbus.py:204 apprise/plugins/glib.py:165 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:128 #: apprise/plugins/trigv.py:189 msgid "Urgency" msgstr "Urgencia" #: apprise/plugins/dbus.py:216 apprise/plugins/glib.py:176 msgid "X-Axis" msgstr "Eje X" #: apprise/plugins/dbus.py:222 apprise/plugins/glib.py:182 msgid "Y-Axis" msgstr "Eje Y" #: apprise/plugins/dingtalk.py:100 apprise/plugins/signl4.py:76 #, fuzzy msgid "Secret" msgstr "Secreto" #: apprise/plugins/discord.py:139 apprise/plugins/flock.py:106 #: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106 #: apprise/plugins/slack.py:216 apprise/plugins/viber.py:99 #: apprise/plugins/zulip.py:124 msgid "Bot Name" msgstr "Nombre del Bot" #: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:201 msgid "Text To Speech" msgstr "Texto a Voz" #: apprise/plugins/discord.py:168 apprise/plugins/fluxer.py:206 msgid "Avatar Image" msgstr "Imagen de Avatar" #: apprise/plugins/discord.py:173 apprise/plugins/fluxer.py:211 #: apprise/plugins/ntfy.py:263 #, fuzzy msgid "Avatar URL" msgstr "URL del Avatar" #: apprise/plugins/discord.py:177 apprise/plugins/fluxer.py:215 #: apprise/plugins/pushover.py:275 apprise/plugins/trigv.py:180 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:222 msgid "Thread ID" msgstr "ID del Hilo" #: apprise/plugins/discord.py:190 apprise/plugins/fluxer.py:230 msgid "Display Footer" msgstr "Mostrar Pie de Pagina" #: apprise/plugins/discord.py:195 apprise/plugins/fluxer.py:235 msgid "Footer Logo" msgstr "Logo del Pie de Pagina" #: apprise/plugins/discord.py:200 apprise/plugins/fluxer.py:240 #, fuzzy msgid "Use Fields" msgstr "Usar Campos" #: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:245 msgid "Discord Flags" msgstr "Banderas de Discord" #: apprise/plugins/discord.py:219 apprise/plugins/fluxer.py:256 msgid "Ping Users/Roles" msgstr "Mencionar Usuarios/Roles" #: apprise/plugins/discord.py:223 apprise/plugins/slack.py:338 #: apprise/plugins/workflows.py:200 msgid "Template Path" msgstr "Ruta de Plantilla" #: apprise/plugins/discord.py:231 #, fuzzy msgid "Batch Attachments" msgstr "Archivos Adjuntos Sensibles" #: apprise/plugins/discord.py:241 apprise/plugins/mailgun.py:208 #: apprise/plugins/pingram.py:258 apprise/plugins/slack.py:348 #: apprise/plugins/sparkpost.py:223 apprise/plugins/telegram.py:437 #: apprise/plugins/workflows.py:221 #, fuzzy msgid "Template Tokens" msgstr "Tokens de Plantilla" #: apprise/plugins/dot.py:142 #, fuzzy msgid "Device Serial Number" msgstr "Numero de Serie del Dispositivo" #: apprise/plugins/dot.py:155 #, fuzzy msgid "API Mode" msgstr "Modo de API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Actualizar Ahora" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Firma de Texto" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Icono Base64 (API de Texto)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Imagen Base64 (API de Imagen)" #: apprise/plugins/dot.py:180 msgid "Link" msgstr "Enlace" #: apprise/plugins/dot.py:184 #, fuzzy msgid "Border" msgstr "Borde" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Tipo de Tramado" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Kernel de Tramado" #: apprise/plugins/dot.py:203 #, fuzzy msgid "Task Key" msgstr "Clave del Hilo" #: apprise/plugins/emby.py:112 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/gotify.py:134 #: apprise/plugins/mattermost.py:172 apprise/plugins/notica.py:145 #: apprise/plugins/pinglet.py:154 msgid "Path" msgstr "Ruta" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Tiempo de Espera del Servidor" #: apprise/plugins/evolution.py:130 #, fuzzy msgid "Instance Name" msgstr "Nombre del Dispositivo" #: apprise/plugins/exotel.py:160 apprise/plugins/sinch.py:106 #: apprise/plugins/twilio.py:140 msgid "Account SID" msgstr "SID de la Cuenta" #: apprise/plugins/exotel.py:172 #, fuzzy msgid "From Phone No / Sender ID" msgstr "Numero de Telefono de Origen" #: apprise/plugins/exotel.py:233 apprise/plugins/jira.py:275 #: apprise/plugins/mailgun.py:176 apprise/plugins/opsgenie.py:273 #: apprise/plugins/pagerduty.py:176 apprise/plugins/pingram.py:220 #: apprise/plugins/sparkpost.py:191 msgid "Region Name" msgstr "Nombre de Region" #: apprise/plugins/feishu.py:49 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:99 apprise/plugins/twitter.py:175 msgid "Access Key" msgstr "Clave de Acceso" #: apprise/plugins/flock.py:111 msgid "To User ID" msgstr "ID del Usuario de Destino" #: apprise/plugins/flock.py:118 msgid "To Channel ID" msgstr "ID del Canal de Destino" #: apprise/plugins/flowtriq.py:129 apprise/plugins/matrix/base.py:365 #, fuzzy msgid "Webhook Path" msgstr "Webhook" #: apprise/plugins/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188 #: apprise/plugins/fluxer.py:195 apprise/plugins/kook.py:196 #: apprise/plugins/lametric.py:510 apprise/plugins/mattermost.py:229 #: apprise/plugins/notifyre.py:157 apprise/plugins/ntfy.py:297 #: apprise/plugins/office365.py:319 apprise/plugins/pingram.py:226 #: apprise/plugins/sns.py:202 apprise/plugins/vapid/__init__.py:169 #: apprise/plugins/webexteams.py:197 apprise/plugins/zoom.py:158 #, fuzzy msgid "Mode" msgstr "Modo" #: apprise/plugins/fluxer.py:226 #, fuzzy msgid "Thread Name" msgstr "Nombre del Hilo" #: apprise/plugins/fortysixelks.py:58 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:89 #, fuzzy msgid "API Username" msgstr "Usuario de API" #: apprise/plugins/fortysixelks.py:94 #, fuzzy msgid "API Password" msgstr "Contrasena de API" #: apprise/plugins/freemobile.py:48 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/glib.py:123 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x o libdbus-2.so.x debe estar instalado." #: apprise/plugins/glib.py:127 #, fuzzy msgid "GLib Notification" msgstr "Notificacion DBus" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Se requiere un entorno local de Gnome." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Notificacion de Gnome" #: apprise/plugins/google_chat.py:115 msgid "Workspace" msgstr "Espacio de Trabajo" #: apprise/plugins/google_chat.py:121 #, fuzzy msgid "Webhook Key" msgstr "Clave del Webhook" #: apprise/plugins/google_chat.py:133 #, fuzzy msgid "Thread Key" msgstr "Clave del Hilo" #: apprise/plugins/groupme.py:123 #, fuzzy msgid "Bot ID" msgstr "ID del Webhook" #: apprise/plugins/groupme.py:130 apprise/plugins/line.py:82 #: apprise/plugins/mastodon.py:185 apprise/plugins/matrix/base.py:307 #: apprise/plugins/misskey.py:122 apprise/plugins/pushbullet.py:83 #: apprise/plugins/pushover.py:234 apprise/plugins/smseagle.py:146 #: apprise/plugins/spugpush.py:68 apprise/plugins/streamlabs.py:105 #: apprise/plugins/whatsapp.py:121 msgid "Access Token" msgstr "Token de Acceso" #: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:195 #: apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Version" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Fijado" #: apprise/plugins/home_assistant.py:143 #, fuzzy msgid "Long-Lived Access Token" msgstr "Token de Acceso de Larga Duracion" #: apprise/plugins/home_assistant.py:166 msgid "Notification ID" msgstr "ID de Notificacion" #: apprise/plugins/home_assistant.py:183 #, fuzzy msgid "Path Prefix" msgstr "Prefijo" #: apprise/plugins/humhub.py:127 #, fuzzy msgid "Token or Username" msgstr "Nombre de Usuario" #: apprise/plugins/ifttt.py:109 msgid "Events" msgstr "Eventos" #: apprise/plugins/ifttt.py:129 msgid "Add Tokens" msgstr "Agregar Tokens" #: apprise/plugins/ifttt.py:133 msgid "Remove Tokens" msgstr "Eliminar Tokens" #: apprise/plugins/jira.py:240 apprise/plugins/opsgenie.py:238 #, fuzzy msgid "Target Escalation" msgstr "Escalada de Destino" #: apprise/plugins/jira.py:246 apprise/plugins/opsgenie.py:244 #, fuzzy msgid "Target Schedule" msgstr "Programacion de Destino" #: apprise/plugins/irc/base.py:137 apprise/plugins/jira.py:252 #: apprise/plugins/line.py:88 apprise/plugins/mastodon.py:196 #: apprise/plugins/matrix/base.py:314 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:129 apprise/plugins/opsgenie.py:250 #: apprise/plugins/pushed.py:95 apprise/plugins/rocketchat.py:156 #: apprise/plugins/slack.py:269 apprise/plugins/twitter.py:187 #: apprise/plugins/wechat.py:198 apprise/plugins/xmpp/base.py:123 #: apprise/plugins/zulip.py:143 msgid "Target User" msgstr "Usuario de Destino" #: apprise/plugins/jira.py:258 apprise/plugins/opsgenie.py:256 #, fuzzy msgid "Target Team" msgstr "Equipo de Destino" #: apprise/plugins/jira.py:264 apprise/plugins/opsgenie.py:262 #, fuzzy msgid "Targets " msgstr "Destinatarios" #: apprise/plugins/jira.py:293 apprise/plugins/opsgenie.py:286 msgid "Entity" msgstr "Entidad" #: apprise/plugins/jira.py:297 apprise/plugins/opsgenie.py:290 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:308 apprise/plugins/opsgenie.py:298 #: apprise/plugins/pagertree.py:118 apprise/plugins/splunk.py:202 #, fuzzy msgid "Action" msgstr "Accion" #: apprise/plugins/jira.py:319 apprise/plugins/opsgenie.py:317 #, fuzzy msgid "Details" msgstr "Detalles" #: apprise/plugins/jira.py:323 apprise/plugins/opsgenie.py:321 #: apprise/plugins/splunk.py:213 msgid "Action Mapping" msgstr "Mapeo de Acciones" #: apprise/plugins/join.py:147 msgid "Device ID" msgstr "ID del Dispositivo" #: apprise/plugins/join.py:153 #, fuzzy msgid "Device Name" msgstr "Nombre del Dispositivo" #: apprise/plugins/kavenegar.py:122 apprise/plugins/messagebird.py:85 #: apprise/plugins/notifyre.py:163 apprise/plugins/plivo.py:100 #, fuzzy msgid "Source Phone No" msgstr "Numero de Telefono de Origen" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:141 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Duracion" #: apprise/plugins/kumulos.py:99 #, fuzzy msgid "Server Key" msgstr "Clave del Servidor" #: apprise/plugins/lametric.py:436 #, fuzzy msgid "Device API Key" msgstr "Clave de API del Dispositivo" #: apprise/plugins/lametric.py:442 apprise/plugins/one_signal.py:102 #: apprise/plugins/parseplatform.py:101 msgid "App ID" msgstr "ID de Aplicacion" #: apprise/plugins/lametric.py:448 #, fuzzy msgid "App Version" msgstr "Version de Aplicacion" #: apprise/plugins/lametric.py:455 #, fuzzy msgid "App Access Token" msgstr "Token de Acceso de Aplicacion" #: apprise/plugins/lametric.py:500 msgid "Custom Icon" msgstr "Icono Personalizado" #: apprise/plugins/lametric.py:504 msgid "Icon Type" msgstr "Tipo de Icono" #: apprise/plugins/lametric.py:521 msgid "Cycles" msgstr "Ciclos" #: apprise/plugins/lark.py:47 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:67 apprise/plugins/revolt.py:98 #: apprise/plugins/telegram.py:357 msgid "Bot Token" msgstr "Token del Bot" #: apprise/plugins/macosx.py:65 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" #: apprise/plugins/macosx.py:72 msgid "MacOSX Notification" msgstr "Notificacion de MacOSX" #: apprise/plugins/macosx.py:128 msgid "Open/Click URL" msgstr "URL de Apertura/Clic" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:165 #: apprise/plugins/office365.py:315 apprise/plugins/pingram.py:231 #: apprise/plugins/resend.py:150 msgid "Reply To" msgstr "Responder A" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:141 #: apprise/plugins/sendpulse.py:115 apprise/plugins/smtp2go.py:113 #: apprise/plugins/sparkpost.py:164 msgid "Domain" msgstr "Dominio" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:168 #: apprise/plugins/postmark.py:144 apprise/plugins/resend.py:140 #: apprise/plugins/ses.py:210 apprise/plugins/smtp2go.py:135 #: apprise/plugins/sparkpost.py:186 msgid "From Name" msgstr "Nombre de Origen" #: apprise/plugins/email/base.py:262 apprise/plugins/mailgun.py:204 #: apprise/plugins/smtp2go.py:161 apprise/plugins/sparkpost.py:219 #, fuzzy msgid "Email Header" msgstr "Cabecera de Correo" #: apprise/plugins/mastodon.py:216 apprise/plugins/misskey.py:143 msgid "Visibility" msgstr "Visibilidad" #: apprise/plugins/mastodon.py:222 msgid "Spoiler Text" msgstr "Texto de Spoiler" #: apprise/plugins/mastodon.py:226 msgid "Idempotency-Key" msgstr "Clave de Idempotencia" #: apprise/plugins/mastodon.py:230 msgid "Language Code" msgstr "Codigo de Idioma" #: apprise/plugins/mastodon.py:234 apprise/plugins/twitter.py:210 msgid "Cache Results" msgstr "Almacenar Resultados en Cache" #: apprise/plugins/mastodon.py:239 msgid "Sensitive Attachments" msgstr "Archivos Adjuntos Sensibles" #: apprise/plugins/mastodon.py:255 #, fuzzy msgid "Ping Users/Tags" msgstr "Mencionar Usuarios/Roles" #: apprise/plugins/irc/base.py:128 apprise/plugins/mattermost.py:156 #: apprise/plugins/xmpp/base.py:112 #, fuzzy msgid "User" msgstr "Usuario" #: apprise/plugins/irc/base.py:143 apprise/plugins/mattermost.py:182 #: apprise/plugins/notifiarr.py:97 apprise/plugins/pushbullet.py:94 #: apprise/plugins/pushed.py:101 apprise/plugins/rocketchat.py:150 #: apprise/plugins/slack.py:275 apprise/plugins/trigv.py:150 #: apprise/plugins/twist.py:112 apprise/plugins/xmpp/base.py:128 msgid "Target Channel" msgstr "Canal de Destino" #: apprise/plugins/mattermost.py:188 apprise/plugins/twist.py:118 #, fuzzy msgid "Target Channel ID" msgstr "ID del Canal de Destino" #: apprise/plugins/mqtt.py:171 msgid "Target Queue" msgstr "" #: apprise/plugins/mqtt.py:188 msgid "QOS" msgstr "QoS" #: apprise/plugins/mqtt.py:201 apprise/plugins/office365.py:269 #: apprise/plugins/ringcentral.py:192 apprise/plugins/sendpulse.py:120 #, fuzzy msgid "Client ID" msgstr "ID del Cliente" #: apprise/plugins/mqtt.py:205 msgid "Use Session" msgstr "Usar Sesion" #: apprise/plugins/mqtt.py:210 msgid "Retain Messages" msgstr "Retener Mensajes" #: apprise/plugins/msg91.py:102 apprise/plugins/sendpulse.py:156 msgid "Template ID" msgstr "ID de Plantilla" #: apprise/plugins/msg91.py:109 #, fuzzy msgid "Authentication Key" msgstr "Clave de Autenticacion" #: apprise/plugins/msg91.py:138 msgid "Short URL" msgstr "URL Corta" #: apprise/plugins/msg91.py:148 apprise/plugins/whatsapp.py:203 msgid "Template Mapping" msgstr "Mapeo de Plantilla" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:113 msgid "URL Prefix" msgstr "Prefijo de URL" #: apprise/plugins/nextcloudtalk.py:43 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:96 #, fuzzy msgid "Room ID" msgstr "ID de la Sala" #: apprise/plugins/notifiarr.py:121 msgid "Discord Event ID" msgstr "ID de Evento de Discord" #: apprise/plugins/notifiarr.py:131 apprise/plugins/pagerduty.py:145 #, fuzzy msgid "Source" msgstr "Origen" #: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:187 #, fuzzy msgid "Project ID" msgstr "ID del Proyecto" #: apprise/plugins/notifico.py:196 msgid "Message Hook" msgstr "Hook de Mensaje" #: apprise/plugins/notifico.py:211 msgid "IRC Colors" msgstr "Colores IRC" #: apprise/plugins/notifico.py:217 msgid "Prefix" msgstr "Prefijo" #: apprise/plugins/notifyre.py:168 #, fuzzy msgid "Campaign Name" msgstr "Usuario de API" #: apprise/plugins/notifyre.py:172 apprise/plugins/whatsapp.py:128 msgid "Template Name" msgstr "Nombre de Plantilla" #: apprise/plugins/notifyre.py:176 #, fuzzy msgid "Client Reference" msgstr "Secreto del Cliente" #: apprise/plugins/notifyre.py:180 msgid "High Quality" msgstr "" #: apprise/plugins/notifyre.py:185 #, fuzzy msgid "Fax Header" msgstr "Cabecera de Correo" #: apprise/plugins/ntfy.py:235 apprise/plugins/pinglet.py:165 msgid "Topic" msgstr "Tema" #: apprise/plugins/ntfy.py:253 msgid "Attach" msgstr "Adjuntar" #: apprise/plugins/ntfy.py:267 msgid "Attach Filename" msgstr "Nombre del Archivo Adjunto" #: apprise/plugins/ntfy.py:275 msgid "Delay" msgstr "Retraso" #: apprise/plugins/ntfy.py:279 apprise/plugins/twist.py:107 #, fuzzy msgid "Email" msgstr "Correo Electronico" #: apprise/plugins/ntfy.py:293 #, fuzzy msgid "Actions" msgstr "Acciones" #: apprise/plugins/ntfy.py:306 #, fuzzy msgid "Authentication Type" msgstr "Tipo de Autenticacion" #: apprise/plugins/octopush.py:130 #, fuzzy msgid "API Login" msgstr "Token de API" #: apprise/plugins/octopush.py:142 #, fuzzy msgid "Sender" msgstr "ID del Remitente" #: apprise/plugins/octopush.py:178 #, fuzzy msgid "Accept Replies" msgstr "Enviar Respuestas" #: apprise/plugins/octopush.py:183 msgid "Purpose" msgstr "" #: apprise/plugins/octopush.py:189 #, fuzzy msgid "Type" msgstr "Tipo de Icono" #: apprise/plugins/office365.py:257 #, fuzzy msgid "Tenant Domain" msgstr "Dominio del Inquilino" #: apprise/plugins/office365.py:264 msgid "Account Email or Object ID" msgstr "Correo de la Cuenta o ID del Objeto" #: apprise/plugins/office365.py:276 apprise/plugins/ringcentral.py:199 #: apprise/plugins/sendpulse.py:127 #, fuzzy msgid "Client Secret" msgstr "Secreto del Cliente" #: apprise/plugins/office365.py:325 #, fuzzy msgid "Save to Sent Items" msgstr "Entradas de Datos" #: apprise/plugins/one_signal.py:108 apprise/plugins/sendgrid.py:146 msgid "Template" msgstr "Plantilla" #: apprise/plugins/one_signal.py:119 #, fuzzy msgid "Target Player ID" msgstr "ID del Jugador de Destino" #: apprise/plugins/one_signal.py:135 #, fuzzy msgid "Include Segment" msgstr "Incluir Segmento" #: apprise/plugins/one_signal.py:155 msgid "Subtitle" msgstr "Subtitulo" #: apprise/plugins/one_signal.py:159 apprise/plugins/sfr.py:125 #: apprise/plugins/whatsapp.py:141 msgid "Language" msgstr "Idioma" #: apprise/plugins/one_signal.py:170 msgid "Enable Contents" msgstr "Habilitar Contenidos" #: apprise/plugins/one_signal.py:176 msgid "Decode Template Args" msgstr "Decodificar Argumentos de Plantilla" #: apprise/plugins/one_signal.py:195 msgid "Custom Data" msgstr "Datos Personalizados" #: apprise/plugins/one_signal.py:199 msgid "Postback Data" msgstr "Datos de Postback" #: apprise/plugins/pagerduty.py:138 apprise/plugins/pushward.py:134 #: apprise/plugins/spike.py:68 #, fuzzy msgid "Integration Key" msgstr "Clave de Integracion" #: apprise/plugins/pagerduty.py:151 #, fuzzy msgid "Component" msgstr "Componente" #: apprise/plugins/pagerduty.py:167 msgid "Class" msgstr "Clase" #: apprise/plugins/pagerduty.py:185 msgid "Severity" msgstr "Severidad" #: apprise/plugins/pagerduty.py:202 #, fuzzy msgid "Custom Details" msgstr "Detalles Personalizados" #: apprise/plugins/pagertree.py:105 msgid "Integration ID" msgstr "ID de Integracion" #: apprise/plugins/pagertree.py:124 msgid "Third Party ID" msgstr "ID de Terceros" #: apprise/plugins/pagertree.py:150 msgid "Meta Extras" msgstr "Extras de Meta" #: apprise/plugins/parseplatform.py:107 #, fuzzy msgid "Master Key" msgstr "Clave Maestra" #: apprise/plugins/parseplatform.py:120 #, fuzzy msgid "Device" msgstr "Dispositivo" #: apprise/plugins/pinglet.py:160 #, fuzzy msgid "Namespace" msgstr "Nombre" #: apprise/plugins/pinglet.py:191 #, fuzzy msgid "Badges" msgstr "Insignia" #: apprise/plugins/pinglet.py:195 #, fuzzy msgid "Metadata" msgstr "Extras de Meta" #: apprise/plugins/matrix/base.py:377 apprise/plugins/pingram.py:169 msgid "Message Type" msgstr "Tipo de Mensaje" #: apprise/plugins/pingram.py:187 #, fuzzy msgid "Target ID" msgstr "ID de Destino" #: apprise/plugins/pingram.py:193 #, fuzzy msgid "Target SMS" msgstr "SMS de Destino" #: apprise/plugins/pingram.py:215 msgid "Channels" msgstr "Canales" #: apprise/plugins/email/base.py:178 apprise/plugins/pingram.py:236 #: apprise/plugins/sendpulse.py:150 apprise/plugins/ses.py:163 msgid "From Email" msgstr "Correo de Origen" #: apprise/plugins/plivo.py:88 #, fuzzy msgid "Auth ID" msgstr "ID de Autenticacion" #: apprise/plugins/plivo.py:94 apprise/plugins/sinch.py:113 #: apprise/plugins/twilio.py:147 msgid "Auth Token" msgstr "Token de Autenticacion" #: apprise/plugins/prowl.py:128 msgid "Provider Key" msgstr "Clave del Proveedor" #: apprise/plugins/pushdeer.py:85 #, fuzzy msgid "Pushkey" msgstr "Clave Push" #: apprise/plugins/pushed.py:83 msgid "Application Key" msgstr "Clave de Aplicacion" #: apprise/plugins/pushed.py:89 apprise/plugins/reddit.py:158 msgid "Application Secret" msgstr "Secreto de Aplicacion" #: apprise/plugins/pushjet.py:82 msgid "Secret Key" msgstr "Clave Secreta" #: apprise/plugins/pushme.py:81 apprise/plugins/signal_api.py:152 #: apprise/plugins/smseagle.py:193 msgid "Show Status" msgstr "Mostrar Estado" #: apprise/plugins/pushover.py:228 msgid "User Key" msgstr "Clave de Usuario" #: apprise/plugins/pushover.py:280 msgid "URL Title" msgstr "Titulo de URL" #: apprise/plugins/pushover.py:285 msgid "Emergency Retry Interval" msgstr "" #: apprise/plugins/pushover.py:291 msgid "Expire" msgstr "Expirar" #: apprise/plugins/pushover.py:308 msgid "E2EE" msgstr "" #: apprise/plugins/pushplus.py:172 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:210 apprise/plugins/qq.py:66 #, fuzzy msgid "User Token" msgstr "Token de Usuario" #: apprise/plugins/pushplus.py:219 msgid "Group Topics" msgstr "" #: apprise/plugins/pushplus.py:240 #, fuzzy msgid "Channel" msgstr "Canales" #: apprise/plugins/pushplus.py:257 #, fuzzy msgid "Webhook Name" msgstr "Modo Webhook" #: apprise/plugins/pushsafer.py:360 #, fuzzy msgid "Private Key" msgstr "Clave Privada" #: apprise/plugins/pushsafer.py:397 #, fuzzy msgid "Vibration" msgstr "Vibracion" #: apprise/plugins/pushward.py:158 #, fuzzy msgid "Info Level" msgstr "Nivel" #: apprise/plugins/pushward.py:164 #, fuzzy msgid "Success Level" msgstr "Secreto de Acceso" #: apprise/plugins/pushward.py:170 msgid "Warning Level" msgstr "" #: apprise/plugins/pushward.py:176 msgid "Failure Level" msgstr "" #: apprise/plugins/pushy.py:79 #, fuzzy msgid "Secret API Key" msgstr "Clave Secreta de API" #: apprise/plugins/fcm/__init__.py:162 apprise/plugins/pushy.py:91 #: apprise/plugins/sns.py:163 apprise/plugins/wxpusher.py:128 msgid "Target Topic" msgstr "Tema de Destino" #: apprise/plugins/qq.py:46 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:151 #, fuzzy msgid "Application ID" msgstr "ID de Aplicacion" #: apprise/plugins/reddit.py:165 #, fuzzy msgid "Target Subreddit" msgstr "Subreddit de Destino" #: apprise/plugins/reddit.py:182 msgid "Kind" msgstr "Tipo" #: apprise/plugins/reddit.py:188 msgid "Flair ID" msgstr "ID del Flair" #: apprise/plugins/reddit.py:193 msgid "Flair Text" msgstr "Texto del Flair" #: apprise/plugins/reddit.py:198 msgid "NSFW" msgstr "Contenido Adulto" #: apprise/plugins/reddit.py:204 msgid "Is Ad?" msgstr "Es Anuncio?" #: apprise/plugins/reddit.py:210 msgid "Send Replies" msgstr "Enviar Respuestas" #: apprise/plugins/reddit.py:216 msgid "Is Spoiler" msgstr "Es Spoiler" #: apprise/plugins/reddit.py:222 msgid "Resubmit Flag" msgstr "Bandera de Reenvio" #: apprise/plugins/resend.py:135 #, fuzzy msgid "From Address" msgstr "Nombre de Origen" #: apprise/plugins/revolt.py:104 #, fuzzy msgid "Channel ID" msgstr "ID del Canal" #: apprise/plugins/revolt.py:131 msgid "Embed URL" msgstr "URL Incrustada" #: apprise/plugins/ringcentral.py:186 #, fuzzy msgid "Token / Password" msgstr "Contrasena" #: apprise/plugins/ringcentral.py:239 #, fuzzy msgid "Environment" msgstr "Evento" #: apprise/plugins/ringcentral.py:252 #, fuzzy msgid "Authentication Mode" msgstr "Token de Autenticacion" #: apprise/plugins/rocketchat.py:146 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:320 apprise/plugins/rocketchat.py:162 msgid "Target Room ID" msgstr "ID de Sala de Destino" #: apprise/plugins/matrix/base.py:359 apprise/plugins/rocketchat.py:178 #: apprise/plugins/ryver.py:118 msgid "Webhook Mode" msgstr "Modo Webhook" #: apprise/plugins/rocketchat.py:183 msgid "Use Avatar" msgstr "Usar Avatar" #: apprise/plugins/rsyslog.py:173 apprise/plugins/syslog.py:144 msgid "Facility" msgstr "Instalacion" #: apprise/plugins/rsyslog.py:203 apprise/plugins/syslog.py:161 msgid "Log PID" msgstr "PID del Log" #: apprise/plugins/ryver.py:93 apprise/plugins/zulip.py:130 msgid "Organization" msgstr "Organizacion" #: apprise/plugins/sendgrid.py:166 apprise/plugins/sendpulse.py:182 msgid "Template Data" msgstr "Datos de Plantilla" #: apprise/plugins/serwersms.py:142 apprise/plugins/sfr.py:131 #, fuzzy msgid "Sender Name" msgstr "Nombre del Remitente" #: apprise/plugins/serwersms.py:155 apprise/plugins/signal_api.py:131 #: apprise/plugins/smseagle.py:158 apprise/plugins/whatsapp.py:159 #, fuzzy msgid "Target Group ID" msgstr "ID del Grupo de Destino" #: apprise/plugins/ses.py:157 apprise/plugins/sns.py:132 #, fuzzy msgid "Session Token" msgstr "Token de Acceso" #: apprise/plugins/ses.py:169 apprise/plugins/sns.py:138 msgid "Access Key ID" msgstr "ID de Clave de Acceso" #: apprise/plugins/ses.py:175 apprise/plugins/sns.py:144 msgid "Secret Access Key" msgstr "Clave de Acceso Secreta" #: apprise/plugins/ses.py:181 apprise/plugins/sinch.py:157 #: apprise/plugins/sns.py:150 msgid "Region" msgstr "Region" #: apprise/plugins/ses.py:188 apprise/plugins/smtp2go.py:124 #: apprise/plugins/sparkpost.py:175 msgid "Target Emails" msgstr "Correos de Destino" #: apprise/plugins/seven.py:113 apprise/plugins/smseagle.py:203 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:117 msgid "Label" msgstr "Etiqueta" #: apprise/plugins/sfr.py:58 msgid "SociΓ©tΓ© FranΓ§aise du RadiotΓ©lΓ©phone" msgstr "" #: apprise/plugins/sfr.py:90 #, fuzzy msgid "Service ID" msgstr "ID del Servicio" #: apprise/plugins/sfr.py:95 #, fuzzy msgid "Service Password" msgstr "Contrasena del Servicio" #: apprise/plugins/sfr.py:101 #, fuzzy msgid "Space ID" msgstr "ID del Espacio" #: apprise/plugins/sfr.py:107 msgid "Recipient Phone Number" msgstr "Numero de Telefono del Destinatario" #: apprise/plugins/sfr.py:138 msgid "Media Type" msgstr "Tipo de Medio" #: apprise/plugins/sfr.py:145 #, fuzzy msgid "Timeout" msgstr "Tiempo de Espera" #: apprise/plugins/sfr.py:151 #, fuzzy msgid "TTS Voice" msgstr "Voz TTS" #: apprise/plugins/signl4.py:89 #, fuzzy msgid "Service" msgstr "Servicio" #: apprise/plugins/signl4.py:93 #, fuzzy msgid "Location" msgstr "Ubicacion" #: apprise/plugins/signl4.py:97 msgid "Alerting Scenario" msgstr "Escenario de Alerta" #: apprise/plugins/signl4.py:101 msgid "Filtering" msgstr "Filtrado" #: apprise/plugins/signl4.py:106 #, fuzzy msgid "External ID" msgstr "ID Externo" #: apprise/plugins/signl4.py:110 #, fuzzy msgid "Status" msgstr "Estado" #: apprise/plugins/simplepush.py:113 msgid "Salt" msgstr "Salt" #: apprise/plugins/simplepush.py:126 #, fuzzy msgid "Event" msgstr "Evento" #: apprise/plugins/sinch.py:134 apprise/plugins/twilio.py:168 msgid "Target Short Code" msgstr "Codigo Corto de Destino" #: apprise/plugins/slack.py:224 #, fuzzy msgid "OAuth Access Token" msgstr "Token de Acceso OAuth" #: apprise/plugins/slack.py:233 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:242 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:251 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:258 msgid "Target Encoded ID" msgstr "ID Codificado de Destino" #: apprise/plugins/slack.py:287 #, fuzzy msgid "Workflow Path" msgstr "ID del Flujo de Trabajo" #: apprise/plugins/slack.py:305 #, fuzzy msgid "Include Footer" msgstr "Incluir Pie de Pagina" #: apprise/plugins/slack.py:313 msgid "Use Blocks" msgstr "Usar Bloques" #: apprise/plugins/slack.py:319 #, fuzzy msgid "Include Timestamp" msgstr "Incluir Marca de Tiempo" #: apprise/plugins/slack.py:325 apprise/plugins/twitter.py:204 #, fuzzy msgid "Message Mode" msgstr "Modo de Mensaje" #: apprise/plugins/smpp.py:61 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:103 #, fuzzy msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:138 #, fuzzy msgid "Login" msgstr "Token de API" #: apprise/plugins/smsc.py:175 msgid "Transliterate" msgstr "" #: apprise/plugins/smseagle.py:165 #, fuzzy msgid "Target Contact" msgstr "Contacto de Destino" #: apprise/plugins/smseagle.py:198 msgid "Test Only" msgstr "Solo Prueba" #: apprise/plugins/smsmanager.py:143 msgid "Gateway" msgstr "Pasarela" #: apprise/plugins/sogs.py:219 #, fuzzy msgid "Public Key" msgstr "Clave de API" #: apprise/plugins/sogs.py:226 #, fuzzy msgid "Seed" msgstr "ID del Remitente" #: apprise/plugins/sogs.py:233 #, fuzzy msgid "Rooms" msgstr "ID de la Sala" #: apprise/plugins/spike.py:48 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:117 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:172 #, fuzzy msgid "Target Routing Key" msgstr "Clave de Enrutamiento de Destino" #: apprise/plugins/splunk.py:179 msgid "Entity ID" msgstr "ID de Entidad" #: apprise/plugins/spugpush.py:48 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:125 msgid "Alert Type" msgstr "Tipo de Alerta" #: apprise/plugins/streamlabs.py:131 msgid "Image Link" msgstr "Enlace de Imagen" #: apprise/plugins/streamlabs.py:136 #, fuzzy msgid "Sound Link" msgstr "Enlace de Sonido" #: apprise/plugins/streamlabs.py:147 msgid "Special Text Color" msgstr "Color de Texto Especial" #: apprise/plugins/streamlabs.py:153 msgid "Amount" msgstr "Cantidad" #: apprise/plugins/streamlabs.py:159 #, fuzzy msgid "Currency" msgstr "Moneda" #: apprise/plugins/streamlabs.py:165 #, fuzzy msgid "Name" msgstr "Nombre" #: apprise/plugins/streamlabs.py:171 msgid "Identifier" msgstr "Identificador" #: apprise/plugins/synology.py:117 msgid "Upload" msgstr "Subir" #: apprise/plugins/syslog.py:167 msgid "Log to STDERR" msgstr "Registrar en STDERR" #: apprise/plugins/telegram.py:366 msgid "Target Chat ID" msgstr "ID del Chat de Destino" #: apprise/plugins/telegram.py:389 msgid "Detect Bot Owner" msgstr "Detectar Propietario del Bot" #: apprise/plugins/telegram.py:395 msgid "Silent Notification" msgstr "Notificacion Silenciosa" #: apprise/plugins/telegram.py:400 msgid "Web Page Preview" msgstr "Vista Previa de Pagina Web" #: apprise/plugins/telegram.py:405 msgid "Topic Thread ID" msgstr "ID de Hilo del Tema" #: apprise/plugins/telegram.py:412 #, fuzzy msgid "Markdown Version" msgstr "Version de Markdown" #: apprise/plugins/telegram.py:421 msgid "Content Placement" msgstr "Ubicacion del Contenido" #: apprise/plugins/telegram.py:427 #, fuzzy msgid "Rich Message Template Path" msgstr "Ruta de Plantilla" #: apprise/plugins/threema.py:85 msgid "Gateway ID" msgstr "ID de la Pasarela" #: apprise/plugins/threema.py:110 #, fuzzy msgid "Target Threema ID" msgstr "ID Threema de Destino" #: apprise/plugins/trigv.py:159 #, fuzzy msgid "API Hostname" msgstr "Nombre del Host" #: apprise/plugins/trigv.py:185 #, fuzzy msgid "Image URL" msgstr "Enlace de Imagen" #: apprise/plugins/trigv.py:195 #, fuzzy msgid "Event type" msgstr "Tipo de Alerta" #: apprise/plugins/trigv.py:199 msgid "Priority (Pushover compatibility)" msgstr "" #: apprise/plugins/twilio.py:200 msgid "Notification Method: sms or call" msgstr "Metodo de Notificacion: sms o llamada" #: apprise/plugins/twitter.py:163 msgid "Consumer Key" msgstr "Clave del Consumidor" #: apprise/plugins/twitter.py:169 msgid "Consumer Secret" msgstr "Secreto del Consumidor" #: apprise/plugins/twitter.py:181 msgid "Access Secret" msgstr "Secreto de Acceso" #: apprise/plugins/viber.py:49 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:81 #, fuzzy msgid "Authentication Token" msgstr "Token de Autenticacion" #: apprise/plugins/viber.py:87 msgid "Receiver IDs" msgstr "IDs de Receptores" #: apprise/plugins/viber.py:105 #, fuzzy msgid "Bot Avatar URL" msgstr "URL del Avatar del Bot" #: apprise/plugins/voipms.py:83 #, fuzzy msgid "User Email" msgstr "Correo del Usuario" #: apprise/plugins/vapid/__init__.py:179 apprise/plugins/vonage.py:133 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:178 #, fuzzy msgid "Bot Access Token" msgstr "Token de Acceso OAuth" #: apprise/plugins/webexteams.py:184 #, fuzzy msgid "Room IDs" msgstr "ID de la Sala" #: apprise/plugins/wechat.py:174 #, fuzzy msgid "Corp ID" msgstr "ID de Aplicacion" #: apprise/plugins/wechat.py:181 #, fuzzy msgid "App Secret" msgstr "Secreto de API" #: apprise/plugins/wechat.py:189 #, fuzzy msgid "Agent ID" msgstr "ID de Destino" #: apprise/plugins/wechat.py:205 #, fuzzy msgid "Target Department" msgstr "Dispositivo de Destino" #: apprise/plugins/wechat.py:212 #, fuzzy msgid "Target Tag" msgstr "Equipo de Destino" #: apprise/plugins/wecombot.py:99 #, fuzzy msgid "Bot Webhook Key" msgstr "Clave Webhook del Bot" #: apprise/plugins/whatsapp.py:134 #, fuzzy msgid "From Phone ID" msgstr "ID del Telefono de Origen" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Se requiere un entorno local de Microsoft Windows." #: apprise/plugins/workflows.py:150 #, fuzzy msgid "Workflow ID" msgstr "ID del Flujo de Trabajo" #: apprise/plugins/workflows.py:158 msgid "Signature" msgstr "Firma" #: apprise/plugins/workflows.py:181 msgid "Use Power Automate URL" msgstr "Usar URL de Power Automate" #: apprise/plugins/workflows.py:188 #, fuzzy msgid "Power Automate Routing ID" msgstr "Usar URL de Power Automate" #: apprise/plugins/workflows.py:195 msgid "Wrap Text" msgstr "Ajustar Texto" #: apprise/plugins/workflows.py:210 #, fuzzy msgid "API Version" msgstr "Version de API" #: apprise/plugins/wxpusher.py:121 #, fuzzy msgid "App Token" msgstr "Token de Aplicacion" #: apprise/plugins/wxpusher.py:133 #, fuzzy msgid "Target User ID" msgstr "ID de Usuario de Destino" #: apprise/plugins/zoom.py:145 #, fuzzy msgid "Verification Token" msgstr "Token de Autenticacion" #: apprise/plugins/zulip.py:148 #, fuzzy msgid "Target Stream" msgstr "Stream de Destino" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Servidor SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:149 msgid "Secure Mode" msgstr "Modo Seguro" #: apprise/plugins/email/base.py:205 #, fuzzy msgid "PGP Security Mode" msgstr "Modo Seguro" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Ruta de Clave Publica PGP" #: apprise/plugins/email/base.py:225 #, fuzzy msgid "PGP Private Key Path" msgstr "Ruta de Clave Publica PGP" #: apprise/plugins/email/base.py:233 msgid "Web Key Directory" msgstr "" #: apprise/plugins/email/base.py:239 #, fuzzy msgid "Inline Attachments" msgstr "Archivos Adjuntos Sensibles" #: apprise/plugins/email/base.py:244 msgid "To Email" msgstr "Para Correo" #: apprise/plugins/fcm/__init__.py:148 msgid "OAuth2 KeyFile" msgstr "Archivo de Clave OAuth2" #: apprise/plugins/fcm/__init__.py:193 msgid "Custom Image URL" msgstr "URL de Imagen Personalizada" #: apprise/plugins/fcm/__init__.py:205 msgid "Notification Color" msgstr "Color de Notificacion" #: apprise/plugins/fcm/__init__.py:215 msgid "Data Entries" msgstr "Entradas de Datos" #: apprise/plugins/irc/base.py:159 #, fuzzy msgid "Real Name" msgstr "Nombre Real" #: apprise/plugins/irc/base.py:160 #, fuzzy msgid "Nickname" msgstr "Apodo" #: apprise/plugins/irc/base.py:162 #, fuzzy msgid "Join Channels" msgstr "Unirse a Canales" #: apprise/plugins/irc/base.py:167 msgid "Auth Mode" msgstr "" #: apprise/plugins/matrix/base.py:326 msgid "Target Room Alias" msgstr "Alias de Sala de Destino" #: apprise/plugins/matrix/base.py:349 #, fuzzy msgid "Server Discovery" msgstr "Descubrimiento de Servidor" #: apprise/plugins/matrix/base.py:354 msgid "Force Home Server on Room IDs" msgstr "Forzar Servidor Home en IDs de Sala" #: apprise/plugins/matrix/base.py:371 msgid "Matrix API Verion" msgstr "Version de API de Matrix" #: apprise/plugins/matrix/base.py:383 #, fuzzy msgid "End-to-End Encryption" msgstr "Cifrado PGP" #: apprise/plugins/vapid/__init__.py:193 msgid "PEM Private KeyFile" msgstr "Archivo de Clave Privada PEM" #: apprise/plugins/vapid/__init__.py:199 msgid "Subscripion File" msgstr "Archivo de Suscripcion" #: apprise/plugins/xmpp/base.py:144 #, fuzzy msgid "XMPP Server" msgstr "Servidor SMTP" #: apprise/plugins/xmpp/base.py:156 #, fuzzy msgid "Get Roster" msgstr "Obtener Lista de Contactos" #: apprise/plugins/xmpp/base.py:161 msgid "Use Subject" msgstr "Usar Asunto" #: apprise/plugins/xmpp/base.py:166 #, fuzzy msgid "Keep Connection Alive" msgstr "Mantener Conexion Activa" #: apprise/plugins/xmpp/base.py:172 #, fuzzy msgid "MUC Nickname" msgstr "Apodo MUC" #: apprise/plugins/xmpp/base.py:176 msgid "SCRAM-PLUS Channel Binding" msgstr "" caronc-apprise-182f859/apprise/i18n/it/000077500000000000000000000000001524161175200175525ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/it/LC_MESSAGES000066400000000000000000001636331524161175200212760ustar00rootroot00000000000000# Italian translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # albano battistella , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 1.12.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-07-04 12:10-0400\n" "PO-Revision-Date: 2026-07-11 20:56+0200\n" "Last-Translator: Chris Caron \n" "Language: it\n" "Language-Team: it \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:96 apprise/url.py:157 msgid "Verify SSL" msgstr "Verifica SSL" #: apprise/url.py:167 msgid "Follow Redirects" msgstr "Segui i reindirizzamenti" #: apprise/url.py:177 msgid "Socket Read Timeout" msgstr "Timeout di lettura del socket" #: apprise/url.py:191 msgid "Socket Connect Timeout" msgstr "Timeout di connessione del socket" #: apprise/attachment/base.py:82 msgid "Cache Age" msgstr "EtΓ  della cache" #: apprise/attachment/base.py:88 msgid "Forced Mime Type" msgstr "Mime Type forzato" #: apprise/attachment/base.py:92 msgid "Forced File Name" msgstr "Nome file forzato" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "File locale" #: apprise/attachment/http.py:46 apprise/config/http.py:54 msgid "Web Based" msgstr "Basato sul Web" #: apprise/attachment/memory.py:44 apprise/config/memory.py:37 msgid "Memory" msgstr "Memoria" #: apprise/plugins/__init__.py:278 msgid "Schema" msgstr "Schema" #: apprise/plugins/__init__.py:398 msgid "No dependencies." msgstr "Nessuna dipendenza." #: apprise/plugins/__init__.py:401 msgid "Packages are required to function." msgstr "I pacchetti sono richiesti per il funzionamento." #: apprise/plugins/__init__.py:405 msgid "Packages are recommended to improve functionality." msgstr "I pacchetti sono consigliati per migliorare le funzionalitΓ ." #: apprise/plugins/africas_talking.py:132 msgid "App User Name" msgstr "Nome utente dell'app" #: apprise/plugins/africas_talking.py:138 apprise/plugins/brevo.py:157 #: apprise/plugins/burstsms.py:104 apprise/plugins/clicksend.py:98 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:113 #: apprise/plugins/exotel.py:213 apprise/plugins/fcm/__init__.py:143 #: apprise/plugins/flowtriq.py:112 apprise/plugins/httpsms.py:77 #: apprise/plugins/jira.py:230 apprise/plugins/join.py:140 #: apprise/plugins/kavenegar.py:115 apprise/plugins/kumulos.py:87 #: apprise/plugins/mailersend.py:126 apprise/plugins/mailgun.py:146 #: apprise/plugins/messagebird.py:78 apprise/plugins/notifyre.py:130 #: apprise/plugins/octopush.py:136 apprise/plugins/one_signal.py:113 #: apprise/plugins/opsgenie.py:228 apprise/plugins/pagerduty.py:131 #: apprise/plugins/postmark.py:117 apprise/plugins/prowl.py:121 #: apprise/plugins/resend.py:107 apprise/plugins/resend.py:145 #: apprise/plugins/sendgrid.py:116 apprise/plugins/seven.py:75 #: apprise/plugins/simplepush.py:101 apprise/plugins/smsmanager.py:106 #: apprise/plugins/smtp2go.py:118 apprise/plugins/sparkpost.py:169 #: apprise/plugins/splunk.py:165 apprise/plugins/techuluspush.py:97 #: apprise/plugins/twilio.py:194 apprise/plugins/vapid/__init__.py:152 #: apprise/plugins/vonage.py:80 msgid "API Key" msgstr "Chiave API" #: apprise/plugins/africas_talking.py:145 apprise/plugins/fortysixelks.py:106 msgid "Target Phone" msgstr "Telefono di destinazione" #: apprise/plugins/africas_talking.py:150 apprise/plugins/aprs.py:187 #: apprise/plugins/bark.py:159 apprise/plugins/brevo.py:174 #: apprise/plugins/bulksms.py:137 apprise/plugins/bulkvs.py:110 #: apprise/plugins/burstsms.py:131 apprise/plugins/clickatell.py:90 #: apprise/plugins/clicksend.py:112 apprise/plugins/d7networks.py:110 #: apprise/plugins/dapnet.py:138 apprise/plugins/dingtalk.py:111 #: apprise/plugins/eight00com.py:128 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:190 #: apprise/plugins/fcm/__init__.py:168 apprise/plugins/flock.py:125 #: apprise/plugins/fortysixelks.py:111 apprise/plugins/home_assistant.py:153 #: apprise/plugins/httpsms.py:97 apprise/plugins/humhub.py:149 #: apprise/plugins/irc/base.py:149 apprise/plugins/join.py:172 #: apprise/plugins/kavenegar.py:135 apprise/plugins/kook.py:176 #: apprise/plugins/line.py:93 apprise/plugins/mailersend.py:143 #: apprise/plugins/mailgun.py:157 apprise/plugins/mastodon.py:202 #: apprise/plugins/matrix/base.py:307 apprise/plugins/mattermost.py:189 #: apprise/plugins/messagebird.py:99 apprise/plugins/mqtt.py:177 #: apprise/plugins/msg91.py:123 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:101 apprise/plugins/notifiarr.py:103 #: apprise/plugins/notificationapi.py:192 apprise/plugins/notifyre.py:143 #: apprise/plugins/ntfy.py:241 apprise/plugins/octopush.py:153 #: apprise/plugins/office365.py:287 apprise/plugins/one_signal.py:141 #: apprise/plugins/plivo.py:114 apprise/plugins/postmark.py:133 #: apprise/plugins/pushbullet.py:105 apprise/plugins/pushed.py:107 #: apprise/plugins/pushover.py:252 apprise/plugins/pushsafer.py:376 #: apprise/plugins/pushy.py:97 apprise/plugins/reddit.py:170 #: apprise/plugins/resend.py:124 apprise/plugins/revolt.py:112 #: apprise/plugins/ringcentral.py:219 apprise/plugins/rocketchat.py:167 #: apprise/plugins/sendgrid.py:133 apprise/plugins/sendpulse.py:139 #: apprise/plugins/serwersms.py:161 apprise/plugins/seven.py:88 #: apprise/plugins/sfr.py:113 apprise/plugins/signal_api.py:138 #: apprise/plugins/sinch.py:140 apprise/plugins/slack.py:281 #: apprise/plugins/smpp.py:128 apprise/plugins/smsc.py:156 #: apprise/plugins/smseagle.py:172 apprise/plugins/smsmanager.py:119 #: apprise/plugins/sns.py:170 apprise/plugins/telegram.py:365 #: apprise/plugins/threema.py:115 apprise/plugins/twilio.py:174 #: apprise/plugins/twist.py:123 apprise/plugins/twitter.py:193 #: apprise/plugins/vapid/__init__.py:158 apprise/plugins/voipms.py:108 #: apprise/plugins/vonage.py:108 apprise/plugins/wechat.py:218 #: apprise/plugins/whatsapp.py:166 apprise/plugins/wxpusher.py:139 #: apprise/plugins/xmpp/base.py:134 apprise/plugins/zulip.py:153 msgid "Targets" msgstr "Destinatari" #: apprise/plugins/africas_talking.py:166 msgid "From" msgstr "Da" #: apprise/plugins/africas_talking.py:172 msgid "SMS Mode" msgstr "ModalitΓ  SMS" #: apprise/plugins/africas_talking.py:181 apprise/plugins/bulksms.py:170 #: apprise/plugins/bulkvs.py:131 apprise/plugins/burstsms.py:166 #: apprise/plugins/clicksend.py:130 apprise/plugins/d7networks.py:144 #: apprise/plugins/dapnet.py:167 apprise/plugins/exotel.py:222 #: apprise/plugins/home_assistant.py:170 apprise/plugins/jira.py:282 #: apprise/plugins/mailgun.py:194 apprise/plugins/mastodon.py:247 #: apprise/plugins/octopush.py:173 apprise/plugins/one_signal.py:185 #: apprise/plugins/opsgenie.py:307 apprise/plugins/plivo.py:137 #: apprise/plugins/signal_api.py:160 apprise/plugins/smseagle.py:211 #: apprise/plugins/smsmanager.py:152 apprise/plugins/smtp2go.py:151 #: apprise/plugins/sparkpost.py:209 apprise/plugins/twitter.py:218 msgid "Batch Mode" msgstr "ModalitΓ  batch" #: apprise/plugins/apprise_api.py:102 apprise/plugins/bark.py:134 #: apprise/plugins/custom_form.py:130 apprise/plugins/custom_json.py:112 #: apprise/plugins/custom_xml.py:112 apprise/plugins/emby.py:85 #: apprise/plugins/enigma2.py:110 apprise/plugins/evolution.py:119 #: apprise/plugins/flowtriq.py:118 apprise/plugins/fluxer.py:159 #: apprise/plugins/gotify.py:129 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:122 apprise/plugins/humhub.py:138 #: apprise/plugins/irc/base.py:117 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:460 apprise/plugins/mastodon.py:180 #: apprise/plugins/matrix/base.py:262 apprise/plugins/mattermost.py:155 #: apprise/plugins/misskey.py:117 apprise/plugins/mqtt.py:149 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74 #: apprise/plugins/notica.py:126 apprise/plugins/notifico.py:166 #: apprise/plugins/ntfy.py:211 apprise/plugins/parseplatform.py:90 #: apprise/plugins/pushdeer.py:75 apprise/plugins/pushjet.py:71 #: apprise/plugins/rocketchat.py:120 apprise/plugins/rsyslog.py:180 #: apprise/plugins/signal_api.py:97 apprise/plugins/smseagle.py:135 #: apprise/plugins/sogs.py:207 apprise/plugins/synology.py:83 #: apprise/plugins/workflows.py:138 apprise/plugins/xmpp/base.py:101 msgid "Hostname" msgstr "Hostname" #: apprise/plugins/apprise_api.py:107 apprise/plugins/bark.py:139 #: apprise/plugins/custom_form.py:135 apprise/plugins/custom_json.py:117 #: apprise/plugins/custom_xml.py:117 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:115 #: apprise/plugins/evolution.py:124 apprise/plugins/flowtriq.py:123 #: apprise/plugins/fluxer.py:163 apprise/plugins/gotify.py:140 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:127 #: apprise/plugins/humhub.py:143 apprise/plugins/irc/base.py:122 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:464 #: apprise/plugins/mastodon.py:190 apprise/plugins/matrix/base.py:267 #: apprise/plugins/mattermost.py:171 apprise/plugins/misskey.py:127 #: apprise/plugins/mqtt.py:154 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130 #: apprise/plugins/notifico.py:170 apprise/plugins/ntfy.py:215 #: apprise/plugins/parseplatform.py:95 apprise/plugins/pushdeer.py:79 #: apprise/plugins/pushjet.py:76 apprise/plugins/rocketchat.py:125 #: apprise/plugins/rsyslog.py:185 apprise/plugins/signal_api.py:102 #: apprise/plugins/smpp.py:108 apprise/plugins/smseagle.py:140 #: apprise/plugins/sogs.py:212 apprise/plugins/synology.py:88 #: apprise/plugins/workflows.py:143 apprise/plugins/xmpp/base.py:106 msgid "Port" msgstr "Porta" #: apprise/plugins/apprise_api.py:113 apprise/plugins/bark.py:145 #: apprise/plugins/bluesky.py:119 apprise/plugins/custom_form.py:141 #: apprise/plugins/custom_json.py:123 apprise/plugins/custom_xml.py:123 #: apprise/plugins/emby.py:97 apprise/plugins/enigma2.py:121 #: apprise/plugins/freemobile.py:78 apprise/plugins/home_assistant.py:133 #: apprise/plugins/jira.py:236 apprise/plugins/kodi.py:107 #: apprise/plugins/lametric.py:471 apprise/plugins/matrix/base.py:273 #: apprise/plugins/nextcloud.py:127 apprise/plugins/nextcloudtalk.py:85 #: apprise/plugins/notica.py:136 apprise/plugins/notifico.py:176 #: apprise/plugins/ntfy.py:221 apprise/plugins/opsgenie.py:234 #: apprise/plugins/pushjet.py:88 apprise/plugins/rocketchat.py:131 #: apprise/plugins/serwersms.py:131 apprise/plugins/signal_api.py:108 #: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:94 msgid "Username" msgstr "Nome utente" #: apprise/plugins/apprise_api.py:117 apprise/plugins/aprs.py:172 #: apprise/plugins/bark.py:149 apprise/plugins/bluesky.py:124 #: apprise/plugins/bulksms.py:117 apprise/plugins/bulkvs.py:90 #: apprise/plugins/custom_form.py:145 apprise/plugins/custom_json.py:127 #: apprise/plugins/custom_xml.py:127 apprise/plugins/dapnet.py:123 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:101 #: apprise/plugins/enigma2.py:125 apprise/plugins/freemobile.py:83 #: apprise/plugins/growl.py:151 apprise/plugins/home_assistant.py:137 #: apprise/plugins/humhub.py:133 apprise/plugins/irc/base.py:132 #: apprise/plugins/kodi.py:111 apprise/plugins/matrix/base.py:277 #: apprise/plugins/mqtt.py:165 apprise/plugins/nextcloud.py:131 #: apprise/plugins/nextcloudtalk.py:90 apprise/plugins/notica.py:140 #: apprise/plugins/notifico.py:180 apprise/plugins/ntfy.py:225 #: apprise/plugins/pushjet.py:92 apprise/plugins/reddit.py:145 #: apprise/plugins/rocketchat.py:135 apprise/plugins/serwersms.py:136 #: apprise/plugins/signal_api.py:112 apprise/plugins/simplepush.py:108 #: apprise/plugins/smpp.py:97 apprise/plugins/smsc.py:143 #: apprise/plugins/synology.py:98 apprise/plugins/twist.py:101 #: apprise/plugins/voipms.py:88 apprise/plugins/xmpp/base.py:117 msgid "Password" msgstr "Password" #: apprise/plugins/apprise_api.py:122 apprise/plugins/chanify.py:74 #: apprise/plugins/dingtalk.py:93 apprise/plugins/exotel.py:166 #: apprise/plugins/feishu.py:80 apprise/plugins/gotify.py:123 #: apprise/plugins/kook.py:170 apprise/plugins/mattermost.py:161 #: apprise/plugins/notica.py:119 apprise/plugins/notifiarr.py:91 #: apprise/plugins/ntfy.py:230 apprise/plugins/pushme.py:62 #: apprise/plugins/ryver.py:99 apprise/plugins/serverchan.py:70 #: apprise/plugins/slack.py:331 apprise/plugins/synology.py:103 #: apprise/plugins/zulip.py:136 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:136 apprise/plugins/jira.py:301 #: apprise/plugins/ntfy.py:289 apprise/plugins/opsgenie.py:294 #: apprise/plugins/pagertree.py:133 msgid "Tags" msgstr "Tag" #: apprise/plugins/apprise_api.py:140 msgid "Query Method" msgstr "Metodo di query" #: apprise/plugins/apprise_api.py:154 apprise/plugins/custom_form.py:174 #: apprise/plugins/custom_json.py:150 apprise/plugins/custom_xml.py:150 #: apprise/plugins/enigma2.py:153 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:122 apprise/plugins/notica.py:156 #: apprise/plugins/pagertree.py:142 apprise/plugins/synology.py:128 msgid "HTTP Header" msgstr "Intestazione HTTP" #: apprise/plugins/aprs.py:167 apprise/plugins/bulksms.py:112 #: apprise/plugins/bulkvs.py:85 apprise/plugins/clicksend.py:93 #: apprise/plugins/dapnet.py:118 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:160 #: apprise/plugins/reddit.py:140 apprise/plugins/sendpulse.py:110 #: apprise/plugins/smtp2go.py:108 apprise/plugins/sparkpost.py:159 msgid "User Name" msgstr "Nome utente" #: apprise/plugins/aprs.py:178 apprise/plugins/aprs.py:199 #: apprise/plugins/dapnet.py:129 apprise/plugins/dapnet.py:162 msgid "Target Callsign" msgstr "Nominativo di destinazione" #: apprise/plugins/aprs.py:204 msgid "Resend Delay" msgstr "Ritardo di reinvio" #: apprise/plugins/aprs.py:211 msgid "Locale" msgstr "Impostazione locale" #: apprise/plugins/bark.py:154 apprise/plugins/fcm/__init__.py:157 #: apprise/plugins/home_assistant.py:148 apprise/plugins/pushbullet.py:89 #: apprise/plugins/pushover.py:240 apprise/plugins/pushsafer.py:366 #: apprise/plugins/pushy.py:85 msgid "Target Device" msgstr "Dispositivo di destinazione" #: apprise/plugins/bark.py:171 apprise/plugins/lametric.py:516 #: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:269 #: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110 msgid "Sound" msgstr "Suono" #: apprise/plugins/bark.py:176 apprise/plugins/pushward.py:152 msgid "Level" msgstr "Livello" #: apprise/plugins/bark.py:181 apprise/plugins/pushward.py:182 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:187 apprise/plugins/ntfy.py:271 #: apprise/plugins/pagerduty.py:172 msgid "Click" msgstr "Clic" #: apprise/plugins/bark.py:191 apprise/plugins/pushy.py:114 msgid "Badge" msgstr "Badge" #: apprise/plugins/bark.py:196 msgid "Category" msgstr "Categoria" #: apprise/plugins/bark.py:200 apprise/plugins/join.py:158 #: apprise/plugins/pagerduty.py:163 msgid "Group" msgstr "Gruppo" #: apprise/plugins/bark.py:204 apprise/plugins/dbus.py:228 #: apprise/plugins/discord.py:210 apprise/plugins/fcm/__init__.py:197 #: apprise/plugins/flock.py:136 apprise/plugins/fluxer.py:250 #: apprise/plugins/glib.py:188 apprise/plugins/gnome.py:154 #: apprise/plugins/growl.py:175 apprise/plugins/join.py:183 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:108 #: apprise/plugins/macosx.py:115 apprise/plugins/matrix/base.py:318 #: apprise/plugins/mattermost.py:212 apprise/plugins/notifiarr.py:125 #: apprise/plugins/ntfy.py:257 apprise/plugins/one_signal.py:164 #: apprise/plugins/pagerduty.py:191 apprise/plugins/ryver.py:124 #: apprise/plugins/slack.py:299 apprise/plugins/telegram.py:376 #: apprise/plugins/vapid/__init__.py:204 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:175 msgid "Include Image" msgstr "Includi immagine" #: apprise/plugins/bark.py:210 apprise/plugins/mattermost.py:208 #: apprise/plugins/revolt.py:129 msgid "Icon URL" msgstr "URL dell'icona" #: apprise/plugins/bark.py:214 apprise/plugins/streamlabs.py:119 msgid "Call" msgstr "Chiamata" #: apprise/plugins/base.py:305 msgid "Overflow Mode" msgstr "ModalitΓ  di overflow" #: apprise/plugins/base.py:320 msgid "Notify Format" msgstr "Formato di notifica" #: apprise/plugins/base.py:330 msgid "Interpret Emojis" msgstr "Interpreta emoji" #: apprise/plugins/base.py:340 msgid "Persistent Storage" msgstr "Archiviazione persistente" #: apprise/plugins/base.py:350 msgid "Timezone" msgstr "Fuso orario" #: apprise/plugins/base.py:359 msgid "Service Retry" msgstr "Tentativi del servizio" #: apprise/plugins/base.py:367 msgid "Inter-Retry Wait" msgstr "Attesa tra i tentativi" #: apprise/plugins/base.py:375 msgid "Optional Service" msgstr "Servizio opzionale" #: apprise/plugins/blink1.py:151 msgid "blink(1)" msgstr "blink(1)" #: apprise/plugins/blink1.py:178 msgid "Serial Number" msgstr "Numero di serie" #: apprise/plugins/blink1.py:188 msgid "Duration (ms)" msgstr "Durata (ms)" #: apprise/plugins/blink1.py:195 msgid "Fade Time (ms)" msgstr "Tempo di dissolvenza (ms)" #: apprise/plugins/blink1.py:202 msgid "LED Number" msgstr "Numero del LED" #: apprise/plugins/brevo.py:164 apprise/plugins/mailersend.py:133 #: apprise/plugins/postmark.py:123 apprise/plugins/resend.py:114 #: apprise/plugins/sendgrid.py:123 msgid "Source Email" msgstr "Email di origine" #: apprise/plugins/brevo.py:169 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:138 apprise/plugins/mailgun.py:152 #: apprise/plugins/notificationapi.py:177 apprise/plugins/office365.py:282 #: apprise/plugins/one_signal.py:124 apprise/plugins/postmark.py:128 #: apprise/plugins/pushbullet.py:100 apprise/plugins/pushsafer.py:371 #: apprise/plugins/resend.py:119 apprise/plugins/sendgrid.py:128 #: apprise/plugins/sendpulse.py:134 apprise/plugins/slack.py:264 #: apprise/plugins/threema.py:105 msgid "Target Email" msgstr "Email di destinazione" #: apprise/plugins/brevo.py:185 apprise/plugins/postmark.py:149 #: apprise/plugins/ses.py:205 msgid "Reply To Email" msgstr "Rispondi a (Email)" #: apprise/plugins/brevo.py:193 apprise/plugins/email/base.py:249 #: apprise/plugins/mailersend.py:157 apprise/plugins/mailgun.py:186 #: apprise/plugins/notificationapi.py:243 apprise/plugins/office365.py:307 #: apprise/plugins/postmark.py:157 apprise/plugins/resend.py:158 #: apprise/plugins/sendgrid.py:153 apprise/plugins/sendpulse.py:169 #: apprise/plugins/ses.py:232 apprise/plugins/smtp2go.py:143 #: apprise/plugins/sparkpost.py:201 msgid "Carbon Copy" msgstr "Copia conoscenza (CC)" #: apprise/plugins/brevo.py:197 apprise/plugins/email/base.py:253 #: apprise/plugins/mailersend.py:161 apprise/plugins/mailgun.py:190 #: apprise/plugins/notificationapi.py:247 apprise/plugins/office365.py:311 #: apprise/plugins/postmark.py:161 apprise/plugins/resend.py:162 #: apprise/plugins/sendgrid.py:157 apprise/plugins/sendpulse.py:173 #: apprise/plugins/ses.py:236 apprise/plugins/smtp2go.py:147 #: apprise/plugins/sparkpost.py:205 msgid "Blind Carbon Copy" msgstr "Copia conoscenza nascosta (CCN)" #: apprise/plugins/bulksms.py:123 apprise/plugins/bulkvs.py:103 #: apprise/plugins/burstsms.py:124 apprise/plugins/clickatell.py:83 #: apprise/plugins/clicksend.py:105 apprise/plugins/d7networks.py:103 #: apprise/plugins/dingtalk.py:106 apprise/plugins/eight00com.py:121 #: apprise/plugins/evolution.py:135 apprise/plugins/exotel.py:183 #: apprise/plugins/httpsms.py:90 apprise/plugins/kavenegar.py:128 #: apprise/plugins/messagebird.py:92 apprise/plugins/msg91.py:116 #: apprise/plugins/notifyre.py:136 apprise/plugins/octopush.py:146 #: apprise/plugins/plivo.py:107 apprise/plugins/ringcentral.py:213 #: apprise/plugins/serwersms.py:148 apprise/plugins/seven.py:81 #: apprise/plugins/signal_api.py:124 apprise/plugins/sinch.py:127 #: apprise/plugins/smpp.py:121 apprise/plugins/smsc.py:149 #: apprise/plugins/smseagle.py:151 apprise/plugins/smsmanager.py:112 #: apprise/plugins/sns.py:157 apprise/plugins/threema.py:98 #: apprise/plugins/twilio.py:161 apprise/plugins/voipms.py:101 #: apprise/plugins/vonage.py:101 apprise/plugins/whatsapp.py:147 msgid "Target Phone No" msgstr "Numero di telefono di destinazione" #: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:246 msgid "Target Group" msgstr "Gruppo di destinazione" #: apprise/plugins/bulksms.py:149 apprise/plugins/bulkvs.py:96 #: apprise/plugins/bulkvs.py:122 apprise/plugins/clickatell.py:78 #: apprise/plugins/eight00com.py:114 apprise/plugins/eight00com.py:142 #: apprise/plugins/fortysixelks.py:100 apprise/plugins/httpsms.py:83 #: apprise/plugins/httpsms.py:115 apprise/plugins/ringcentral.py:207 #: apprise/plugins/signal_api.py:117 apprise/plugins/sinch.py:120 #: apprise/plugins/smpp.py:114 apprise/plugins/smsmanager.py:134 #: apprise/plugins/twilio.py:154 apprise/plugins/voipms.py:94 #: apprise/plugins/vonage.py:94 msgid "From Phone No" msgstr "Numero di telefono mittente" #: apprise/plugins/bulksms.py:155 msgid "Route Group" msgstr "Gruppo di instradamento" #: apprise/plugins/bulksms.py:162 apprise/plugins/d7networks.py:136 #: apprise/plugins/exotel.py:228 msgid "Unicode Characters" msgstr "Caratteri Unicode" #: apprise/plugins/burstsms.py:111 apprise/plugins/threema.py:92 #: apprise/plugins/vonage.py:87 msgid "API Secret" msgstr "Segreto API" #: apprise/plugins/burstsms.py:118 apprise/plugins/smsc.py:171 msgid "Sender ID" msgstr "ID mittente" #: apprise/plugins/burstsms.py:152 msgid "Country" msgstr "Paese" #: apprise/plugins/burstsms.py:161 msgid "validity" msgstr "validitΓ " #: apprise/plugins/chanify.py:47 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/chime.py:130 apprise/plugins/discord.py:144 #: apprise/plugins/fluxer.py:174 apprise/plugins/ifttt.py:103 #: apprise/plugins/zoom.py:139 msgid "Webhook ID" msgstr "ID Webhook" #: apprise/plugins/chime.py:136 apprise/plugins/discord.py:150 #: apprise/plugins/fluxer.py:181 apprise/plugins/google_chat.py:127 #: apprise/plugins/stackfield.py:92 apprise/plugins/webexteams.py:171 msgid "Webhook Token" msgstr "Token Webhook" #: apprise/plugins/clickatell.py:45 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:72 apprise/plugins/eight00com.py:108 #: apprise/plugins/rocketchat.py:140 msgid "API Token" msgstr "Token API" #: apprise/plugins/custom_form.py:157 apprise/plugins/custom_json.py:139 #: apprise/plugins/custom_xml.py:139 msgid "Fetch Method" msgstr "Metodo di recupero (Fetch)" #: apprise/plugins/custom_form.py:163 msgid "Attach File As" msgstr "Allega file come" #: apprise/plugins/custom_form.py:178 apprise/plugins/custom_json.py:154 #: apprise/plugins/custom_xml.py:154 apprise/plugins/pagertree.py:146 msgid "Payload Extras" msgstr "Elementi extra del payload" #: apprise/plugins/custom_form.py:182 apprise/plugins/custom_json.py:158 #: apprise/plugins/custom_xml.py:158 msgid "GET Params" msgstr "Parametri GET" #: apprise/plugins/d7networks.py:97 msgid "API Access Token" msgstr "Token di accesso API" #: apprise/plugins/d7networks.py:127 apprise/plugins/seven.py:105 msgid "Originating Address" msgstr "Indirizzo di origine" #: apprise/plugins/dapnet.py:150 apprise/plugins/exotel.py:240 #: apprise/plugins/gotify.py:153 apprise/plugins/growl.py:163 #: apprise/plugins/jira.py:287 apprise/plugins/join.py:189 #: apprise/plugins/lametric.py:494 apprise/plugins/ntfy.py:283 #: apprise/plugins/opsgenie.py:280 apprise/plugins/prowl.py:141 #: apprise/plugins/pushover.py:263 apprise/plugins/pushsafer.py:387 #: apprise/plugins/smseagle.py:187 msgid "Priority" msgstr "PrioritΓ " #: apprise/plugins/dapnet.py:156 msgid "Transmitter Groups" msgstr "Gruppi di trasmissione" #: apprise/plugins/dbus.py:156 msgid "libdbus-1.so.x must be installed." msgstr "La libreria libdbus-1.so.x deve essere installata." #: apprise/plugins/dbus.py:160 msgid "DBus Notification" msgstr "Notifica DBus" #: apprise/plugins/dbus.py:204 apprise/plugins/glib.py:165 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:128 msgid "Urgency" msgstr "Urgenza" #: apprise/plugins/dbus.py:216 apprise/plugins/glib.py:176 msgid "X-Axis" msgstr "Asse X" #: apprise/plugins/dbus.py:222 apprise/plugins/glib.py:182 msgid "Y-Axis" msgstr "Asse Y" #: apprise/plugins/dingtalk.py:100 apprise/plugins/signl4.py:76 msgid "Secret" msgstr "Segreto" #: apprise/plugins/discord.py:139 apprise/plugins/flock.py:106 #: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106 #: apprise/plugins/slack.py:216 apprise/plugins/viber.py:99 #: apprise/plugins/zulip.py:124 msgid "Bot Name" msgstr "Nome del bot" #: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:201 msgid "Text To Speech" msgstr "Sintesi vocale (Text To Speech)" #: apprise/plugins/discord.py:168 apprise/plugins/fluxer.py:206 msgid "Avatar Image" msgstr "Immagine dell'avatar" #: apprise/plugins/discord.py:173 apprise/plugins/fluxer.py:211 #: apprise/plugins/ntfy.py:263 msgid "Avatar URL" msgstr "URL dell'avatar" #: apprise/plugins/discord.py:177 apprise/plugins/fluxer.py:215 #: apprise/plugins/pushover.py:275 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:222 msgid "Thread ID" msgstr "ID discussione (Thread ID)" #: apprise/plugins/discord.py:190 apprise/plugins/fluxer.py:230 msgid "Display Footer" msgstr "Mostra piΓ¨ di pagina" #: apprise/plugins/discord.py:195 apprise/plugins/fluxer.py:235 msgid "Footer Logo" msgstr "Logo del piΓ¨ di pagina" #: apprise/plugins/discord.py:200 apprise/plugins/fluxer.py:240 msgid "Use Fields" msgstr "Usa campi" #: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:245 msgid "Discord Flags" msgstr "Flag di Discord" #: apprise/plugins/discord.py:219 apprise/plugins/fluxer.py:256 msgid "Ping Users/Roles" msgstr "Menziona (Ping) utenti/ruoli" #: apprise/plugins/discord.py:223 apprise/plugins/slack.py:338 #: apprise/plugins/workflows.py:193 msgid "Template Path" msgstr "Percorso del modello (Template Path)" #: apprise/plugins/discord.py:231 msgid "Batch Attachments" msgstr "Allegati in batch" #: apprise/plugins/discord.py:241 apprise/plugins/mailgun.py:208 #: apprise/plugins/notificationapi.py:256 apprise/plugins/slack.py:348 #: apprise/plugins/sparkpost.py:223 apprise/plugins/workflows.py:214 msgid "Template Tokens" msgstr "Token del modello (Template Token)" #: apprise/plugins/dot.py:142 msgid "Device Serial Number" msgstr "Numero di serie del dispositivo" #: apprise/plugins/dot.py:155 msgid "API Mode" msgstr "ModalitΓ  API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Aggiorna ora" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Firma di testo" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Icona Base64 (Text API)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Immagine Base64 (Image API)" #: apprise/plugins/dot.py:180 msgid "Link" msgstr "Link" #: apprise/plugins/dot.py:184 msgid "Border" msgstr "Bordo" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Tipo di dither" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Kernel di dither" #: apprise/plugins/dot.py:203 msgid "Task Key" msgstr "Chiave dell'attivitΓ  (Task Key)" #: apprise/plugins/emby.py:112 msgid "Modal" msgstr "Finestra modale" #: apprise/plugins/enigma2.py:130 apprise/plugins/gotify.py:134 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:145 msgid "Path" msgstr "Percorso (Path)" #: apprise/plugins/enigma2.py:140 msgid "Server Timeout" msgstr "Timeout del server" #: apprise/plugins/evolution.py:130 msgid "Instance Name" msgstr "Nome dell'istanza" #: apprise/plugins/exotel.py:160 apprise/plugins/sinch.py:106 #: apprise/plugins/twilio.py:140 msgid "Account SID" msgstr "Account SID" #: apprise/plugins/exotel.py:172 msgid "From Phone No / Sender ID" msgstr "Numero telefonico mittente / ID mittente" #: apprise/plugins/exotel.py:233 apprise/plugins/jira.py:275 #: apprise/plugins/mailgun.py:176 apprise/plugins/notificationapi.py:212 #: apprise/plugins/opsgenie.py:273 apprise/plugins/pagerduty.py:176 #: apprise/plugins/sparkpost.py:191 msgid "Region Name" msgstr "Nome della regione" #: apprise/plugins/feishu.py:49 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:99 apprise/plugins/twitter.py:175 msgid "Access Key" msgstr "Chiave di accesso (Access Key)" #: apprise/plugins/flock.py:111 msgid "To User ID" msgstr "ID utente destinatario" #: apprise/plugins/flock.py:118 msgid "To Channel ID" msgstr "ID canale destinatario" #: apprise/plugins/flowtriq.py:129 apprise/plugins/matrix/base.py:340 msgid "Webhook Path" msgstr "Percorso del Webhook" #: apprise/plugins/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188 #: apprise/plugins/fluxer.py:195 apprise/plugins/kook.py:196 #: apprise/plugins/lametric.py:510 apprise/plugins/mattermost.py:224 #: apprise/plugins/notificationapi.py:218 apprise/plugins/notifyre.py:157 #: apprise/plugins/ntfy.py:297 apprise/plugins/office365.py:319 #: apprise/plugins/sns.py:202 apprise/plugins/vapid/__init__.py:169 #: apprise/plugins/webexteams.py:197 apprise/plugins/zoom.py:158 msgid "Mode" msgstr "ModalitΓ " #: apprise/plugins/fluxer.py:226 msgid "Thread Name" msgstr "Nome della discussione (Thread)" #: apprise/plugins/fortysixelks.py:58 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:89 msgid "API Username" msgstr "Nome utente API" #: apprise/plugins/fortysixelks.py:94 msgid "API Password" msgstr "Password API" #: apprise/plugins/freemobile.py:48 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/glib.py:123 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "La libreria libdbus-1.so.x o libdbus-2.so.x deve essere installata." #: apprise/plugins/glib.py:127 msgid "GLib Notification" msgstr "Notifica GLib" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "È richiesto un ambiente locale Gnome." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Notifica Gnome" #: apprise/plugins/google_chat.py:115 msgid "Workspace" msgstr "Area di lavoro (Workspace)" #: apprise/plugins/google_chat.py:121 msgid "Webhook Key" msgstr "Chiave del Webhook" #: apprise/plugins/google_chat.py:133 msgid "Thread Key" msgstr "Chiave della discussione (Thread Key)" #: apprise/plugins/groupme.py:123 msgid "Bot ID" msgstr "ID del bot" #: apprise/plugins/groupme.py:130 apprise/plugins/line.py:82 #: apprise/plugins/mastodon.py:185 apprise/plugins/matrix/base.py:282 #: apprise/plugins/misskey.py:122 apprise/plugins/pushbullet.py:83 #: apprise/plugins/pushover.py:234 apprise/plugins/smseagle.py:146 #: apprise/plugins/spugpush.py:68 apprise/plugins/streamlabs.py:105 #: apprise/plugins/whatsapp.py:121 msgid "Access Token" msgstr "Token di accesso" #: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:195 #: apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versione" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Permanente (Sticky)" #: apprise/plugins/home_assistant.py:142 msgid "Long-Lived Access Token" msgstr "Token di accesso a lungo termine" #: apprise/plugins/home_assistant.py:165 msgid "Notification ID" msgstr "ID notifica" #: apprise/plugins/home_assistant.py:182 msgid "Path Prefix" msgstr "Prefisso del percorso" #: apprise/plugins/humhub.py:127 msgid "Token or Username" msgstr "Token o Nome utente" #: apprise/plugins/ifttt.py:109 msgid "Events" msgstr "Eventi" #: apprise/plugins/ifttt.py:129 msgid "Add Tokens" msgstr "Aggiungi token" #: apprise/plugins/ifttt.py:133 msgid "Remove Tokens" msgstr "Rimuovi token" #: apprise/plugins/jira.py:240 apprise/plugins/opsgenie.py:238 msgid "Target Escalation" msgstr "Escalation di destinazione" #: apprise/plugins/jira.py:246 apprise/plugins/opsgenie.py:244 msgid "Target Schedule" msgstr "Pianificazione di destinazione" #: apprise/plugins/irc/base.py:137 apprise/plugins/jira.py:252 #: apprise/plugins/line.py:88 apprise/plugins/mastodon.py:196 #: apprise/plugins/matrix/base.py:289 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:129 apprise/plugins/opsgenie.py:250 #: apprise/plugins/pushed.py:95 apprise/plugins/rocketchat.py:156 #: apprise/plugins/slack.py:269 apprise/plugins/twitter.py:187 #: apprise/plugins/wechat.py:198 apprise/plugins/xmpp/base.py:123 #: apprise/plugins/zulip.py:143 msgid "Target User" msgstr "Utente di destinazione" #: apprise/plugins/jira.py:258 apprise/plugins/opsgenie.py:256 msgid "Target Team" msgstr "Team di destinazione" #: apprise/plugins/jira.py:264 apprise/plugins/opsgenie.py:262 msgid "Targets " msgstr "Destinatari" #: apprise/plugins/jira.py:293 apprise/plugins/opsgenie.py:286 msgid "Entity" msgstr "EntitΓ " #: apprise/plugins/jira.py:297 apprise/plugins/opsgenie.py:290 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:308 apprise/plugins/opsgenie.py:298 #: apprise/plugins/pagertree.py:118 apprise/plugins/splunk.py:202 msgid "Action" msgstr "Azione" #: apprise/plugins/jira.py:319 apprise/plugins/opsgenie.py:317 msgid "Details" msgstr "Dettagli" #: apprise/plugins/jira.py:323 apprise/plugins/opsgenie.py:321 #: apprise/plugins/splunk.py:213 msgid "Action Mapping" msgstr "Mappatura delle azioni" #: apprise/plugins/join.py:147 msgid "Device ID" msgstr "ID dispositivo" #: apprise/plugins/join.py:153 msgid "Device Name" msgstr "Nome dispositivo" #: apprise/plugins/kavenegar.py:122 apprise/plugins/messagebird.py:85 #: apprise/plugins/notifyre.py:163 apprise/plugins/plivo.py:100 msgid "Source Phone No" msgstr "Numero di telefono mittente" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:141 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Durata" #: apprise/plugins/kumulos.py:99 msgid "Server Key" msgstr "Chiave del server" #: apprise/plugins/lametric.py:436 msgid "Device API Key" msgstr "Chiave API del dispositivo" #: apprise/plugins/lametric.py:442 apprise/plugins/one_signal.py:102 #: apprise/plugins/parseplatform.py:101 msgid "App ID" msgstr "ID dell'app" #: apprise/plugins/lametric.py:448 msgid "App Version" msgstr "Versione dell'app" #: apprise/plugins/lametric.py:455 msgid "App Access Token" msgstr "Token di accesso dell'app" #: apprise/plugins/lametric.py:500 msgid "Custom Icon" msgstr "Icona personalizzata" #: apprise/plugins/lametric.py:504 msgid "Icon Type" msgstr "Tipo di icona" #: apprise/plugins/lametric.py:521 msgid "Cycles" msgstr "Cicli" #: apprise/plugins/lark.py:47 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:67 apprise/plugins/revolt.py:98 #: apprise/plugins/telegram.py:350 msgid "Bot Token" msgstr "Token del bot" #: apprise/plugins/macosx.py:65 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" "Funziona solo con Mac OS X 10.8 e versioni successive. Richiede inoltre che /usr/" "local/bin/terminal-notifier sia accessibile localmente." #: apprise/plugins/macosx.py:72 msgid "MacOSX Notification" msgstr "Notifica MacOSX" #: apprise/plugins/macosx.py:128 msgid "Open/Click URL" msgstr "Apri/Fai clic su URL" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:165 #: apprise/plugins/notificationapi.py:223 apprise/plugins/office365.py:315 #: apprise/plugins/resend.py:150 msgid "Reply To" msgstr "Rispondi a" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:141 #: apprise/plugins/sendpulse.py:115 apprise/plugins/smtp2go.py:113 #: apprise/plugins/sparkpost.py:164 msgid "Domain" msgstr "Dominio" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:168 #: apprise/plugins/postmark.py:144 apprise/plugins/resend.py:140 #: apprise/plugins/ses.py:210 apprise/plugins/smtp2go.py:135 #: apprise/plugins/sparkpost.py:186 msgid "From Name" msgstr "Nome del mittente" #: apprise/plugins/email/base.py:262 apprise/plugins/mailgun.py:204 #: apprise/plugins/smtp2go.py:161 apprise/plugins/sparkpost.py:219 msgid "Email Header" msgstr "Intestazione dell'email" #: apprise/plugins/mastodon.py:216 apprise/plugins/misskey.py:143 msgid "Visibility" msgstr "VisibilitΓ " #: apprise/plugins/mastodon.py:222 msgid "Spoiler Text" msgstr "Testo dello spoiler" #: apprise/plugins/mastodon.py:230 msgid "Language Code" msgstr "Codice lingua" #: apprise/plugins/mastodon.py:234 apprise/plugins/twitter.py:210 msgid "Cache Results" msgstr "Risultati della cache" #: apprise/plugins/mastodon.py:239 msgid "Sensitive Attachments" msgstr "Allegati sensibili" #: apprise/plugins/mastodon.py:255 msgid "Ping Users/Tags" msgstr "Menziona (Ping) utenti/tag" #: apprise/plugins/irc/base.py:128 apprise/plugins/mattermost.py:151 #: apprise/plugins/xmpp/base.py:112 msgid "User" msgstr "Utente" #: apprise/plugins/irc/base.py:143 apprise/plugins/mattermost.py:177 #: apprise/plugins/notifiarr.py:97 apprise/plugins/pushbullet.py:94 #: apprise/plugins/pushed.py:101 apprise/plugins/rocketchat.py:150 #: apprise/plugins/slack.py:275 apprise/plugins/twist.py:112 #: apprise/plugins/xmpp/base.py:128 msgid "Target Channel" msgstr "Canale di destinazione" #: apprise/plugins/mattermost.py:183 apprise/plugins/twist.py:118 msgid "Target Channel ID" msgstr "ID del canale di destinazione" #: apprise/plugins/mqtt.py:171 msgid "Target Queue" msgstr "Coda di destinazione" #: apprise/plugins/mqtt.py:188 msgid "QOS" msgstr "QOS" #: apprise/plugins/mqtt.py:201 apprise/plugins/notificationapi.py:166 #: apprise/plugins/office365.py:269 apprise/plugins/ringcentral.py:192 #: apprise/plugins/sendpulse.py:120 msgid "Client ID" msgstr "ID client" #: apprise/plugins/mqtt.py:205 msgid "Use Session" msgstr "Usa sessione" #: apprise/plugins/mqtt.py:210 msgid "Retain Messages" msgstr "Conserva messaggi (Retain)" #: apprise/plugins/msg91.py:102 apprise/plugins/sendpulse.py:156 msgid "Template ID" msgstr "ID del modello" #: apprise/plugins/msg91.py:109 msgid "Authentication Key" msgstr "Chiave di autenticazione" #: apprise/plugins/msg91.py:138 msgid "Short URL" msgstr "URL breve" #: apprise/plugins/msg91.py:148 apprise/plugins/whatsapp.py:203 msgid "Template Mapping" msgstr "Mappatura del modello" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:113 msgid "URL Prefix" msgstr "Prefisso URL" #: apprise/plugins/nextcloudtalk.py:43 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:96 msgid "Room ID" msgstr "ID stanza" #: apprise/plugins/notifiarr.py:121 msgid "Discord Event ID" msgstr "ID evento Discord" #: apprise/plugins/notifiarr.py:131 apprise/plugins/pagerduty.py:145 msgid "Source" msgstr "Origine" #: apprise/plugins/matrix/base.py:352 apprise/plugins/notificationapi.py:159 msgid "Message Type" msgstr "Tipo di messaggio" #: apprise/plugins/notificationapi.py:171 apprise/plugins/office365.py:276 #: apprise/plugins/ringcentral.py:199 apprise/plugins/sendpulse.py:127 msgid "Client Secret" msgstr "Segreto client" #: apprise/plugins/notificationapi.py:182 msgid "Target ID" msgstr "ID di destinazione" #: apprise/plugins/notificationapi.py:187 msgid "Target SMS" msgstr "SMS di destinazione" #: apprise/plugins/notificationapi.py:207 msgid "Channels" msgstr "Canali" #: apprise/plugins/email/base.py:178 apprise/plugins/notificationapi.py:228 #: apprise/plugins/sendpulse.py:150 apprise/plugins/ses.py:163 msgid "From Email" msgstr "Email del mittente" #: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:187 msgid "Project ID" msgstr "ID progetto" #: apprise/plugins/notifico.py:196 msgid "Message Hook" msgstr "Hook del messaggio" #: apprise/plugins/notifico.py:211 msgid "IRC Colors" msgstr "Colori IRC" #: apprise/plugins/notifico.py:217 msgid "Prefix" msgstr "Prefisso" #: apprise/plugins/notifyre.py:168 msgid "Campaign Name" msgstr "Nome della campagna" #: apprise/plugins/notifyre.py:172 apprise/plugins/whatsapp.py:128 msgid "Template Name" msgstr "Nome del modello" #: apprise/plugins/notifyre.py:176 msgid "Client Reference" msgstr "Riferimento client" #: apprise/plugins/notifyre.py:180 msgid "High Quality" msgstr "Alta qualitΓ " #: apprise/plugins/notifyre.py:185 msgid "Fax Header" msgstr "Intestazione fax" #: apprise/plugins/ntfy.py:235 msgid "Topic" msgstr "Argomento (Topic)" #: apprise/plugins/ntfy.py:253 msgid "Attach" msgstr "Allega" #: apprise/plugins/ntfy.py:267 msgid "Attach Filename" msgstr "Nome del file allegato" #: apprise/plugins/ntfy.py:275 msgid "Delay" msgstr "Ritardo" #: apprise/plugins/ntfy.py:279 apprise/plugins/twist.py:107 msgid "Email" msgstr "Email" #: apprise/plugins/ntfy.py:293 msgid "Actions" msgstr "Azioni" #: apprise/plugins/ntfy.py:306 msgid "Authentication Type" msgstr "Tipo di autenticazione" #: apprise/plugins/octopush.py:130 msgid "API Login" msgstr "Login API" #: apprise/plugins/octopush.py:142 msgid "Sender" msgstr "Mittente" #: apprise/plugins/octopush.py:178 msgid "Accept Replies" msgstr "Accetta risposte" #: apprise/plugins/octopush.py:183 msgid "Purpose" msgstr "Scopo" #: apprise/plugins/octopush.py:189 msgid "Type" msgstr "Tipo" #: apprise/plugins/office365.py:257 msgid "Tenant Domain" msgstr "Dominio Tenant" #: apprise/plugins/office365.py:264 msgid "Account Email or Object ID" msgstr "Email dell'account o Object ID" #: apprise/plugins/office365.py:325 msgid "Save to Sent Items" msgstr "Salva in Posta inviata" #: apprise/plugins/one_signal.py:108 apprise/plugins/sendgrid.py:146 msgid "Template" msgstr "Modello (Template)" #: apprise/plugins/one_signal.py:119 msgid "Target Player ID" msgstr "Player ID di destinazione" #: apprise/plugins/one_signal.py:135 msgid "Include Segment" msgstr "Includi segmento" #: apprise/plugins/one_signal.py:155 msgid "Subtitle" msgstr "Sottotitolo" #: apprise/plugins/one_signal.py:159 apprise/plugins/sfr.py:125 #: apprise/plugins/whatsapp.py:141 msgid "Language" msgstr "Lingua" #: apprise/plugins/one_signal.py:170 msgid "Enable Contents" msgstr "Abilita contenuti" #: apprise/plugins/one_signal.py:176 msgid "Decode Template Args" msgstr "Decodifica argomenti del modello" #: apprise/plugins/one_signal.py:195 msgid "Custom Data" msgstr "Dati personalizzati" #: apprise/plugins/one_signal.py:199 msgid "Postback Data" msgstr "Dati di postback" #: apprise/plugins/pagerduty.py:138 apprise/plugins/pushward.py:134 #: apprise/plugins/spike.py:68 msgid "Integration Key" msgstr "Chiave di integrazione (Integration Key)" #: apprise/plugins/pagerduty.py:151 msgid "Component" msgstr "Componente" #: apprise/plugins/pagerduty.py:167 msgid "Class" msgstr "Classe" #: apprise/plugins/pagerduty.py:185 msgid "Severity" msgstr "GravitΓ  (Severity)" #: apprise/plugins/pagerduty.py:202 msgid "Custom Details" msgstr "Dettagli personalizzati" #: apprise/plugins/pagertree.py:105 msgid "Integration ID" msgstr "ID integrazione" #: apprise/plugins/pagertree.py:124 msgid "Third Party ID" msgstr "ID di terze parti" #: apprise/plugins/pagertree.py:150 msgid "Meta Extras" msgstr "Elementi meta extra" #: apprise/plugins/parseplatform.py:107 msgid "Master Key" msgstr "Master Key" #: apprise/plugins/parseplatform.py:120 msgid "Device" msgstr "Dispositivo" #: apprise/plugins/plivo.py:88 msgid "Auth ID" msgstr "Auth ID" #: apprise/plugins/plivo.py:94 apprise/plugins/sinch.py:113 #: apprise/plugins/twilio.py:147 msgid "Auth Token" msgstr "Auth Token" #: apprise/plugins/prowl.py:128 msgid "Provider Key" msgstr "Provider Key" #: apprise/plugins/pushdeer.py:85 msgid "Pushkey" msgstr "Pushkey" #: apprise/plugins/pushed.py:83 msgid "Application Key" msgstr "Application Key" #: apprise/plugins/pushed.py:89 apprise/plugins/reddit.py:158 msgid "Application Secret" msgstr "Application Secret" #: apprise/plugins/pushjet.py:82 msgid Secret Key msgstr "Secret Key" #: apprise/plugins/pushme.py:81 apprise/plugins/signal_api.py:152 #: apprise/plugins/smseagle.py:193 msgid "Show Status" msgstr "Mostra stato" #: apprise/plugins/pushover.py:228 msgid "User Key" msgstr "User Key" #: apprise/plugins/pushover.py:280 msgid "URL Title" msgstr "Titolo dell'URL" #: apprise/plugins/pushover.py:285 msgid "Emergency Retry Interval" msgstr "Intervallo tentativi di emergenza" #: apprise/plugins/pushover.py:291 msgid "Expire" msgstr "Scadenza" #: apprise/plugins/pushover.py:301 msgid "Encryption Key" msgstr "Chiave di crittografia" #: apprise/plugins/pushover.py:308 msgid "E2EE" msgstr "E2EE" #: apprise/plugins/pushplus.py:172 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:210 apprise/plugins/qq.py:66 msgid "User Token" msgstr "Token utente" #: apprise/plugins/pushplus.py:219 msgid "Group Topics" msgstr "Argomenti del gruppo" #: apprise/plugins/pushplus.py:240 msgid "Channel" msgstr "Canale" #: apprise/plugins/pushplus.py:257 msgid "Webhook Name" msgstr "Nome del Webhook" #: apprise/plugins/pushsafer.py:360 msgid "Private Key" msgstr "Private Key" #: apprise/plugins/pushsafer.py:397 msgid "Vibration" msgstr "Vibrazione" #: apprise/plugins/pushward.py:158 msgid "Info Level" msgstr "Livello info" #: apprise/plugins/pushward.py:164 msgid "Success Level" msgstr "Livello successo" #: apprise/plugins/pushward.py:170 msgid "Warning Level" msgstr "Livello avviso" #: apprise/plugins/pushward.py:176 msgid "Failure Level" msgstr "Livello errore" #: apprise/plugins/pushy.py:79 msgid "Secret API Key" msgstr "Secret API Key" #: apprise/plugins/fcm/__init__.py:162 apprise/plugins/pushy.py:91 #: apprise/plugins/sns.py:163 apprise/plugins/wxpusher.py:128 msgid "Target Topic" msgstr "Argomento di destinazione (Topic)" #: apprise/plugins/qq.py:46 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:151 msgid "Application ID" msgstr "ID applicazione" #: apprise/plugins/reddit.py:165 msgid "Target Subreddit" msgstr "Subreddit di destinazione" #: apprise/plugins/reddit.py:182 msgid "Kind" msgstr "Tipo" #: apprise/plugins/reddit.py:188 msgid "Flair ID" msgstr "ID Flair" #: apprise/plugins/reddit.py:193 msgid "Flair Text" msgstr "Testo Flair" #: apprise/plugins/reddit.py:198 msgid "NSFW" msgstr "NSFW" #: apprise/plugins/reddit.py:204 msgid "Is Ad?" msgstr "È un annuncio?" #: apprise/plugins/reddit.py:210 msgid "Send Replies" msgstr "Invia risposte" #: apprise/plugins/reddit.py:216 msgid "Is Spoiler" msgstr "È uno spoiler" #: apprise/plugins/reddit.py:222 msgid "Resubmit Flag" msgstr "Flag di rinvio" #: apprise/plugins/resend.py:135 msgid "From Address" msgstr "Indirizzo mittente" #: apprise/plugins/revolt.py:104 msgid "Channel ID" msgstr "ID canale" #: apprise/plugins/revolt.py:131 msgid "Embed URL" msgstr "URL incorporato (Embed)" #: apprise/plugins/ringcentral.py:186 msgid "Token / Password" msgstr "Token / Password" #: apprise/plugins/ringcentral.py:239 msgid "Environment" msgstr "Ambiente" #: apprise/plugins/ringcentral.py:252 msgid "Authentication Mode" msgstr "ModalitΓ  di autenticazione" #: apprise/plugins/rocketchat.py:146 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:295 apprise/plugins/rocketchat.py:162 msgid "Target Room ID" msgstr "ID stanza di destinazione" #: apprise/plugins/matrix/base.py:334 apprise/plugins/rocketchat.py:178 #: apprise/plugins/ryver.py:118 msgid "Webhook Mode" msgstr "ModalitΓ  Webhook" #: apprise/plugins/rocketchat.py:183 msgid "Use Avatar" msgstr "Usa avatar" #: apprise/plugins/rsyslog.py:173 apprise/plugins/syslog.py:144 msgid "Facility" msgstr "Facility" #: apprise/plugins/rsyslog.py:203 apprise/plugins/syslog.py:161 msgid "Log PID" msgstr "PID del registro (Log PID)" #: apprise/plugins/ryver.py:93 apprise/plugins/zulip.py:130 msgid "Organization" msgstr "Organizzazione" #: apprise/plugins/sendgrid.py:166 apprise/plugins/sendpulse.py:182 msgid "Template Data" msgstr "Dati del modello" #: apprise/plugins/serwersms.py:142 apprise/plugins/sfr.py:131 msgid "Sender Name" msgstr "Nome del mittente" #: apprise/plugins/serwersms.py:155 apprise/plugins/signal_api.py:131 #: apprise/plugins/smseagle.py:158 apprise/plugins/whatsapp.py:159 msgid "Target Group ID" msgstr "ID gruppo di destinazione" #: apprise/plugins/ses.py:157 apprise/plugins/sns.py:132 msgid "Session Token" msgstr "Session Token" #: apprise/plugins/ses.py:169 apprise/plugins/sns.py:138 msgid "Access Key ID" msgstr "Access Key ID" #: apprise/plugins/ses.py:175 apprise/plugins/sns.py:144 msgid "Secret Access Key" msgstr "Secret Access Key" #: apprise/plugins/ses.py:181 apprise/plugins/sinch.py:157 #: apprise/plugins/sns.py:150 msgid "Region" msgstr "Regione" #: apprise/plugins/ses.py:188 apprise/plugins/smtp2go.py:124 #: apprise/plugins/sparkpost.py:175 msgid "Target Emails" msgstr "Email di destinazione" #: apprise/plugins/seven.py:113 apprise/plugins/smseagle.py:203 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:117 msgid "Label" msgstr "Etichetta (Label)" #: apprise/plugins/sfr.py:58 msgid "SociΓ©tΓ© FranΓ§aise du RadiotΓ©lΓ©phone" msgstr "SociΓ©tΓ© FranΓ§aise du RadiotΓ©lΓ©phone" #: apprise/plugins/sfr.py:90 msgid "Service ID" msgstr "ID servizio" #: apprise/plugins/sfr.py:95 msgid "Service Password" msgstr "Password del servizio" #: apprise/plugins/sfr.py:101 msgid "Space ID" msgstr "ID spazio" #: apprise/plugins/sfr.py:107 msgid "Recipient Phone Number" msgstr "Numero di telefono del destinatario" #: apprise/plugins/sfr.py:138 msgid "Media Type" msgstr "Tipo di contenuto (Media)" #: apprise/plugins/sfr.py:145 msgid "Timeout" msgstr "Timeout" #: apprise/plugins/sfr.py:151 msgid "TTS Voice" msgstr "Voce TTS" #: apprise/plugins/signl4.py:89 msgid "Service" msgstr "Servizio" #: apprise/plugins/signl4.py:93 msgid "Location" msgstr "Posizione" #: apprise/plugins/signl4.py:97 msgid "Alerting Scenario" msgstr "Scenario di avviso" #: apprise/plugins/signl4.py:101 msgid "Filtering" msgstr "Filtraggio" #: apprise/plugins/signl4.py:106 msgid "External ID" msgstr "ID esterno" #: apprise/plugins/signl4.py:110 msgid "Status" msgstr "Stato" #: apprise/plugins/simplepush.py:113 msgid "Salt" msgstr "Salt" #: apprise/plugins/simplepush.py:126 msgid "Event" msgstr "Evento" #: apprise/plugins/sinch.py:134 apprise/plugins/twilio.py:168 msgid "Target Short Code" msgstr "Short Code di destinazione" #: apprise/plugins/slack.py:224 msgid "OAuth Access Token" msgstr "Token di accesso OAuth" #: apprise/plugins/slack.py:233 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:242 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:251 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:258 msgid "Target Encoded ID" msgstr "ID codificato di destinazione" #: apprise/plugins/slack.py:287 msgid "Workflow Path" msgstr "Percorso del workflow" #: apprise/plugins/slack.py:305 msgid "Include Footer" msgstr "Includi piΓ¨ di pagina" #: apprise/plugins/slack.py:313 msgid "Use Blocks" msgstr "Usa blocchi" #: apprise/plugins/slack.py:319 msgid "Include Timestamp" msgstr "Includi timestamp" #: apprise/plugins/slack.py:325 apprise/plugins/twitter.py:204 msgid "Message Mode" msgstr "ModalitΓ  messaggio" #: apprise/plugins/smpp.py:61 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:103 msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:138 msgid "Login" msgstr "Login" #: apprise/plugins/smsc.py:175 msgid "Transliterate" msgstr "Traslittera" #: apprise/plugins/smseagle.py:165 msgid "Target Contact" msgstr "Contatto di destinazione" #: apprise/plugins/smseagle.py:198 msgid "Test Only" msgstr "Solo test" #: apprise/plugins/smsmanager.py:143 msgid "Gateway" msgstr "Gateway" #: apprise/plugins/sogs.py:219 msgid "Public Key" msgstr "Public Key" #: apprise/plugins/sogs.py:226 msgid "Seed" msgstr "Seed" #: apprise/plugins/sogs.py:233 msgid "Rooms" msgstr "Stanze" #: apprise/plugins/spike.py:48 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:117 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:172 msgid "Target Routing Key" msgstr "Routing Key di destinazione" #: apprise/plugins/splunk.py:179 msgid "Entity ID" msgstr "ID entitΓ " #: apprise/plugins/spugpush.py:48 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:125 msgid "Alert Type" msgstr "Tipo di avviso" #: apprise/plugins/streamlabs.py:131 msgid "Image Link" msgstr "Collegamento immagine" #: apprise/plugins/streamlabs.py:136 msgid "Sound Link" msgstr "Collegamento audio" #: apprise/plugins/streamlabs.py:147 msgid "Special Text Color" msgstr "Colore testo speciale" #: apprise/plugins/streamlabs.py:153 msgid "Amount" msgstr "Importo" #: apprise/plugins/streamlabs.py:159 msgid "Currency" msgstr "Valuta" #: apprise/plugins/streamlabs.py:165 msgid "Name" msgstr "Nome" #: apprise/plugins/streamlabs.py:171 msgid "Identifier" msgstr "Identificatore" #: apprise/plugins/synology.py:116 msgid "Upload" msgstr "Caricamento (Upload)" #: apprise/plugins/syslog.py:167 msgid "Log to STDERR" msgstr "Registra su STDERR" #: apprise/plugins/telegram.py:359 msgid "Target Chat ID" msgstr "ID chat di destinazione" #: apprise/plugins/telegram.py:382 msgid "Detect Bot Owner" msgstr "Rileva proprietario del bot" #: apprise/plugins/telegram.py:388 msgid "Silent Notification" msgstr "Notifica silenziosa" #: apprise/plugins/telegram.py:393 msgid "Web Page Preview" msgstr "Anteprima pagina web" #: apprise/plugins/telegram.py:398 msgid "Topic Thread ID" msgstr "ID thread dell'argomento (Topic)" #: apprise/plugins/telegram.py:405 msgid "Markdown Version" msgstr "Versione Markdown" #: apprise/plugins/telegram.py:414 msgid "Content Placement" msgstr "Posizionamento del contenuto" #: apprise/plugins/threema.py:85 msgid "Gateway ID" msgstr "ID Gateway" #: apprise/plugins/threema.py:110 msgid "Target Threema ID" msgstr "ID Threema di destinazione" #: apprise/plugins/twilio.py:200 msgid "Notification Method: sms or call" msgstr "Metodo di notifica: sms o chiamata" #: apprise/plugins/twitter.py:163 msgid "Consumer Key" msgstr "Consumer Key" #: apprise/plugins/twitter.py:169 msgid "Consumer Secret" msgstr "Consumer Secret" #: apprise/plugins/twitter.py:181 msgid "Access Secret" msgstr "Access Secret" #: apprise/plugins/viber.py:49 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:81 msgid "Authentication Token" msgstr "Token di autenticazione" #: apprise/plugins/viber.py:87 msgid "Receiver IDs" msgstr "ID dei destinatari" #: apprise/plugins/viber.py:105 msgid "Bot Avatar URL" msgstr "URL dell'avatar del bot" #: apprise/plugins/voipms.py:83 msgid "User Email" msgstr "Email utente" #: apprise/plugins/vapid/__init__.py:179 apprise/plugins/vonage.py:133 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:178 msgid "Bot Access Token" msgstr "Token di accesso del bot" #: apprise/plugins/webexteams.py:184 msgid "Room IDs" msgstr "ID stanze" #: apprise/plugins/wechat.py:174 msgid "Corp ID" msgstr "Corp ID" #: apprise/plugins/wechat.py:181 msgid "App Secret" msgstr "App Secret" #: apprise/plugins/wechat.py:189 msgid "Agent ID" msgstr "Agent ID" #: apprise/plugins/wechat.py:205 msgid "Target Department" msgstr "Dipartimento di destinazione" #: apprise/plugins/wechat.py:212 msgid "Target Tag" msgstr "Tag di destinazione" #: apprise/plugins/wecombot.py:99 msgid "Bot Webhook Key" msgstr "Chiave Webhook del bot" #: apprise/plugins/whatsapp.py:134 msgid "From Phone ID" msgstr "ID telefono mittente" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "È richiesto un ambiente locale Microsoft Windows." #: apprise/plugins/workflows.py:150 msgid "Workflow ID" msgstr "ID workflow" #: apprise/plugins/workflows.py:158 msgid "Signature" msgstr "Firma" #: apprise/plugins/workflows.py:181 msgid "Use Power Automate URL" msgstr "Usa URL di Power Automate" #: apprise/plugins/workflows.py:188 msgid "Wrap Text" msgstr "A capo automatico (Wrap Text)" #: apprise/plugins/workflows.py:203 msgid "API Version" msgstr "Versione API" #: apprise/plugins/wxpusher.py:121 msgid "App Token" msgstr "Token dell'app" #: apprise/plugins/wxpusher.py:133 msgid "Target User ID" msgstr "ID utente di destinazione" #: apprise/plugins/zoom.py:145 msgid "Verification Token" msgstr "Token di verifica" #: apprise/plugins/zulip.py:148 msgid "Target Stream" msgstr "Stream di destinazione" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Server SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:149 msgid "Secure Mode" msgstr "ModalitΓ  sicura" #: apprise/plugins/email/base.py:205 msgid "PGP Security Mode" msgstr "ModalitΓ  di sicurezza PGP" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Percorso della chiave pubblica PGP" #: apprise/plugins/email/base.py:225 msgid "PGP Private Key Path" msgstr "Percorso della chiave privata PGP" #: apprise/plugins/email/base.py:233 msgid "Web Key Directory" msgstr "Web Key Directory" #: apprise/plugins/email/base.py:239 msgid "Inline Attachments" msgstr "Allegati incorporati (Inline)" #: apprise/plugins/email/base.py:244 msgid "To Email" msgstr "Email del destinatario" #: apprise/plugins/fcm/__init__.py:148 msgid "OAuth2 KeyFile" msgstr "KeyFile OAuth2" #: apprise/plugins/fcm/__init__.py:193 msgid "Custom Image URL" msgstr "URL dell'immagine personalizzata" #: apprise/plugins/fcm/__init__.py:205 msgid "Notification Color" msgstr "Colore della notifica" #: apprise/plugins/fcm/__init__.py:215 msgid "Data Entries" msgstr "Voci di dati (Data Entries)" #: apprise/plugins/irc/base.py:159 msgid "Real Name" msgstr "Nome reale" #: apprise/plugins/irc/base.py:160 msgid "Nickname" msgstr "Nickname" #: apprise/plugins/irc/base.py:162 msgid "Join Channels" msgstr "Entra nei canali" #: apprise/plugins/irc/base.py:167 msgid "Auth Mode" msgstr "ModalitΓ  di autenticazione" #: apprise/plugins/matrix/base.py:301 msgid "Target Room Alias" msgstr "Alias della stanza di destinazione" #: apprise/plugins/matrix/base.py:324 msgid "Server Discovery" msgstr "Rilevamento del server" #: apprise/plugins/matrix/base.py:329 msgid "Force Home Server on Room IDs" msgstr "Forza Home Server sugli ID delle stanze" #: apprise/plugins/matrix/base.py:346 msgid "Matrix API Verion" msgstr "Versione API Matrix" #: apprise/plugins/matrix/base.py:358 msgid "End-to-End Encryption" msgstr "Crittografia end-to-end" #: apprise/plugins/vapid/__init__.py:193 msgid "PEM Private KeyFile" msgstr "KeyFile privato PEM" #: apprise/plugins/vapid/__init__.py:199 msgid "Subscripion File" msgstr "File di iscrizione" #: apprise/plugins/xmpp/base.py:144 msgid "XMPP Server" msgstr "Server XMPP" #: apprise/plugins/xmpp/base.py:156 msgid "Get Roster" msgstr "Ottieni elenco contatti (Roster)" #: apprise/plugins/xmpp/base.py:161 msgid "Use Subject" msgstr "Usa oggetto" #: apprise/plugins/xmpp/base.py:166 msgid "Keep Connection Alive" msgstr "Mantieni attiva la connessione" #: apprise/plugins/xmpp/base.py:172 msgid "MUC Nickname" msgstr "Nickname MUC" #: apprise/plugins/xmpp/base.py:176 msgid "SCRAM-PLUS Channel Binding" msgstr "Channel Binding SCRAM-PLUS" caronc-apprise-182f859/apprise/i18n/pt_BR/000077500000000000000000000000001524161175200201445ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/pt_BR/LC_MESSAGES/000077500000000000000000000000001524161175200217315ustar00rootroot00000000000000caronc-apprise-182f859/apprise/i18n/pt_BR/LC_MESSAGES/apprise.po000066400000000000000000001672661524161175200237560ustar00rootroot00000000000000# Portuguese (Brazilian) translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 1.11.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-08-20 09:55-0400\n" "PO-Revision-Date: 2019-05-24 20:00-0400\n" "Last-Translator: Chris Caron \n" "Language: pt_BR\n" "Language-Team: pt_BR \n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:96 apprise/url.py:158 msgid "Verify SSL" msgstr "Verificar SSL" #: apprise/url.py:168 msgid "Follow Redirects" msgstr "" #: apprise/url.py:178 #, fuzzy msgid "Socket Read Timeout" msgstr "Tempo Limite de Leitura do Socket" #: apprise/url.py:192 #, fuzzy msgid "Socket Connect Timeout" msgstr "Tempo Limite de Conexao do Socket" #: apprise/attachment/base.py:82 msgid "Cache Age" msgstr "Idade do Cache" #: apprise/attachment/base.py:88 msgid "Forced Mime Type" msgstr "Tipo MIME Forcado" #: apprise/attachment/base.py:92 msgid "Forced File Name" msgstr "Nome de Arquivo Forcado" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "Arquivo Local" #: apprise/attachment/http.py:47 apprise/config/http.py:55 msgid "Web Based" msgstr "Baseado na Web" #: apprise/attachment/memory.py:44 apprise/config/memory.py:37 msgid "Memory" msgstr "Memoria" #: apprise/plugins/__init__.py:278 msgid "Schema" msgstr "Esquema" #: apprise/plugins/__init__.py:398 msgid "No dependencies." msgstr "Sem dependencias." #: apprise/plugins/__init__.py:401 msgid "Packages are required to function." msgstr "Pacotes sao necessarios para funcionar." #: apprise/plugins/__init__.py:405 msgid "Packages are recommended to improve functionality." msgstr "Pacotes sao recomendados para melhorar a funcionalidade." #: apprise/plugins/africas_talking.py:132 #, fuzzy msgid "App User Name" msgstr "Nome de Usuario do Aplicativo" #: apprise/plugins/africas_talking.py:138 apprise/plugins/brevo.py:157 #: apprise/plugins/burstsms.py:104 apprise/plugins/clicksend.py:98 #: apprise/plugins/dot.py:136 apprise/plugins/evolution.py:113 #: apprise/plugins/exotel.py:213 apprise/plugins/fcm/__init__.py:143 #: apprise/plugins/flowtriq.py:112 apprise/plugins/httpsms.py:77 #: apprise/plugins/jira.py:230 apprise/plugins/join.py:140 #: apprise/plugins/kavenegar.py:115 apprise/plugins/kumulos.py:87 #: apprise/plugins/mailersend.py:126 apprise/plugins/mailgun.py:146 #: apprise/plugins/messagebird.py:78 apprise/plugins/notifyre.py:130 #: apprise/plugins/octopush.py:136 apprise/plugins/one_signal.py:113 #: apprise/plugins/opsgenie.py:228 apprise/plugins/pagerduty.py:131 #: apprise/plugins/pinglet.py:137 apprise/plugins/pingram.py:175 #: apprise/plugins/postmark.py:117 apprise/plugins/prowl.py:121 #: apprise/plugins/resend.py:107 apprise/plugins/resend.py:145 #: apprise/plugins/sendgrid.py:116 apprise/plugins/seven.py:75 #: apprise/plugins/simplepush.py:101 apprise/plugins/smsmanager.py:106 #: apprise/plugins/smtp2go.py:118 apprise/plugins/sparkpost.py:169 #: apprise/plugins/splunk.py:165 apprise/plugins/techuluspush.py:97 #: apprise/plugins/trigv.py:142 apprise/plugins/twilio.py:194 #: apprise/plugins/vapid/__init__.py:152 apprise/plugins/vonage.py:80 msgid "API Key" msgstr "Chave de API" #: apprise/plugins/africas_talking.py:145 apprise/plugins/fortysixelks.py:106 #, fuzzy msgid "Target Phone" msgstr "Telefone de Destino" #: apprise/plugins/africas_talking.py:150 apprise/plugins/aprs.py:187 #: apprise/plugins/bark.py:178 apprise/plugins/brevo.py:174 #: apprise/plugins/bulksms.py:137 apprise/plugins/bulkvs.py:110 #: apprise/plugins/burstsms.py:131 apprise/plugins/clickatell.py:90 #: apprise/plugins/clicksend.py:112 apprise/plugins/d7networks.py:110 #: apprise/plugins/dapnet.py:138 apprise/plugins/dingtalk.py:111 #: apprise/plugins/eight00com.py:128 apprise/plugins/email/base.py:168 #: apprise/plugins/evolution.py:142 apprise/plugins/exotel.py:190 #: apprise/plugins/fcm/__init__.py:168 apprise/plugins/flock.py:125 #: apprise/plugins/fortysixelks.py:111 apprise/plugins/home_assistant.py:154 #: apprise/plugins/httpsms.py:97 apprise/plugins/humhub.py:149 #: apprise/plugins/irc/base.py:149 apprise/plugins/join.py:172 #: apprise/plugins/kavenegar.py:135 apprise/plugins/kook.py:176 #: apprise/plugins/line.py:93 apprise/plugins/mailersend.py:143 #: apprise/plugins/mailgun.py:157 apprise/plugins/mastodon.py:202 #: apprise/plugins/matrix/base.py:332 apprise/plugins/mattermost.py:194 #: apprise/plugins/messagebird.py:99 apprise/plugins/mqtt.py:177 #: apprise/plugins/msg91.py:123 apprise/plugins/nextcloud.py:148 #: apprise/plugins/nextcloudtalk.py:101 apprise/plugins/notifiarr.py:103 #: apprise/plugins/notifyre.py:143 apprise/plugins/ntfy.py:241 #: apprise/plugins/octopush.py:153 apprise/plugins/office365.py:287 #: apprise/plugins/one_signal.py:141 apprise/plugins/pingram.py:198 #: apprise/plugins/plivo.py:114 apprise/plugins/postmark.py:133 #: apprise/plugins/pushbullet.py:105 apprise/plugins/pushed.py:107 #: apprise/plugins/pushover.py:252 apprise/plugins/pushsafer.py:376 #: apprise/plugins/pushy.py:97 apprise/plugins/reddit.py:170 #: apprise/plugins/resend.py:124 apprise/plugins/revolt.py:112 #: apprise/plugins/ringcentral.py:219 apprise/plugins/rocketchat.py:167 #: apprise/plugins/sendgrid.py:133 apprise/plugins/sendpulse.py:139 #: apprise/plugins/serwersms.py:161 apprise/plugins/seven.py:88 #: apprise/plugins/sfr.py:113 apprise/plugins/signal_api.py:138 #: apprise/plugins/sinch.py:140 apprise/plugins/slack.py:281 #: apprise/plugins/smpp.py:128 apprise/plugins/smsc.py:156 #: apprise/plugins/smseagle.py:172 apprise/plugins/smsmanager.py:119 #: apprise/plugins/sns.py:170 apprise/plugins/telegram.py:372 #: apprise/plugins/threema.py:115 apprise/plugins/trigv.py:155 #: apprise/plugins/twilio.py:174 apprise/plugins/twist.py:123 #: apprise/plugins/twitter.py:193 apprise/plugins/vapid/__init__.py:158 #: apprise/plugins/voipms.py:108 apprise/plugins/vonage.py:108 #: apprise/plugins/wechat.py:218 apprise/plugins/whatsapp.py:166 #: apprise/plugins/wxpusher.py:139 apprise/plugins/xmpp/base.py:134 #: apprise/plugins/zulip.py:153 msgid "Targets" msgstr "Destinatarios" #: apprise/plugins/africas_talking.py:166 #, fuzzy msgid "From" msgstr "De" #: apprise/plugins/africas_talking.py:172 #, fuzzy msgid "SMS Mode" msgstr "Modo SMS" #: apprise/plugins/africas_talking.py:181 apprise/plugins/bulksms.py:170 #: apprise/plugins/bulkvs.py:131 apprise/plugins/burstsms.py:166 #: apprise/plugins/clicksend.py:130 apprise/plugins/d7networks.py:144 #: apprise/plugins/dapnet.py:167 apprise/plugins/exotel.py:222 #: apprise/plugins/home_assistant.py:171 apprise/plugins/jira.py:282 #: apprise/plugins/mailgun.py:194 apprise/plugins/mastodon.py:247 #: apprise/plugins/octopush.py:173 apprise/plugins/one_signal.py:185 #: apprise/plugins/opsgenie.py:307 apprise/plugins/plivo.py:137 #: apprise/plugins/signal_api.py:160 apprise/plugins/smseagle.py:211 #: apprise/plugins/smsmanager.py:152 apprise/plugins/smtp2go.py:151 #: apprise/plugins/sparkpost.py:209 apprise/plugins/twitter.py:218 #, fuzzy msgid "Batch Mode" msgstr "Modo em Lote" #: apprise/plugins/apprise_api.py:102 apprise/plugins/bark.py:152 #: apprise/plugins/custom_form.py:131 apprise/plugins/custom_json.py:113 #: apprise/plugins/custom_xml.py:113 apprise/plugins/emby.py:85 #: apprise/plugins/enigma2.py:111 apprise/plugins/evolution.py:119 #: apprise/plugins/flowtriq.py:118 apprise/plugins/fluxer.py:159 #: apprise/plugins/gotify.py:129 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:123 apprise/plugins/humhub.py:138 #: apprise/plugins/irc/base.py:117 apprise/plugins/kodi.py:96 #: apprise/plugins/lametric.py:460 apprise/plugins/mastodon.py:180 #: apprise/plugins/matrix/base.py:287 apprise/plugins/mattermost.py:160 #: apprise/plugins/misskey.py:117 apprise/plugins/mqtt.py:149 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74 #: apprise/plugins/notica.py:126 apprise/plugins/notifico.py:166 #: apprise/plugins/ntfy.py:211 apprise/plugins/parseplatform.py:90 #: apprise/plugins/pinglet.py:143 apprise/plugins/pushdeer.py:75 #: apprise/plugins/pushjet.py:71 apprise/plugins/rocketchat.py:120 #: apprise/plugins/rsyslog.py:180 apprise/plugins/signal_api.py:97 #: apprise/plugins/smseagle.py:135 apprise/plugins/sogs.py:207 #: apprise/plugins/synology.py:84 apprise/plugins/workflows.py:138 #: apprise/plugins/xmpp/base.py:101 msgid "Hostname" msgstr "Nome do Host" #: apprise/plugins/apprise_api.py:107 apprise/plugins/bark.py:157 #: apprise/plugins/custom_form.py:136 apprise/plugins/custom_json.py:118 #: apprise/plugins/custom_xml.py:118 apprise/plugins/email/base.py:157 #: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:116 #: apprise/plugins/evolution.py:124 apprise/plugins/flowtriq.py:123 #: apprise/plugins/fluxer.py:163 apprise/plugins/gotify.py:140 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:128 #: apprise/plugins/humhub.py:143 apprise/plugins/irc/base.py:122 #: apprise/plugins/kodi.py:101 apprise/plugins/lametric.py:464 #: apprise/plugins/mastodon.py:190 apprise/plugins/matrix/base.py:292 #: apprise/plugins/mattermost.py:176 apprise/plugins/misskey.py:127 #: apprise/plugins/mqtt.py:154 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130 #: apprise/plugins/notifico.py:170 apprise/plugins/ntfy.py:215 #: apprise/plugins/parseplatform.py:95 apprise/plugins/pinglet.py:148 #: apprise/plugins/pushdeer.py:79 apprise/plugins/pushjet.py:76 #: apprise/plugins/rocketchat.py:125 apprise/plugins/rsyslog.py:185 #: apprise/plugins/signal_api.py:102 apprise/plugins/smpp.py:108 #: apprise/plugins/smseagle.py:140 apprise/plugins/sogs.py:212 #: apprise/plugins/synology.py:89 apprise/plugins/trigv.py:163 #: apprise/plugins/workflows.py:143 apprise/plugins/xmpp/base.py:106 msgid "Port" msgstr "Porta" #: apprise/plugins/apprise_api.py:113 apprise/plugins/bark.py:163 #: apprise/plugins/bluesky.py:119 apprise/plugins/custom_form.py:142 #: apprise/plugins/custom_json.py:124 apprise/plugins/custom_xml.py:124 #: apprise/plugins/emby.py:97 apprise/plugins/enigma2.py:122 #: apprise/plugins/freemobile.py:78 apprise/plugins/home_assistant.py:134 #: apprise/plugins/jira.py:236 apprise/plugins/kodi.py:107 #: apprise/plugins/lametric.py:471 apprise/plugins/matrix/base.py:298 #: apprise/plugins/nextcloud.py:127 apprise/plugins/nextcloudtalk.py:85 #: apprise/plugins/notica.py:136 apprise/plugins/notifico.py:176 #: apprise/plugins/ntfy.py:221 apprise/plugins/opsgenie.py:234 #: apprise/plugins/pushjet.py:88 apprise/plugins/rocketchat.py:131 #: apprise/plugins/serwersms.py:131 apprise/plugins/signal_api.py:108 #: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:95 msgid "Username" msgstr "Nome de Usuario" #: apprise/plugins/apprise_api.py:117 apprise/plugins/aprs.py:172 #: apprise/plugins/bark.py:167 apprise/plugins/bluesky.py:124 #: apprise/plugins/bulksms.py:117 apprise/plugins/bulkvs.py:90 #: apprise/plugins/custom_form.py:146 apprise/plugins/custom_json.py:128 #: apprise/plugins/custom_xml.py:128 apprise/plugins/dapnet.py:123 #: apprise/plugins/email/base.py:147 apprise/plugins/emby.py:101 #: apprise/plugins/enigma2.py:126 apprise/plugins/freemobile.py:83 #: apprise/plugins/growl.py:151 apprise/plugins/home_assistant.py:138 #: apprise/plugins/humhub.py:133 apprise/plugins/irc/base.py:132 #: apprise/plugins/kodi.py:111 apprise/plugins/matrix/base.py:302 #: apprise/plugins/mqtt.py:165 apprise/plugins/nextcloud.py:131 #: apprise/plugins/nextcloudtalk.py:90 apprise/plugins/notica.py:140 #: apprise/plugins/notifico.py:180 apprise/plugins/ntfy.py:225 #: apprise/plugins/pushjet.py:92 apprise/plugins/reddit.py:145 #: apprise/plugins/rocketchat.py:135 apprise/plugins/serwersms.py:136 #: apprise/plugins/signal_api.py:112 apprise/plugins/simplepush.py:108 #: apprise/plugins/smpp.py:97 apprise/plugins/smsc.py:143 #: apprise/plugins/synology.py:99 apprise/plugins/twist.py:101 #: apprise/plugins/voipms.py:88 apprise/plugins/xmpp/base.py:117 msgid "Password" msgstr "Senha" #: apprise/plugins/apprise_api.py:122 apprise/plugins/chanify.py:74 #: apprise/plugins/dingtalk.py:93 apprise/plugins/exotel.py:166 #: apprise/plugins/feishu.py:80 apprise/plugins/gotify.py:123 #: apprise/plugins/kook.py:170 apprise/plugins/mattermost.py:166 #: apprise/plugins/notica.py:119 apprise/plugins/notifiarr.py:91 #: apprise/plugins/ntfy.py:230 apprise/plugins/pushme.py:62 #: apprise/plugins/ryver.py:99 apprise/plugins/serverchan.py:70 #: apprise/plugins/slack.py:331 apprise/plugins/synology.py:104 #: apprise/plugins/zulip.py:136 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:136 apprise/plugins/jira.py:301 #: apprise/plugins/ntfy.py:289 apprise/plugins/opsgenie.py:294 #: apprise/plugins/pagertree.py:133 #, fuzzy msgid "Tags" msgstr "Etiquetas" #: apprise/plugins/apprise_api.py:140 msgid "Query Method" msgstr "Metodo de Consulta" #: apprise/plugins/apprise_api.py:154 apprise/plugins/custom_form.py:175 #: apprise/plugins/custom_json.py:151 apprise/plugins/custom_xml.py:151 #: apprise/plugins/enigma2.py:154 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:122 apprise/plugins/notica.py:156 #: apprise/plugins/pagertree.py:142 apprise/plugins/synology.py:129 msgid "HTTP Header" msgstr "Cabecalho HTTP" #: apprise/plugins/aprs.py:167 apprise/plugins/bulksms.py:112 #: apprise/plugins/bulkvs.py:85 apprise/plugins/clicksend.py:93 #: apprise/plugins/dapnet.py:118 apprise/plugins/email/base.py:143 #: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:160 #: apprise/plugins/reddit.py:140 apprise/plugins/sendpulse.py:110 #: apprise/plugins/smtp2go.py:108 apprise/plugins/sparkpost.py:159 msgid "User Name" msgstr "Nome de Usuario" #: apprise/plugins/aprs.py:178 apprise/plugins/aprs.py:199 #: apprise/plugins/dapnet.py:129 apprise/plugins/dapnet.py:162 #, fuzzy msgid "Target Callsign" msgstr "Indicativo de Chamada de Destino" #: apprise/plugins/aprs.py:204 msgid "Resend Delay" msgstr "Atraso de Reenvio" #: apprise/plugins/aprs.py:211 msgid "Locale" msgstr "Localidade" #: apprise/plugins/bark.py:172 apprise/plugins/fcm/__init__.py:157 #: apprise/plugins/home_assistant.py:149 apprise/plugins/pushbullet.py:89 #: apprise/plugins/pushover.py:240 apprise/plugins/pushsafer.py:366 #: apprise/plugins/pushy.py:85 msgid "Target Device" msgstr "Dispositivo de Destino" #: apprise/plugins/bark.py:191 apprise/plugins/lametric.py:516 #: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:269 #: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110 msgid "Sound" msgstr "Som" #: apprise/plugins/bark.py:196 apprise/plugins/pushward.py:152 msgid "Level" msgstr "Nivel" #: apprise/plugins/bark.py:201 apprise/plugins/pushward.py:182 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:207 apprise/plugins/ntfy.py:271 #: apprise/plugins/pagerduty.py:172 msgid "Click" msgstr "Clique" #: apprise/plugins/bark.py:211 apprise/plugins/pushy.py:114 msgid "Badge" msgstr "Emblema" #: apprise/plugins/bark.py:216 msgid "Category" msgstr "Categoria" #: apprise/plugins/bark.py:220 apprise/plugins/join.py:158 #: apprise/plugins/pagerduty.py:163 msgid "Group" msgstr "Grupo" #: apprise/plugins/bark.py:224 apprise/plugins/dbus.py:228 #: apprise/plugins/discord.py:210 apprise/plugins/fcm/__init__.py:197 #: apprise/plugins/flock.py:136 apprise/plugins/fluxer.py:250 #: apprise/plugins/glib.py:188 apprise/plugins/gnome.py:154 #: apprise/plugins/growl.py:175 apprise/plugins/join.py:183 #: apprise/plugins/kodi.py:129 apprise/plugins/line.py:108 #: apprise/plugins/macosx.py:115 apprise/plugins/matrix/base.py:343 #: apprise/plugins/mattermost.py:217 apprise/plugins/notifiarr.py:125 #: apprise/plugins/ntfy.py:257 apprise/plugins/one_signal.py:164 #: apprise/plugins/pagerduty.py:191 apprise/plugins/ryver.py:124 #: apprise/plugins/slack.py:299 apprise/plugins/telegram.py:383 #: apprise/plugins/vapid/__init__.py:204 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:175 msgid "Include Image" msgstr "Incluir Imagem" #: apprise/plugins/bark.py:230 apprise/plugins/mattermost.py:213 #: apprise/plugins/revolt.py:129 msgid "Icon URL" msgstr "URL do Icone" #: apprise/plugins/bark.py:234 apprise/plugins/streamlabs.py:119 msgid "Call" msgstr "Chamada" #: apprise/plugins/bark.py:239 apprise/plugins/pushover.py:301 #, fuzzy msgid "Encryption Key" msgstr "Criptografia PGP" #: apprise/plugins/base.py:305 msgid "Overflow Mode" msgstr "Modo de Estouro" #: apprise/plugins/base.py:320 msgid "Notify Format" msgstr "Formato de Notificacao" #: apprise/plugins/base.py:330 #, fuzzy msgid "Interpret Emojis" msgstr "Interpretar Emojis" #: apprise/plugins/base.py:340 msgid "Persistent Storage" msgstr "Armazenamento Persistente" #: apprise/plugins/base.py:350 #, fuzzy msgid "Timezone" msgstr "Fuso Horario" #: apprise/plugins/base.py:359 #, fuzzy msgid "Service Retry" msgstr "Servico" #: apprise/plugins/base.py:367 #, fuzzy msgid "Inter-Retry Wait" msgstr "Interpretar Emojis" #: apprise/plugins/base.py:375 #, fuzzy msgid "Optional Service" msgstr "Servico" #: apprise/plugins/blink1.py:151 #, fuzzy msgid "blink(1)" msgstr "Link" #: apprise/plugins/blink1.py:178 #, fuzzy msgid "Serial Number" msgstr "Numero de Serie do Dispositivo" #: apprise/plugins/blink1.py:188 #, fuzzy msgid "Duration (ms)" msgstr "Duracao" #: apprise/plugins/blink1.py:195 msgid "Fade Time (ms)" msgstr "" #: apprise/plugins/blink1.py:202 msgid "LED Number" msgstr "" #: apprise/plugins/brevo.py:164 apprise/plugins/mailersend.py:133 #: apprise/plugins/postmark.py:123 apprise/plugins/resend.py:114 #: apprise/plugins/sendgrid.py:123 #, fuzzy msgid "Source Email" msgstr "E-mail de Origem" #: apprise/plugins/brevo.py:169 apprise/plugins/email/base.py:163 #: apprise/plugins/mailersend.py:138 apprise/plugins/mailgun.py:152 #: apprise/plugins/office365.py:282 apprise/plugins/one_signal.py:124 #: apprise/plugins/pingram.py:182 apprise/plugins/postmark.py:128 #: apprise/plugins/pushbullet.py:100 apprise/plugins/pushsafer.py:371 #: apprise/plugins/resend.py:119 apprise/plugins/sendgrid.py:128 #: apprise/plugins/sendpulse.py:134 apprise/plugins/slack.py:264 #: apprise/plugins/threema.py:105 msgid "Target Email" msgstr "E-mail de Destino" #: apprise/plugins/brevo.py:185 apprise/plugins/postmark.py:149 #: apprise/plugins/ses.py:205 #, fuzzy msgid "Reply To Email" msgstr "Responder para E-mail" #: apprise/plugins/brevo.py:193 apprise/plugins/email/base.py:249 #: apprise/plugins/mailersend.py:157 apprise/plugins/mailgun.py:186 #: apprise/plugins/office365.py:307 apprise/plugins/pingram.py:245 #: apprise/plugins/postmark.py:157 apprise/plugins/resend.py:158 #: apprise/plugins/sendgrid.py:153 apprise/plugins/sendpulse.py:169 #: apprise/plugins/ses.py:232 apprise/plugins/smtp2go.py:143 #: apprise/plugins/sparkpost.py:201 msgid "Carbon Copy" msgstr "Copia Carbono" #: apprise/plugins/brevo.py:197 apprise/plugins/email/base.py:253 #: apprise/plugins/mailersend.py:161 apprise/plugins/mailgun.py:190 #: apprise/plugins/office365.py:311 apprise/plugins/pingram.py:249 #: apprise/plugins/postmark.py:161 apprise/plugins/resend.py:162 #: apprise/plugins/sendgrid.py:157 apprise/plugins/sendpulse.py:173 #: apprise/plugins/ses.py:236 apprise/plugins/smtp2go.py:147 #: apprise/plugins/sparkpost.py:205 msgid "Blind Carbon Copy" msgstr "Copia Carbono Oculta" #: apprise/plugins/bulksms.py:123 apprise/plugins/bulkvs.py:103 #: apprise/plugins/burstsms.py:124 apprise/plugins/clickatell.py:83 #: apprise/plugins/clicksend.py:105 apprise/plugins/d7networks.py:103 #: apprise/plugins/dingtalk.py:106 apprise/plugins/eight00com.py:121 #: apprise/plugins/evolution.py:135 apprise/plugins/exotel.py:183 #: apprise/plugins/httpsms.py:90 apprise/plugins/kavenegar.py:128 #: apprise/plugins/messagebird.py:92 apprise/plugins/msg91.py:116 #: apprise/plugins/notifyre.py:136 apprise/plugins/octopush.py:146 #: apprise/plugins/plivo.py:107 apprise/plugins/ringcentral.py:213 #: apprise/plugins/serwersms.py:148 apprise/plugins/seven.py:81 #: apprise/plugins/signal_api.py:124 apprise/plugins/sinch.py:127 #: apprise/plugins/smpp.py:121 apprise/plugins/smsc.py:149 #: apprise/plugins/smseagle.py:151 apprise/plugins/smsmanager.py:112 #: apprise/plugins/sns.py:157 apprise/plugins/threema.py:98 #: apprise/plugins/twilio.py:161 apprise/plugins/voipms.py:101 #: apprise/plugins/vonage.py:101 apprise/plugins/whatsapp.py:147 msgid "Target Phone No" msgstr "Numero de Telefone de Destino" #: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:246 #, fuzzy msgid "Target Group" msgstr "Grupo de Destino" #: apprise/plugins/bulksms.py:149 apprise/plugins/bulkvs.py:96 #: apprise/plugins/bulkvs.py:122 apprise/plugins/clickatell.py:78 #: apprise/plugins/eight00com.py:114 apprise/plugins/eight00com.py:142 #: apprise/plugins/fortysixelks.py:100 apprise/plugins/httpsms.py:83 #: apprise/plugins/httpsms.py:115 apprise/plugins/ringcentral.py:207 #: apprise/plugins/signal_api.py:117 apprise/plugins/sinch.py:120 #: apprise/plugins/smpp.py:114 apprise/plugins/smsmanager.py:134 #: apprise/plugins/twilio.py:154 apprise/plugins/voipms.py:94 #: apprise/plugins/vonage.py:94 msgid "From Phone No" msgstr "Numero de Telefone de Origem" #: apprise/plugins/bulksms.py:155 #, fuzzy msgid "Route Group" msgstr "Grupo de Roteamento" #: apprise/plugins/bulksms.py:162 apprise/plugins/d7networks.py:136 #: apprise/plugins/exotel.py:228 msgid "Unicode Characters" msgstr "Caracteres Unicode" #: apprise/plugins/burstsms.py:111 apprise/plugins/threema.py:92 #: apprise/plugins/vonage.py:87 #, fuzzy msgid "API Secret" msgstr "Segredo de API" #: apprise/plugins/burstsms.py:118 apprise/plugins/smsc.py:171 #, fuzzy msgid "Sender ID" msgstr "ID do Remetente" #: apprise/plugins/burstsms.py:152 msgid "Country" msgstr "Pais" #: apprise/plugins/burstsms.py:161 msgid "validity" msgstr "validade" #: apprise/plugins/chanify.py:47 msgid "Chanify" msgstr "Chanify" #: apprise/plugins/chime.py:130 apprise/plugins/discord.py:144 #: apprise/plugins/fluxer.py:174 apprise/plugins/ifttt.py:103 #: apprise/plugins/zoom.py:139 msgid "Webhook ID" msgstr "ID do Webhook" #: apprise/plugins/chime.py:136 apprise/plugins/discord.py:150 #: apprise/plugins/fluxer.py:181 apprise/plugins/google_chat.py:127 #: apprise/plugins/stackfield.py:92 apprise/plugins/webexteams.py:171 msgid "Webhook Token" msgstr "Token do Webhook" #: apprise/plugins/clickatell.py:45 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:72 apprise/plugins/eight00com.py:108 #: apprise/plugins/rocketchat.py:140 #, fuzzy msgid "API Token" msgstr "Token de API" #: apprise/plugins/custom_form.py:158 apprise/plugins/custom_json.py:140 #: apprise/plugins/custom_xml.py:140 msgid "Fetch Method" msgstr "Metodo de Busca" #: apprise/plugins/custom_form.py:164 msgid "Attach File As" msgstr "Anexar Arquivo Como" #: apprise/plugins/custom_form.py:179 apprise/plugins/custom_json.py:155 #: apprise/plugins/custom_xml.py:155 apprise/plugins/pagertree.py:146 msgid "Payload Extras" msgstr "Extras do Payload" #: apprise/plugins/custom_form.py:183 apprise/plugins/custom_json.py:159 #: apprise/plugins/custom_xml.py:159 msgid "GET Params" msgstr "Parametros GET" #: apprise/plugins/d7networks.py:97 #, fuzzy msgid "API Access Token" msgstr "Token de Acesso a API" #: apprise/plugins/d7networks.py:127 apprise/plugins/seven.py:105 msgid "Originating Address" msgstr "Endereco de Origem" #: apprise/plugins/dapnet.py:150 apprise/plugins/exotel.py:240 #: apprise/plugins/gotify.py:153 apprise/plugins/growl.py:163 #: apprise/plugins/jira.py:287 apprise/plugins/join.py:189 #: apprise/plugins/lametric.py:494 apprise/plugins/ntfy.py:283 #: apprise/plugins/opsgenie.py:280 apprise/plugins/pinglet.py:177 #: apprise/plugins/prowl.py:141 apprise/plugins/pushover.py:263 #: apprise/plugins/pushsafer.py:387 apprise/plugins/smseagle.py:187 msgid "Priority" msgstr "Prioridade" #: apprise/plugins/dapnet.py:156 msgid "Transmitter Groups" msgstr "Grupos de Transmissores" #: apprise/plugins/dbus.py:156 msgid "libdbus-1.so.x must be installed." msgstr "libdbus-1.so.x deve estar instalado." #: apprise/plugins/dbus.py:160 msgid "DBus Notification" msgstr "Notificacao DBus" #: apprise/plugins/dbus.py:204 apprise/plugins/glib.py:165 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:128 #: apprise/plugins/trigv.py:189 msgid "Urgency" msgstr "Urgencia" #: apprise/plugins/dbus.py:216 apprise/plugins/glib.py:176 msgid "X-Axis" msgstr "Eixo X" #: apprise/plugins/dbus.py:222 apprise/plugins/glib.py:182 msgid "Y-Axis" msgstr "Eixo Y" #: apprise/plugins/dingtalk.py:100 apprise/plugins/signl4.py:76 #, fuzzy msgid "Secret" msgstr "Segredo" #: apprise/plugins/discord.py:139 apprise/plugins/flock.py:106 #: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106 #: apprise/plugins/slack.py:216 apprise/plugins/viber.py:99 #: apprise/plugins/zulip.py:124 msgid "Bot Name" msgstr "Nome do Bot" #: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:201 msgid "Text To Speech" msgstr "Texto para Fala" #: apprise/plugins/discord.py:168 apprise/plugins/fluxer.py:206 msgid "Avatar Image" msgstr "Imagem de Avatar" #: apprise/plugins/discord.py:173 apprise/plugins/fluxer.py:211 #: apprise/plugins/ntfy.py:263 #, fuzzy msgid "Avatar URL" msgstr "URL do Avatar" #: apprise/plugins/discord.py:177 apprise/plugins/fluxer.py:215 #: apprise/plugins/pushover.py:275 apprise/plugins/trigv.py:180 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:222 msgid "Thread ID" msgstr "ID da Thread" #: apprise/plugins/discord.py:190 apprise/plugins/fluxer.py:230 msgid "Display Footer" msgstr "Exibir Rodape" #: apprise/plugins/discord.py:195 apprise/plugins/fluxer.py:235 msgid "Footer Logo" msgstr "Logo do Rodape" #: apprise/plugins/discord.py:200 apprise/plugins/fluxer.py:240 #, fuzzy msgid "Use Fields" msgstr "Usar Campos" #: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:245 msgid "Discord Flags" msgstr "Flags do Discord" #: apprise/plugins/discord.py:219 apprise/plugins/fluxer.py:256 msgid "Ping Users/Roles" msgstr "Mencionar Usuarios/Cargos" #: apprise/plugins/discord.py:223 apprise/plugins/slack.py:338 #: apprise/plugins/workflows.py:200 msgid "Template Path" msgstr "Caminho do Template" #: apprise/plugins/discord.py:231 #, fuzzy msgid "Batch Attachments" msgstr "Anexos Sensiveis" #: apprise/plugins/discord.py:241 apprise/plugins/mailgun.py:208 #: apprise/plugins/pingram.py:258 apprise/plugins/slack.py:348 #: apprise/plugins/sparkpost.py:223 apprise/plugins/telegram.py:437 #: apprise/plugins/workflows.py:221 #, fuzzy msgid "Template Tokens" msgstr "Tokens do Template" #: apprise/plugins/dot.py:142 #, fuzzy msgid "Device Serial Number" msgstr "Numero de Serie do Dispositivo" #: apprise/plugins/dot.py:155 #, fuzzy msgid "API Mode" msgstr "Modo de API" #: apprise/plugins/dot.py:161 msgid "Refresh Now" msgstr "Atualizar Agora" #: apprise/plugins/dot.py:167 msgid "Text Signature" msgstr "Assinatura de Texto" #: apprise/plugins/dot.py:171 msgid "Icon Base64 (Text API)" msgstr "Icone Base64 (API de Texto)" #: apprise/plugins/dot.py:175 msgid "Image Base64 (Image API)" msgstr "Imagem Base64 (API de Imagem)" #: apprise/plugins/dot.py:180 msgid "Link" msgstr "Link" #: apprise/plugins/dot.py:184 #, fuzzy msgid "Border" msgstr "Borda" #: apprise/plugins/dot.py:191 msgid "Dither Type" msgstr "Tipo de Dithering" #: apprise/plugins/dot.py:197 msgid "Dither Kernel" msgstr "Kernel de Dithering" #: apprise/plugins/dot.py:203 #, fuzzy msgid "Task Key" msgstr "Chave da Thread" #: apprise/plugins/emby.py:112 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:131 apprise/plugins/gotify.py:134 #: apprise/plugins/mattermost.py:172 apprise/plugins/notica.py:145 #: apprise/plugins/pinglet.py:154 msgid "Path" msgstr "Caminho" #: apprise/plugins/enigma2.py:141 msgid "Server Timeout" msgstr "Tempo Limite do Servidor" #: apprise/plugins/evolution.py:130 #, fuzzy msgid "Instance Name" msgstr "Nome do Dispositivo" #: apprise/plugins/exotel.py:160 apprise/plugins/sinch.py:106 #: apprise/plugins/twilio.py:140 msgid "Account SID" msgstr "SID da Conta" #: apprise/plugins/exotel.py:172 #, fuzzy msgid "From Phone No / Sender ID" msgstr "Numero de Telefone de Origem" #: apprise/plugins/exotel.py:233 apprise/plugins/jira.py:275 #: apprise/plugins/mailgun.py:176 apprise/plugins/opsgenie.py:273 #: apprise/plugins/pagerduty.py:176 apprise/plugins/pingram.py:220 #: apprise/plugins/sparkpost.py:191 msgid "Region Name" msgstr "Nome da Regiao" #: apprise/plugins/feishu.py:49 msgid "Feishu" msgstr "Feishu" #: apprise/plugins/flock.py:99 apprise/plugins/twitter.py:175 msgid "Access Key" msgstr "Chave de Acesso" #: apprise/plugins/flock.py:111 msgid "To User ID" msgstr "ID do Usuario de Destino" #: apprise/plugins/flock.py:118 msgid "To Channel ID" msgstr "ID do Canal de Destino" #: apprise/plugins/flowtriq.py:129 apprise/plugins/matrix/base.py:365 #, fuzzy msgid "Webhook Path" msgstr "Webhook" #: apprise/plugins/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188 #: apprise/plugins/fluxer.py:195 apprise/plugins/kook.py:196 #: apprise/plugins/lametric.py:510 apprise/plugins/mattermost.py:229 #: apprise/plugins/notifyre.py:157 apprise/plugins/ntfy.py:297 #: apprise/plugins/office365.py:319 apprise/plugins/pingram.py:226 #: apprise/plugins/sns.py:202 apprise/plugins/vapid/__init__.py:169 #: apprise/plugins/webexteams.py:197 apprise/plugins/zoom.py:158 #, fuzzy msgid "Mode" msgstr "Modo" #: apprise/plugins/fluxer.py:226 #, fuzzy msgid "Thread Name" msgstr "Nome da Thread" #: apprise/plugins/fortysixelks.py:58 msgid "46elks" msgstr "46elks" #: apprise/plugins/fortysixelks.py:89 #, fuzzy msgid "API Username" msgstr "Nome de Usuario de API" #: apprise/plugins/fortysixelks.py:94 #, fuzzy msgid "API Password" msgstr "Senha de API" #: apprise/plugins/freemobile.py:48 msgid "Free-Mobile" msgstr "Free-Mobile" #: apprise/plugins/glib.py:123 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "libdbus-1.so.x ou libdbus-2.so.x deve estar instalado." #: apprise/plugins/glib.py:127 #, fuzzy msgid "GLib Notification" msgstr "Notificacao DBus" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "Um ambiente Gnome local e necessario." #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "Notificacao Gnome" #: apprise/plugins/google_chat.py:115 msgid "Workspace" msgstr "Espaco de Trabalho" #: apprise/plugins/google_chat.py:121 #, fuzzy msgid "Webhook Key" msgstr "Chave do Webhook" #: apprise/plugins/google_chat.py:133 #, fuzzy msgid "Thread Key" msgstr "Chave da Thread" #: apprise/plugins/groupme.py:123 #, fuzzy msgid "Bot ID" msgstr "ID do Webhook" #: apprise/plugins/groupme.py:130 apprise/plugins/line.py:82 #: apprise/plugins/mastodon.py:185 apprise/plugins/matrix/base.py:307 #: apprise/plugins/misskey.py:122 apprise/plugins/pushbullet.py:83 #: apprise/plugins/pushover.py:234 apprise/plugins/smseagle.py:146 #: apprise/plugins/spugpush.py:68 apprise/plugins/streamlabs.py:105 #: apprise/plugins/whatsapp.py:121 msgid "Access Token" msgstr "Token de Acesso" #: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:195 #: apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versao" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Fixado" #: apprise/plugins/home_assistant.py:143 #, fuzzy msgid "Long-Lived Access Token" msgstr "Token de Acesso de Longa Duracao" #: apprise/plugins/home_assistant.py:166 msgid "Notification ID" msgstr "ID de Notificacao" #: apprise/plugins/home_assistant.py:183 #, fuzzy msgid "Path Prefix" msgstr "Prefixo" #: apprise/plugins/humhub.py:127 #, fuzzy msgid "Token or Username" msgstr "Nome de Usuario" #: apprise/plugins/ifttt.py:109 msgid "Events" msgstr "Eventos" #: apprise/plugins/ifttt.py:129 msgid "Add Tokens" msgstr "Adicionar Tokens" #: apprise/plugins/ifttt.py:133 msgid "Remove Tokens" msgstr "Remover Tokens" #: apprise/plugins/jira.py:240 apprise/plugins/opsgenie.py:238 #, fuzzy msgid "Target Escalation" msgstr "Escalada de Destino" #: apprise/plugins/jira.py:246 apprise/plugins/opsgenie.py:244 #, fuzzy msgid "Target Schedule" msgstr "Agendamento de Destino" #: apprise/plugins/irc/base.py:137 apprise/plugins/jira.py:252 #: apprise/plugins/line.py:88 apprise/plugins/mastodon.py:196 #: apprise/plugins/matrix/base.py:314 apprise/plugins/nextcloud.py:136 #: apprise/plugins/one_signal.py:129 apprise/plugins/opsgenie.py:250 #: apprise/plugins/pushed.py:95 apprise/plugins/rocketchat.py:156 #: apprise/plugins/slack.py:269 apprise/plugins/twitter.py:187 #: apprise/plugins/wechat.py:198 apprise/plugins/xmpp/base.py:123 #: apprise/plugins/zulip.py:143 msgid "Target User" msgstr "Usuario de Destino" #: apprise/plugins/jira.py:258 apprise/plugins/opsgenie.py:256 #, fuzzy msgid "Target Team" msgstr "Equipe de Destino" #: apprise/plugins/jira.py:264 apprise/plugins/opsgenie.py:262 #, fuzzy msgid "Targets " msgstr "Destinatarios" #: apprise/plugins/jira.py:293 apprise/plugins/opsgenie.py:286 msgid "Entity" msgstr "Entidade" #: apprise/plugins/jira.py:297 apprise/plugins/opsgenie.py:290 msgid "Alias" msgstr "Alias" #: apprise/plugins/jira.py:308 apprise/plugins/opsgenie.py:298 #: apprise/plugins/pagertree.py:118 apprise/plugins/splunk.py:202 #, fuzzy msgid "Action" msgstr "Acao" #: apprise/plugins/jira.py:319 apprise/plugins/opsgenie.py:317 #, fuzzy msgid "Details" msgstr "Detalhes" #: apprise/plugins/jira.py:323 apprise/plugins/opsgenie.py:321 #: apprise/plugins/splunk.py:213 msgid "Action Mapping" msgstr "Mapeamento de Acoes" #: apprise/plugins/join.py:147 msgid "Device ID" msgstr "ID do Dispositivo" #: apprise/plugins/join.py:153 #, fuzzy msgid "Device Name" msgstr "Nome do Dispositivo" #: apprise/plugins/kavenegar.py:122 apprise/plugins/messagebird.py:85 #: apprise/plugins/notifyre.py:163 apprise/plugins/plivo.py:100 #, fuzzy msgid "Source Phone No" msgstr "Numero de Telefone de Origem" #: apprise/plugins/kodi.py:123 apprise/plugins/streamlabs.py:141 #: apprise/plugins/windows.py:100 msgid "Duration" msgstr "Duracao" #: apprise/plugins/kumulos.py:99 #, fuzzy msgid "Server Key" msgstr "Chave do Servidor" #: apprise/plugins/lametric.py:436 #, fuzzy msgid "Device API Key" msgstr "Chave de API do Dispositivo" #: apprise/plugins/lametric.py:442 apprise/plugins/one_signal.py:102 #: apprise/plugins/parseplatform.py:101 msgid "App ID" msgstr "ID do Aplicativo" #: apprise/plugins/lametric.py:448 #, fuzzy msgid "App Version" msgstr "Versao do Aplicativo" #: apprise/plugins/lametric.py:455 #, fuzzy msgid "App Access Token" msgstr "Token de Acesso do Aplicativo" #: apprise/plugins/lametric.py:500 msgid "Custom Icon" msgstr "Icone Personalizado" #: apprise/plugins/lametric.py:504 msgid "Icon Type" msgstr "Tipo de Icone" #: apprise/plugins/lametric.py:521 msgid "Cycles" msgstr "Ciclos" #: apprise/plugins/lark.py:47 msgid "Lark (Feishu)" msgstr "Lark (Feishu)" #: apprise/plugins/lark.py:67 apprise/plugins/revolt.py:98 #: apprise/plugins/telegram.py:357 msgid "Bot Token" msgstr "Token do Bot" #: apprise/plugins/macosx.py:65 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" #: apprise/plugins/macosx.py:72 msgid "MacOSX Notification" msgstr "Notificacao MacOSX" #: apprise/plugins/macosx.py:128 msgid "Open/Click URL" msgstr "URL de Abertura/Clique" #: apprise/plugins/email/base.py:200 apprise/plugins/mailersend.py:165 #: apprise/plugins/office365.py:315 apprise/plugins/pingram.py:231 #: apprise/plugins/resend.py:150 msgid "Reply To" msgstr "Responder Para" #: apprise/plugins/email/base.py:152 apprise/plugins/mailgun.py:141 #: apprise/plugins/sendpulse.py:115 apprise/plugins/smtp2go.py:113 #: apprise/plugins/sparkpost.py:164 msgid "Domain" msgstr "Dominio" #: apprise/plugins/email/base.py:183 apprise/plugins/mailgun.py:168 #: apprise/plugins/postmark.py:144 apprise/plugins/resend.py:140 #: apprise/plugins/ses.py:210 apprise/plugins/smtp2go.py:135 #: apprise/plugins/sparkpost.py:186 msgid "From Name" msgstr "Nome de Origem" #: apprise/plugins/email/base.py:262 apprise/plugins/mailgun.py:204 #: apprise/plugins/smtp2go.py:161 apprise/plugins/sparkpost.py:219 #, fuzzy msgid "Email Header" msgstr "Cabecalho de E-mail" #: apprise/plugins/mastodon.py:216 apprise/plugins/misskey.py:143 msgid "Visibility" msgstr "Visibilidade" #: apprise/plugins/mastodon.py:222 msgid "Spoiler Text" msgstr "Texto de Spoiler" #: apprise/plugins/mastodon.py:226 msgid "Idempotency-Key" msgstr "Chave de Idempotencia" #: apprise/plugins/mastodon.py:230 msgid "Language Code" msgstr "Codigo de Idioma" #: apprise/plugins/mastodon.py:234 apprise/plugins/twitter.py:210 msgid "Cache Results" msgstr "Armazenar Resultados em Cache" #: apprise/plugins/mastodon.py:239 msgid "Sensitive Attachments" msgstr "Anexos Sensiveis" #: apprise/plugins/mastodon.py:255 #, fuzzy msgid "Ping Users/Tags" msgstr "Mencionar Usuarios/Cargos" #: apprise/plugins/irc/base.py:128 apprise/plugins/mattermost.py:156 #: apprise/plugins/xmpp/base.py:112 #, fuzzy msgid "User" msgstr "Usuario" #: apprise/plugins/irc/base.py:143 apprise/plugins/mattermost.py:182 #: apprise/plugins/notifiarr.py:97 apprise/plugins/pushbullet.py:94 #: apprise/plugins/pushed.py:101 apprise/plugins/rocketchat.py:150 #: apprise/plugins/slack.py:275 apprise/plugins/trigv.py:150 #: apprise/plugins/twist.py:112 apprise/plugins/xmpp/base.py:128 msgid "Target Channel" msgstr "Canal de Destino" #: apprise/plugins/mattermost.py:188 apprise/plugins/twist.py:118 #, fuzzy msgid "Target Channel ID" msgstr "ID do Canal de Destino" #: apprise/plugins/mqtt.py:171 msgid "Target Queue" msgstr "" #: apprise/plugins/mqtt.py:188 msgid "QOS" msgstr "QoS" #: apprise/plugins/mqtt.py:201 apprise/plugins/office365.py:269 #: apprise/plugins/ringcentral.py:192 apprise/plugins/sendpulse.py:120 #, fuzzy msgid "Client ID" msgstr "ID do Cliente" #: apprise/plugins/mqtt.py:205 msgid "Use Session" msgstr "Usar Sessao" #: apprise/plugins/mqtt.py:210 msgid "Retain Messages" msgstr "Reter Mensagens" #: apprise/plugins/msg91.py:102 apprise/plugins/sendpulse.py:156 msgid "Template ID" msgstr "ID do Template" #: apprise/plugins/msg91.py:109 #, fuzzy msgid "Authentication Key" msgstr "Chave de Autenticacao" #: apprise/plugins/msg91.py:138 msgid "Short URL" msgstr "URL Curta" #: apprise/plugins/msg91.py:148 apprise/plugins/whatsapp.py:203 msgid "Template Mapping" msgstr "Mapeamento de Template" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:113 msgid "URL Prefix" msgstr "Prefixo de URL" #: apprise/plugins/nextcloudtalk.py:43 msgid "Nextcloud Talk" msgstr "Nextcloud Talk" #: apprise/plugins/nextcloudtalk.py:96 #, fuzzy msgid "Room ID" msgstr "ID da Sala" #: apprise/plugins/notifiarr.py:121 msgid "Discord Event ID" msgstr "ID de Evento do Discord" #: apprise/plugins/notifiarr.py:131 apprise/plugins/pagerduty.py:145 #, fuzzy msgid "Source" msgstr "Origem" #: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:187 #, fuzzy msgid "Project ID" msgstr "ID do Projeto" #: apprise/plugins/notifico.py:196 msgid "Message Hook" msgstr "Hook de Mensagem" #: apprise/plugins/notifico.py:211 msgid "IRC Colors" msgstr "Cores IRC" #: apprise/plugins/notifico.py:217 msgid "Prefix" msgstr "Prefixo" #: apprise/plugins/notifyre.py:168 #, fuzzy msgid "Campaign Name" msgstr "Nome de Usuario de API" #: apprise/plugins/notifyre.py:172 apprise/plugins/whatsapp.py:128 msgid "Template Name" msgstr "Nome do Template" #: apprise/plugins/notifyre.py:176 #, fuzzy msgid "Client Reference" msgstr "Segredo do Cliente" #: apprise/plugins/notifyre.py:180 msgid "High Quality" msgstr "" #: apprise/plugins/notifyre.py:185 #, fuzzy msgid "Fax Header" msgstr "Cabecalho de E-mail" #: apprise/plugins/ntfy.py:235 apprise/plugins/pinglet.py:165 msgid "Topic" msgstr "Topico" #: apprise/plugins/ntfy.py:253 msgid "Attach" msgstr "Anexar" #: apprise/plugins/ntfy.py:267 msgid "Attach Filename" msgstr "Nome do Arquivo Anexado" #: apprise/plugins/ntfy.py:275 msgid "Delay" msgstr "Atraso" #: apprise/plugins/ntfy.py:279 apprise/plugins/twist.py:107 #, fuzzy msgid "Email" msgstr "E-mail" #: apprise/plugins/ntfy.py:293 #, fuzzy msgid "Actions" msgstr "Acoes" #: apprise/plugins/ntfy.py:306 #, fuzzy msgid "Authentication Type" msgstr "Tipo de Autenticacao" #: apprise/plugins/octopush.py:130 #, fuzzy msgid "API Login" msgstr "Token de API" #: apprise/plugins/octopush.py:142 #, fuzzy msgid "Sender" msgstr "ID do Remetente" #: apprise/plugins/octopush.py:178 #, fuzzy msgid "Accept Replies" msgstr "Enviar Respostas" #: apprise/plugins/octopush.py:183 msgid "Purpose" msgstr "" #: apprise/plugins/octopush.py:189 #, fuzzy msgid "Type" msgstr "Tipo de Icone" #: apprise/plugins/office365.py:257 #, fuzzy msgid "Tenant Domain" msgstr "Dominio do Locatario" #: apprise/plugins/office365.py:264 msgid "Account Email or Object ID" msgstr "E-mail da Conta ou ID do Objeto" #: apprise/plugins/office365.py:276 apprise/plugins/ringcentral.py:199 #: apprise/plugins/sendpulse.py:127 #, fuzzy msgid "Client Secret" msgstr "Segredo do Cliente" #: apprise/plugins/office365.py:325 #, fuzzy msgid "Save to Sent Items" msgstr "Entradas de Dados" #: apprise/plugins/one_signal.py:108 apprise/plugins/sendgrid.py:146 msgid "Template" msgstr "Template" #: apprise/plugins/one_signal.py:119 #, fuzzy msgid "Target Player ID" msgstr "ID do Jogador de Destino" #: apprise/plugins/one_signal.py:135 #, fuzzy msgid "Include Segment" msgstr "Incluir Segmento" #: apprise/plugins/one_signal.py:155 msgid "Subtitle" msgstr "Legenda" #: apprise/plugins/one_signal.py:159 apprise/plugins/sfr.py:125 #: apprise/plugins/whatsapp.py:141 msgid "Language" msgstr "Idioma" #: apprise/plugins/one_signal.py:170 msgid "Enable Contents" msgstr "Habilitar Conteudo" #: apprise/plugins/one_signal.py:176 msgid "Decode Template Args" msgstr "Decodificar Argumentos do Template" #: apprise/plugins/one_signal.py:195 msgid "Custom Data" msgstr "Dados Personalizados" #: apprise/plugins/one_signal.py:199 msgid "Postback Data" msgstr "Dados de Postback" #: apprise/plugins/pagerduty.py:138 apprise/plugins/pushward.py:134 #: apprise/plugins/spike.py:68 #, fuzzy msgid "Integration Key" msgstr "Chave de Integracao" #: apprise/plugins/pagerduty.py:151 #, fuzzy msgid "Component" msgstr "Componente" #: apprise/plugins/pagerduty.py:167 msgid "Class" msgstr "Classe" #: apprise/plugins/pagerduty.py:185 msgid "Severity" msgstr "Severidade" #: apprise/plugins/pagerduty.py:202 #, fuzzy msgid "Custom Details" msgstr "Detalhes Personalizados" #: apprise/plugins/pagertree.py:105 msgid "Integration ID" msgstr "ID de Integracao" #: apprise/plugins/pagertree.py:124 msgid "Third Party ID" msgstr "ID de Terceiros" #: apprise/plugins/pagertree.py:150 msgid "Meta Extras" msgstr "Extras de Meta" #: apprise/plugins/parseplatform.py:107 #, fuzzy msgid "Master Key" msgstr "Chave Mestra" #: apprise/plugins/parseplatform.py:120 #, fuzzy msgid "Device" msgstr "Dispositivo" #: apprise/plugins/pinglet.py:160 #, fuzzy msgid "Namespace" msgstr "Nome" #: apprise/plugins/pinglet.py:191 #, fuzzy msgid "Badges" msgstr "Emblema" #: apprise/plugins/pinglet.py:195 #, fuzzy msgid "Metadata" msgstr "Extras de Meta" #: apprise/plugins/matrix/base.py:377 apprise/plugins/pingram.py:169 msgid "Message Type" msgstr "Tipo de Mensagem" #: apprise/plugins/pingram.py:187 #, fuzzy msgid "Target ID" msgstr "ID de Destino" #: apprise/plugins/pingram.py:193 #, fuzzy msgid "Target SMS" msgstr "SMS de Destino" #: apprise/plugins/pingram.py:215 msgid "Channels" msgstr "Canais" #: apprise/plugins/email/base.py:178 apprise/plugins/pingram.py:236 #: apprise/plugins/sendpulse.py:150 apprise/plugins/ses.py:163 msgid "From Email" msgstr "E-mail de Origem" #: apprise/plugins/plivo.py:88 #, fuzzy msgid "Auth ID" msgstr "ID de Autenticacao" #: apprise/plugins/plivo.py:94 apprise/plugins/sinch.py:113 #: apprise/plugins/twilio.py:147 msgid "Auth Token" msgstr "Token de Autenticacao" #: apprise/plugins/prowl.py:128 msgid "Provider Key" msgstr "Chave do Provedor" #: apprise/plugins/pushdeer.py:85 #, fuzzy msgid "Pushkey" msgstr "Chave Push" #: apprise/plugins/pushed.py:83 msgid "Application Key" msgstr "Chave do Aplicativo" #: apprise/plugins/pushed.py:89 apprise/plugins/reddit.py:158 msgid "Application Secret" msgstr "Segredo do Aplicativo" #: apprise/plugins/pushjet.py:82 msgid "Secret Key" msgstr "Chave Secreta" #: apprise/plugins/pushme.py:81 apprise/plugins/signal_api.py:152 #: apprise/plugins/smseagle.py:193 msgid "Show Status" msgstr "Mostrar Status" #: apprise/plugins/pushover.py:228 msgid "User Key" msgstr "Chave do Usuario" #: apprise/plugins/pushover.py:280 msgid "URL Title" msgstr "Titulo da URL" #: apprise/plugins/pushover.py:285 msgid "Emergency Retry Interval" msgstr "" #: apprise/plugins/pushover.py:291 msgid "Expire" msgstr "Expirar" #: apprise/plugins/pushover.py:308 msgid "E2EE" msgstr "" #: apprise/plugins/pushplus.py:172 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:210 apprise/plugins/qq.py:66 #, fuzzy msgid "User Token" msgstr "Token do Usuario" #: apprise/plugins/pushplus.py:219 msgid "Group Topics" msgstr "" #: apprise/plugins/pushplus.py:240 #, fuzzy msgid "Channel" msgstr "Canais" #: apprise/plugins/pushplus.py:257 #, fuzzy msgid "Webhook Name" msgstr "Modo Webhook" #: apprise/plugins/pushsafer.py:360 #, fuzzy msgid "Private Key" msgstr "Chave Privada" #: apprise/plugins/pushsafer.py:397 #, fuzzy msgid "Vibration" msgstr "Vibracao" #: apprise/plugins/pushward.py:158 #, fuzzy msgid "Info Level" msgstr "Nivel" #: apprise/plugins/pushward.py:164 #, fuzzy msgid "Success Level" msgstr "Segredo de Acesso" #: apprise/plugins/pushward.py:170 msgid "Warning Level" msgstr "" #: apprise/plugins/pushward.py:176 msgid "Failure Level" msgstr "" #: apprise/plugins/pushy.py:79 #, fuzzy msgid "Secret API Key" msgstr "Chave Secreta de API" #: apprise/plugins/fcm/__init__.py:162 apprise/plugins/pushy.py:91 #: apprise/plugins/sns.py:163 apprise/plugins/wxpusher.py:128 msgid "Target Topic" msgstr "Topico de Destino" #: apprise/plugins/qq.py:46 msgid "QQ Push" msgstr "QQ Push" #: apprise/plugins/reddit.py:151 #, fuzzy msgid "Application ID" msgstr "ID do Aplicativo" #: apprise/plugins/reddit.py:165 #, fuzzy msgid "Target Subreddit" msgstr "Subreddit de Destino" #: apprise/plugins/reddit.py:182 msgid "Kind" msgstr "Tipo" #: apprise/plugins/reddit.py:188 msgid "Flair ID" msgstr "ID do Flair" #: apprise/plugins/reddit.py:193 msgid "Flair Text" msgstr "Texto do Flair" #: apprise/plugins/reddit.py:198 msgid "NSFW" msgstr "Conteudo Adulto" #: apprise/plugins/reddit.py:204 msgid "Is Ad?" msgstr "E Anuncio?" #: apprise/plugins/reddit.py:210 msgid "Send Replies" msgstr "Enviar Respostas" #: apprise/plugins/reddit.py:216 msgid "Is Spoiler" msgstr "E Spoiler" #: apprise/plugins/reddit.py:222 msgid "Resubmit Flag" msgstr "Flag de Reenvio" #: apprise/plugins/resend.py:135 #, fuzzy msgid "From Address" msgstr "Nome de Origem" #: apprise/plugins/revolt.py:104 #, fuzzy msgid "Channel ID" msgstr "ID do Canal" #: apprise/plugins/revolt.py:131 msgid "Embed URL" msgstr "URL Incorporada" #: apprise/plugins/ringcentral.py:186 #, fuzzy msgid "Token / Password" msgstr "Senha" #: apprise/plugins/ringcentral.py:239 #, fuzzy msgid "Environment" msgstr "Evento" #: apprise/plugins/ringcentral.py:252 #, fuzzy msgid "Authentication Mode" msgstr "Token de Autenticacao" #: apprise/plugins/rocketchat.py:146 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:320 apprise/plugins/rocketchat.py:162 msgid "Target Room ID" msgstr "ID da Sala de Destino" #: apprise/plugins/matrix/base.py:359 apprise/plugins/rocketchat.py:178 #: apprise/plugins/ryver.py:118 msgid "Webhook Mode" msgstr "Modo Webhook" #: apprise/plugins/rocketchat.py:183 msgid "Use Avatar" msgstr "Usar Avatar" #: apprise/plugins/rsyslog.py:173 apprise/plugins/syslog.py:144 msgid "Facility" msgstr "Instalacao" #: apprise/plugins/rsyslog.py:203 apprise/plugins/syslog.py:161 msgid "Log PID" msgstr "PID do Log" #: apprise/plugins/ryver.py:93 apprise/plugins/zulip.py:130 msgid "Organization" msgstr "Organizacao" #: apprise/plugins/sendgrid.py:166 apprise/plugins/sendpulse.py:182 msgid "Template Data" msgstr "Dados do Template" #: apprise/plugins/serwersms.py:142 apprise/plugins/sfr.py:131 #, fuzzy msgid "Sender Name" msgstr "Nome do Remetente" #: apprise/plugins/serwersms.py:155 apprise/plugins/signal_api.py:131 #: apprise/plugins/smseagle.py:158 apprise/plugins/whatsapp.py:159 #, fuzzy msgid "Target Group ID" msgstr "ID do Grupo de Destino" #: apprise/plugins/ses.py:157 apprise/plugins/sns.py:132 #, fuzzy msgid "Session Token" msgstr "Token de Acesso" #: apprise/plugins/ses.py:169 apprise/plugins/sns.py:138 msgid "Access Key ID" msgstr "ID da Chave de Acesso" #: apprise/plugins/ses.py:175 apprise/plugins/sns.py:144 msgid "Secret Access Key" msgstr "Chave de Acesso Secreta" #: apprise/plugins/ses.py:181 apprise/plugins/sinch.py:157 #: apprise/plugins/sns.py:150 msgid "Region" msgstr "Regiao" #: apprise/plugins/ses.py:188 apprise/plugins/smtp2go.py:124 #: apprise/plugins/sparkpost.py:175 msgid "Target Emails" msgstr "E-mails de Destino" #: apprise/plugins/seven.py:113 apprise/plugins/smseagle.py:203 msgid "Flash" msgstr "Flash" #: apprise/plugins/seven.py:117 msgid "Label" msgstr "Rotulo" #: apprise/plugins/sfr.py:58 msgid "SociΓ©tΓ© FranΓ§aise du RadiotΓ©lΓ©phone" msgstr "" #: apprise/plugins/sfr.py:90 #, fuzzy msgid "Service ID" msgstr "ID do Servico" #: apprise/plugins/sfr.py:95 #, fuzzy msgid "Service Password" msgstr "Senha do Servico" #: apprise/plugins/sfr.py:101 #, fuzzy msgid "Space ID" msgstr "ID do Espaco" #: apprise/plugins/sfr.py:107 msgid "Recipient Phone Number" msgstr "Numero de Telefone do Destinatario" #: apprise/plugins/sfr.py:138 msgid "Media Type" msgstr "Tipo de Midia" #: apprise/plugins/sfr.py:145 #, fuzzy msgid "Timeout" msgstr "Tempo Limite" #: apprise/plugins/sfr.py:151 #, fuzzy msgid "TTS Voice" msgstr "Voz TTS" #: apprise/plugins/signl4.py:89 #, fuzzy msgid "Service" msgstr "Servico" #: apprise/plugins/signl4.py:93 #, fuzzy msgid "Location" msgstr "Localizacao" #: apprise/plugins/signl4.py:97 msgid "Alerting Scenario" msgstr "Cenario de Alerta" #: apprise/plugins/signl4.py:101 msgid "Filtering" msgstr "Filtragem" #: apprise/plugins/signl4.py:106 #, fuzzy msgid "External ID" msgstr "ID Externo" #: apprise/plugins/signl4.py:110 #, fuzzy msgid "Status" msgstr "Status" #: apprise/plugins/simplepush.py:113 msgid "Salt" msgstr "Salt" #: apprise/plugins/simplepush.py:126 #, fuzzy msgid "Event" msgstr "Evento" #: apprise/plugins/sinch.py:134 apprise/plugins/twilio.py:168 msgid "Target Short Code" msgstr "Codigo Curto de Destino" #: apprise/plugins/slack.py:224 #, fuzzy msgid "OAuth Access Token" msgstr "Token de Acesso OAuth" #: apprise/plugins/slack.py:233 msgid "Token A" msgstr "Token A" #: apprise/plugins/slack.py:242 msgid "Token B" msgstr "Token B" #: apprise/plugins/slack.py:251 msgid "Token C" msgstr "Token C" #: apprise/plugins/slack.py:258 msgid "Target Encoded ID" msgstr "ID Codificado de Destino" #: apprise/plugins/slack.py:287 #, fuzzy msgid "Workflow Path" msgstr "ID do Fluxo de Trabalho" #: apprise/plugins/slack.py:305 #, fuzzy msgid "Include Footer" msgstr "Incluir Rodape" #: apprise/plugins/slack.py:313 msgid "Use Blocks" msgstr "Usar Blocos" #: apprise/plugins/slack.py:319 #, fuzzy msgid "Include Timestamp" msgstr "Incluir Timestamp" #: apprise/plugins/slack.py:325 apprise/plugins/twitter.py:204 #, fuzzy msgid "Message Mode" msgstr "Modo de Mensagem" #: apprise/plugins/smpp.py:61 msgid "SMPP" msgstr "SMPP" #: apprise/plugins/smpp.py:103 #, fuzzy msgid "Host" msgstr "Host" #: apprise/plugins/smsc.py:138 #, fuzzy msgid "Login" msgstr "Token de API" #: apprise/plugins/smsc.py:175 msgid "Transliterate" msgstr "" #: apprise/plugins/smseagle.py:165 #, fuzzy msgid "Target Contact" msgstr "Contato de Destino" #: apprise/plugins/smseagle.py:198 msgid "Test Only" msgstr "Somente Teste" #: apprise/plugins/smsmanager.py:143 msgid "Gateway" msgstr "Gateway" #: apprise/plugins/sogs.py:219 #, fuzzy msgid "Public Key" msgstr "Chave de API" #: apprise/plugins/sogs.py:226 #, fuzzy msgid "Seed" msgstr "ID do Remetente" #: apprise/plugins/sogs.py:233 #, fuzzy msgid "Rooms" msgstr "ID da Sala" #: apprise/plugins/spike.py:48 msgid "Spike.sh" msgstr "Spike.sh" #: apprise/plugins/splunk.py:117 msgid "Splunk On-Call" msgstr "Splunk On-Call" #: apprise/plugins/splunk.py:172 #, fuzzy msgid "Target Routing Key" msgstr "Chave de Roteamento de Destino" #: apprise/plugins/splunk.py:179 msgid "Entity ID" msgstr "ID da Entidade" #: apprise/plugins/spugpush.py:48 msgid "SpugPush" msgstr "SpugPush" #: apprise/plugins/streamlabs.py:125 msgid "Alert Type" msgstr "Tipo de Alerta" #: apprise/plugins/streamlabs.py:131 msgid "Image Link" msgstr "Link da Imagem" #: apprise/plugins/streamlabs.py:136 #, fuzzy msgid "Sound Link" msgstr "Link do Som" #: apprise/plugins/streamlabs.py:147 msgid "Special Text Color" msgstr "Cor de Texto Especial" #: apprise/plugins/streamlabs.py:153 msgid "Amount" msgstr "Quantidade" #: apprise/plugins/streamlabs.py:159 #, fuzzy msgid "Currency" msgstr "Moeda" #: apprise/plugins/streamlabs.py:165 #, fuzzy msgid "Name" msgstr "Nome" #: apprise/plugins/streamlabs.py:171 msgid "Identifier" msgstr "Identificador" #: apprise/plugins/synology.py:117 msgid "Upload" msgstr "Enviar" #: apprise/plugins/syslog.py:167 msgid "Log to STDERR" msgstr "Registrar no STDERR" #: apprise/plugins/telegram.py:366 msgid "Target Chat ID" msgstr "ID do Chat de Destino" #: apprise/plugins/telegram.py:389 msgid "Detect Bot Owner" msgstr "Detectar Proprietario do Bot" #: apprise/plugins/telegram.py:395 msgid "Silent Notification" msgstr "Notificacao Silenciosa" #: apprise/plugins/telegram.py:400 msgid "Web Page Preview" msgstr "Pre-visualizacao de Pagina Web" #: apprise/plugins/telegram.py:405 msgid "Topic Thread ID" msgstr "ID de Thread do Topico" #: apprise/plugins/telegram.py:412 #, fuzzy msgid "Markdown Version" msgstr "Versao do Markdown" #: apprise/plugins/telegram.py:421 msgid "Content Placement" msgstr "Posicionamento de Conteudo" #: apprise/plugins/telegram.py:427 #, fuzzy msgid "Rich Message Template Path" msgstr "Caminho do Template" #: apprise/plugins/threema.py:85 msgid "Gateway ID" msgstr "ID do Gateway" #: apprise/plugins/threema.py:110 #, fuzzy msgid "Target Threema ID" msgstr "ID Threema de Destino" #: apprise/plugins/trigv.py:159 #, fuzzy msgid "API Hostname" msgstr "Nome do Host" #: apprise/plugins/trigv.py:185 #, fuzzy msgid "Image URL" msgstr "Link da Imagem" #: apprise/plugins/trigv.py:195 #, fuzzy msgid "Event type" msgstr "Tipo de Alerta" #: apprise/plugins/trigv.py:199 msgid "Priority (Pushover compatibility)" msgstr "" #: apprise/plugins/twilio.py:200 msgid "Notification Method: sms or call" msgstr "Metodo de Notificacao: sms ou chamada" #: apprise/plugins/twitter.py:163 msgid "Consumer Key" msgstr "Chave do Consumidor" #: apprise/plugins/twitter.py:169 msgid "Consumer Secret" msgstr "Segredo do Consumidor" #: apprise/plugins/twitter.py:181 msgid "Access Secret" msgstr "Segredo de Acesso" #: apprise/plugins/viber.py:49 msgid "Viber" msgstr "Viber" #: apprise/plugins/viber.py:81 #, fuzzy msgid "Authentication Token" msgstr "Token de Autenticacao" #: apprise/plugins/viber.py:87 msgid "Receiver IDs" msgstr "IDs dos Receptores" #: apprise/plugins/viber.py:105 #, fuzzy msgid "Bot Avatar URL" msgstr "URL do Avatar do Bot" #: apprise/plugins/voipms.py:83 #, fuzzy msgid "User Email" msgstr "E-mail do Usuario" #: apprise/plugins/vapid/__init__.py:179 apprise/plugins/vonage.py:133 msgid "ttl" msgstr "ttl" #: apprise/plugins/webexteams.py:178 #, fuzzy msgid "Bot Access Token" msgstr "Token de Acesso OAuth" #: apprise/plugins/webexteams.py:184 #, fuzzy msgid "Room IDs" msgstr "ID da Sala" #: apprise/plugins/wechat.py:174 #, fuzzy msgid "Corp ID" msgstr "ID do Aplicativo" #: apprise/plugins/wechat.py:181 #, fuzzy msgid "App Secret" msgstr "Segredo de API" #: apprise/plugins/wechat.py:189 #, fuzzy msgid "Agent ID" msgstr "ID de Destino" #: apprise/plugins/wechat.py:205 #, fuzzy msgid "Target Department" msgstr "Dispositivo de Destino" #: apprise/plugins/wechat.py:212 #, fuzzy msgid "Target Tag" msgstr "Equipe de Destino" #: apprise/plugins/wecombot.py:99 #, fuzzy msgid "Bot Webhook Key" msgstr "Chave Webhook do Bot" #: apprise/plugins/whatsapp.py:134 #, fuzzy msgid "From Phone ID" msgstr "ID do Telefone de Origem" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "Um ambiente Microsoft Windows local e necessario." #: apprise/plugins/workflows.py:150 #, fuzzy msgid "Workflow ID" msgstr "ID do Fluxo de Trabalho" #: apprise/plugins/workflows.py:158 msgid "Signature" msgstr "Assinatura" #: apprise/plugins/workflows.py:181 msgid "Use Power Automate URL" msgstr "Usar URL do Power Automate" #: apprise/plugins/workflows.py:188 #, fuzzy msgid "Power Automate Routing ID" msgstr "Usar URL do Power Automate" #: apprise/plugins/workflows.py:195 msgid "Wrap Text" msgstr "Quebrar Texto" #: apprise/plugins/workflows.py:210 #, fuzzy msgid "API Version" msgstr "Versao da API" #: apprise/plugins/wxpusher.py:121 #, fuzzy msgid "App Token" msgstr "Token do Aplicativo" #: apprise/plugins/wxpusher.py:133 #, fuzzy msgid "Target User ID" msgstr "ID do Usuario de Destino" #: apprise/plugins/zoom.py:145 #, fuzzy msgid "Verification Token" msgstr "Token de Autenticacao" #: apprise/plugins/zulip.py:148 #, fuzzy msgid "Target Stream" msgstr "Stream de Destino" #: apprise/plugins/email/base.py:188 msgid "SMTP Server" msgstr "Servidor SMTP" #: apprise/plugins/email/base.py:193 apprise/plugins/xmpp/base.py:149 msgid "Secure Mode" msgstr "Modo Seguro" #: apprise/plugins/email/base.py:205 #, fuzzy msgid "PGP Security Mode" msgstr "Modo Seguro" #: apprise/plugins/email/base.py:212 msgid "PGP Public Key Path" msgstr "Caminho da Chave Publica PGP" #: apprise/plugins/email/base.py:225 #, fuzzy msgid "PGP Private Key Path" msgstr "Caminho da Chave Publica PGP" #: apprise/plugins/email/base.py:233 msgid "Web Key Directory" msgstr "" #: apprise/plugins/email/base.py:239 #, fuzzy msgid "Inline Attachments" msgstr "Anexos Sensiveis" #: apprise/plugins/email/base.py:244 msgid "To Email" msgstr "Para E-mail" #: apprise/plugins/fcm/__init__.py:148 msgid "OAuth2 KeyFile" msgstr "Arquivo de Chave OAuth2" #: apprise/plugins/fcm/__init__.py:193 msgid "Custom Image URL" msgstr "URL de Imagem Personalizada" #: apprise/plugins/fcm/__init__.py:205 msgid "Notification Color" msgstr "Cor da Notificacao" #: apprise/plugins/fcm/__init__.py:215 msgid "Data Entries" msgstr "Entradas de Dados" #: apprise/plugins/irc/base.py:159 #, fuzzy msgid "Real Name" msgstr "Nome Real" #: apprise/plugins/irc/base.py:160 #, fuzzy msgid "Nickname" msgstr "Apelido" #: apprise/plugins/irc/base.py:162 #, fuzzy msgid "Join Channels" msgstr "Entrar em Canais" #: apprise/plugins/irc/base.py:167 msgid "Auth Mode" msgstr "" #: apprise/plugins/matrix/base.py:326 msgid "Target Room Alias" msgstr "Alias da Sala de Destino" #: apprise/plugins/matrix/base.py:349 #, fuzzy msgid "Server Discovery" msgstr "Descoberta de Servidor" #: apprise/plugins/matrix/base.py:354 msgid "Force Home Server on Room IDs" msgstr "Forcar Servidor Home nos IDs de Sala" #: apprise/plugins/matrix/base.py:371 msgid "Matrix API Verion" msgstr "Versao da API Matrix" #: apprise/plugins/matrix/base.py:383 #, fuzzy msgid "End-to-End Encryption" msgstr "Criptografia PGP" #: apprise/plugins/vapid/__init__.py:193 msgid "PEM Private KeyFile" msgstr "Arquivo de Chave Privada PEM" #: apprise/plugins/vapid/__init__.py:199 msgid "Subscripion File" msgstr "Arquivo de Assinatura" #: apprise/plugins/xmpp/base.py:144 #, fuzzy msgid "XMPP Server" msgstr "Servidor SMTP" #: apprise/plugins/xmpp/base.py:156 #, fuzzy msgid "Get Roster" msgstr "Obter Lista de Contatos" #: apprise/plugins/xmpp/base.py:161 msgid "Use Subject" msgstr "Usar Assunto" #: apprise/plugins/xmpp/base.py:166 #, fuzzy msgid "Keep Connection Alive" msgstr "Manter Conexao Ativa" #: apprise/plugins/xmpp/base.py:172 #, fuzzy msgid "MUC Nickname" msgstr "Apelido MUC" #: apprise/plugins/xmpp/base.py:176 msgid "SCRAM-PLUS Channel Binding" msgstr "" caronc-apprise-182f859/apprise/locale.py000066400000000000000000000214271524161175200201760ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import ctypes import locale import os from os.path import abspath, dirname, join import re from typing import Union from .logger import logger # This gets toggled to True if we succeed GETTEXT_LOADED = False try: # Initialize gettext import gettext # Toggle our flag GETTEXT_LOADED = True except ImportError: # gettext isn't available; no problem; Use the library features without # multi-language support. pass class AppriseLocale: """A wrapper class to gettext so that we can manipulate multiple lanaguages on the fly if required.""" # Define our translation domain _domain = "apprise" # The path to our translations _locale_dir = abspath(join(dirname(__file__), "i18n")) # Locale regular expression _local_re = re.compile( r"^((?PC)|(?P([a-z]{2}))([_:](?P[a-z]{2}))?)" r"(\.(?P[a-z0-9-]+))?$", re.IGNORECASE, ) # Define our default encoding _default_encoding = "utf-8" # The function to assign `_` by default _fn = "gettext" # The language we should fall back to if all else fails _default_language = "en" def __init__(self, language=None): """Initializes our object, if a language is specified, then we initialize ourselves to that, otherwise we use whatever we detect from the local operating system. If all else fails, we resort to the defined default_language. """ # Cache previously loaded translations self._gtobjs = {} # Get our language self.lang = AppriseLocale.detect_language(language) # Our mapping to our _fn self.__fn_map = None if GETTEXT_LOADED is False: # We're done return # Add language self.add(self.lang) def add(self, lang=None, set_default=True): """Add a language to our list.""" lang = lang if lang else self._default_language if lang not in self._gtobjs: # Load our gettext object and install our language try: self._gtobjs[lang] = gettext.translation( self._domain, localedir=self._locale_dir, languages=[lang], fallback=False, ) # The non-intrusive method of applying the gettext change to # the global namespace only self.__fn_map = getattr(self._gtobjs[lang], self._fn) except FileNotFoundError: # The translation directory does not exist logger.debug( "Could not load translation path: %s", join(self._locale_dir, lang), ) # Fallback (handle case where self.lang does not exist) if self.lang not in self._gtobjs: self._gtobjs[self.lang] = gettext self.__fn_map = getattr(self._gtobjs[self.lang], self._fn) return False logger.trace("Loaded language %s", lang) if set_default: logger.debug("Language set to %s", lang) self.lang = lang return True @contextlib.contextmanager def lang_at(self, lang, mapto=_fn): """ The syntax works as: with at.lang_at('fr'): # apprise works as though the french language has been # defined. afterwards, the language falls back to whatever # it was. """ if GETTEXT_LOADED is False: # Do nothing yield None # we're done return # Tidy the language lang = AppriseLocale.detect_language(lang, detect_fallback=False) if lang not in self._gtobjs and not self.add(lang, set_default=False): # Do Nothing yield getattr(self._gtobjs[self.lang], mapto) else: # Yield yield getattr(self._gtobjs[lang], mapto) return @property def gettext(self): """Return the current language gettext() function. Useful for assigning to `_` """ return self._gtobjs[self.lang].gettext @staticmethod def detect_language(lang=None, detect_fallback=True): """Returns the language (if it's retrievable)""" # We want to only use the 2 character version of this language # hence en_CA becomes en, en_US becomes en. if not isinstance(lang, str): if detect_fallback is False: # no detection enabled; we're done return None # Posix lookup lookup = os.environ.get localename = None for variable in ("LC_ALL", "LC_CTYPE", "LANG", "LANGUAGE"): localename = lookup(variable, None) if localename: result = AppriseLocale._local_re.match(localename) if result and result.group("lang"): return result.group("lang").lower() # Windows handling if hasattr(ctypes, "windll"): windll = ctypes.windll.kernel32 try: lang = locale.windows_locale[ windll.GetUserDefaultUILanguage() ] # Our detected windows language return lang[0:2].lower() except (TypeError, KeyError): # Fallback to posix detection pass # Built in locale library check try: # Acquire our locale lang = locale.getlocale()[0] # Compatibility for Python >= 3.12 if lang == "C": lang = AppriseLocale._default_language except (ValueError, TypeError) as e: # This occurs when an invalid locale was parsed from the # environment variable. While we still return None in this # case, we want to better notify the end user of this. Users # receiving this error should check their environment # variables. logger.warning(f"Language detection failure / {e!s}") return None return None if not lang else lang[0:2].lower() def __getstate__(self): """Pickle Support dumps()""" state = self.__dict__.copy() # Remove the unpicklable entries. del state["_gtobjs"] del state["_AppriseLocale__fn_map"] return state def __setstate__(self, state): """Pickle Support loads()""" self.__dict__.update(state) # Our mapping to our _fn self.__fn_map = None self._gtobjs = {} self.add(state["lang"], set_default=True) # # Prepare our default LOCALE Singleton # LOCALE = AppriseLocale() class LazyTranslation: """Doesn't translate anything until str() or unicode() references are made.""" def __init__(self, text, *args, **kwargs): """Store our text.""" self.text = text super().__init__(*args, **kwargs) def __str__(self): return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text # Lazy translation handling def gettext_lazy(text): """A dummy function that can be referenced.""" return LazyTranslation(text=text) # Identify our Translatable content Translatable = Union[str, LazyTranslation] caronc-apprise-182f859/apprise/logger.py000066400000000000000000000150731524161175200202160ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib from io import StringIO import logging import os # The root identifier needed to monitor 'apprise' logging LOGGER_NAME = "apprise" # Define a verbosity level that is a noisier then debug mode logging.TRACE = logging.DEBUG - 1 # Define a verbosity level that is always used even when no verbosity is set # from the command line. The idea here is to allow for deprecation notices logging.DEPRECATE = logging.ERROR + 1 # Assign our Levels into our logging object logging.addLevelName(logging.DEPRECATE, "DEPRECATION WARNING") logging.addLevelName(logging.TRACE, "TRACE") def trace(self, message, *args, **kwargs): """ Verbose Debug Logging - Trace """ if self.isEnabledFor(logging.TRACE): self._log(logging.TRACE, message, args, **kwargs) def deprecate(self, message, *args, **kwargs): """Deprication Warning Logging.""" if self.isEnabledFor(logging.DEPRECATE): self._log(logging.DEPRECATE, message, args, **kwargs) # Assign our Loggers for use in Apprise logging.Logger.trace = trace logging.Logger.deprecate = deprecate # Create ourselve a generic (singleton) logging reference logger = logging.getLogger(LOGGER_NAME) class LogCapture: """A class used to allow one to instantiate loggers that write to memory for temporary purposes. e.g.: 1. with LogCapture() as captured: 2. 3. # Send our notification(s) 4. aobj.notify("hello world") 5. 6. # retrieve our logs produced by the above call via our 7. # `captured` StringIO object we have access to within the `with` 8. # block here: 9. print(captured.getvalue()) """ def __init__( self, path=None, level=None, name=LOGGER_NAME, delete=True, fmt="%(asctime)s - %(levelname)s - %(message)s", ): """Instantiate a temporary log capture object. If a path is specified, then log content is sent to that file instead of a StringIO object. You can optionally specify a logging level such as logging.INFO if you wish, otherwise by default the script uses whatever logging has been set globally. If you set delete to `False` then when using log files, they are not automatically cleaned up afterwards. Optionally over-ride the fmt as well if you wish. """ # Our memory buffer placeholder self.__buffer_ptr = StringIO() # Store our file path as it will determine whether or not we write to # memory and a file self.__path = path self.__delete = delete # Our logging level tracking self.__level = level self.__restore_level = None # Acquire a pointer to our logger self.__logger = logging.getLogger(name) # Prepare our handler self.__handler = ( logging.StreamHandler(self.__buffer_ptr) if not self.__path else logging.FileHandler(self.__path, mode="a", encoding="utf-8") ) # Use the specified level, otherwise take on the already # effective level of our logger self.__handler.setLevel( self.__level if self.__level is not None else self.__logger.getEffectiveLevel() ) # Prepare our formatter self.__handler.setFormatter(logging.Formatter(fmt)) def __enter__(self): """Allows logger manipulation within a 'with' block.""" if self.__level is not None: # Temporary adjust our log level if required self.__restore_level = self.__logger.getEffectiveLevel() if self.__restore_level > self.__level: # Bump our log level up for the duration of our `with` self.__logger.setLevel(self.__level) else: # No restoration required self.__restore_level = None else: # Do nothing but enforce that we have nothing to restore to self.__restore_level = None if self.__path: # If a path has been identified, ensure we can write to the path # and that the file exists with open(self.__path, "a"): os.utime(self.__path, None) # Update our buffer pointer self.__buffer_ptr = open(self.__path) # Add our handler self.__logger.addHandler(self.__handler) # return our memory pointer return self.__buffer_ptr def __exit__(self, exc_type, exc_value, tb): """Removes the handler gracefully when the with block has completed.""" # Flush our content self.__handler.flush() self.__buffer_ptr.flush() # Drop our handler self.__logger.removeHandler(self.__handler) if self.__restore_level is not None: # Restore level self.__logger.setLevel(self.__restore_level) if self.__path: # Close our file pointer self.__buffer_ptr.close() self.__handler.close() if self.__delete: with contextlib.suppress(OSError): # Always remove file afterwards os.unlink(self.__path) return exc_type is None caronc-apprise-182f859/apprise/manager.py000066400000000000000000001040571524161175200203520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import hashlib import inspect import os from os.path import abspath, dirname, join import re import sys import threading import time from .logger import logger from .utils.disk import path_decode from .utils.module import import_module from .utils.parse import parse_list from .utils.singleton import Singleton class PluginManager(metaclass=Singleton): """Designed to be a singleton object to maintain all initialized loading of modules in memory.""" # Description (used for logging) name = "Singleton Plugin" # Memory Space _id = "undefined" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result when scanning a module module_filter_re = re.compile(r"^(?P((?!_)[A-Za-z0-9]+))$") # thread safe loading _lock = threading.Lock() def __init__(self, *args, **kwargs): """Over-ride our class instantiation to provide a singleton.""" self._module_map = None self._schema_map = None # This contains a mapping of all plugins dynamicaly loaded at runtime # from external modules such as the @notify decorator # # The elements here will be additionally added to the _schema_map if # there is no conflict otherwise. # The structure looks like the following: # Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py # { # 'path': path, # # 'notify': { # 'schema': { # 'name': 'Custom schema name', # 'fn_name': 'name_of_function_decorator_was_found_on', # 'url': 'schema://any/additional/info/found/on/url' # 'plugin': # }, # 'schema2': { # 'name': 'Custom schema name', # 'fn_name': 'name_of_function_decorator_was_found_on', # 'url': 'schema://any/additional/info/found/on/url' # 'plugin': # } # } # Note: that the inherits from # NotifyBase self._custom_module_map = {} # Track manually disabled modules (by their schema) self._disabled = set() # Reference counter for optional runtime libraries declared via # runtime_deps(). Maps top-level package name -> count of currently # *enabled* plugins that depend on it. When a plugin is disabled and # its counter reaches zero the library *may* be evicted from # sys.modules - but only when evict_on_disable is True. self._dep_counter = {} # Controls whether libraries are evicted from sys.modules when their # dep counter reaches zero. Defaults to False so that third-party # projects embedding Apprise are not surprised by modules disappearing. # The Apprise API sets this to True at startup to reclaim memory from # optional libraries whose plugins are all disabled. self.evict_on_disable = False # Hash of all paths previously scanned so we don't waste # effort/overhead doing it again self._paths_previously_scanned = set() # Track loaded module paths to prevent from loading them again self._loaded = set() def unload_modules(self, disable_native=False): """Reset our object and unload all modules.""" with self._lock: if self._custom_module_map: # Handle Custom Module Assignments for meta in self._custom_module_map.values(): if meta["name"] not in self._module_map: # Nothing to remove continue # For the purpose of tidying up un-used modules in memory loaded = [ m for m in sys.modules if m.startswith(self._module_map[meta["name"]]["path"]) ] for module_path in loaded: del sys.modules[module_path] # Reset disabled plugins (if any) for schema in self._disabled: self._schema_map[schema].enabled = True self._disabled.clear() # Reset the library dependency counter (evict_on_disable is an # intentional configuration choice and is NOT reset here) self._dep_counter = {} # Reset our variables self._schema_map = {} self._custom_module_map = {} if disable_native: self._module_map = {} else: self._module_map = None self._loaded = set() # Reset our path cache self._paths_previously_scanned = set() def load_modules(self, path=None, name=None, force=False): """Load our modules into memory.""" # Default value module_name_prefix = self.module_name_prefix if name is None else name module_path = self.module_path if path is None else path with self._lock: if not force and module_path in self._loaded: # We're done return # Our base reference module_count = len(self._module_map) if self._module_map else 0 schema_count = len(self._schema_map) if self._schema_map else 0 if not self: # Initialize our maps self._module_map = {} self._schema_map = {} self._custom_module_map = {} # Used for the detection of additional Notify Services objects # The .py extension is optional as we support loading directories # too module_re = re.compile( r"^(?P(?!base|_)[a-z0-9_]+)(\.py)?$", re.I ) t_start = time.time() for f in os.listdir(module_path): tl_start = time.time() match = module_re.match(f) if not match: # keep going continue # Store our notification/plugin name: module_name = match.group("name") module_pyname = f"{module_name_prefix}.{module_name}" if module_name in self._module_map: logger.warning( "%s(s) (%s) already loaded; ignoring %s", self.name, module_name, os.path.join(module_path, f), ) continue try: module = __import__( module_pyname, globals(), locals(), fromlist=[module_name], ) except ImportError: # No problem, we can try again another way... module = import_module( os.path.join(module_path, f), module_pyname ) if not module: # logging found in import_module and not needed here continue module_class = None for m_class in [ obj for obj in dir(module) if self.module_filter_re.match(obj) ]: # Get our plugin plugin = getattr(module, m_class) if not hasattr(plugin, "app_id"): # Filter out non-notification modules logger.trace( "(%s.%s) import failed; no app_id defined in %s", self.name, m_class, os.path.join(module_path, f), ) continue # Add our plugin name to our module map self._module_map[module_name] = { "plugin": {plugin}, "module": module, "path": f"{module_name_prefix}.{module_name}", "native": True, } fn = getattr(plugin, "schemas", None) schemas = set() if not callable(fn) else fn(plugin) # map our schema to our plugin for schema in schemas: if schema in self._schema_map: logger.error( f"{self.name} schema ({schema}) mismatch" " detected -" f" {self._schema_map[schema]} already maps to" f" {plugin}" ) continue # Assign plugin self._schema_map[schema] = plugin # Store our class module_class = m_class break if not module_class: # Not a library we can load as it doesn't follow the simple # rule that the class must bear the same name as the # notification file itself. logger.trace( "%s (%s) import failed; no filename/Class " "match found in %s", self.name, module_name, os.path.join(module_path, f), ) continue logger.trace( f"{self.name} {module_name} loaded" f" in {time.time() - tl_start:.6f}s" ) # Track the directory loaded so we never load it again self._loaded.add(module_path) logger.debug( f"{len(self._module_map) - module_count} {self.name}(s) and" f" {len(self._schema_map) - schema_count} Schema(s) loaded in" f" {time.time() - t_start:.4f}s" ) # Build the runtime dependency reference counter so that # disable() can evict libraries when their last user is # disabled. This is done here (inside the lock) by iterating # _module_map directly to avoid a recursive lock acquisition. self._build_dep_counter() def module_detection(self, paths, cache=True): """Leverage the @notify decorator and load all objects found matching this.""" # A simple restriction that we don't allow periods in the filename at # all so it can't be hidden (Linux OS's) and it won't conflict with # Python path naming. This also prevents us from loading any python # file that starts with an underscore or dash # We allow for __init__.py as well module_re = re.compile( r"^(?P[_a-z0-9][a-z0-9._-]+)?(\.py)?$", re.I ) # Validate if we're a loadable Python file or not valid_python_file_re = re.compile(r".+\.py(o|c)?$", re.IGNORECASE) if isinstance(paths, str): paths = [ paths, ] if not paths or not isinstance(paths, (tuple, list)): # We're done return def _import_module(path): # Since our plugin name can conflict (as a module) with another # we want to generate random strings to avoid steping on # another's namespace if not (path and valid_python_file_re.match(path)): # Ignore file/module type logger.trace("Plugin Scan: Skipping %s", path) return t_start = time.time() module_name = hashlib.sha1(path.encode("utf-8")).hexdigest() module_pyname = "{prefix}.{name}".format( prefix="apprise.custom.module", name=module_name ) if module_pyname in self._custom_module_map: # First clear out existing entries for schema in self._custom_module_map[module_pyname]["notify"]: # Remove any mapped modules to this file del self._schema_map[schema] # Reset del self._custom_module_map[module_pyname] # Load our module module = import_module(path, module_pyname) if not module: # No problem, we can't use this object logger.warning("Failed to load custom module: %s", path_) return # Print our loaded modules if any if module_pyname in self._custom_module_map: logger.debug( "Custom module %s - %d schema(s)" " (name=%s) loaded in %.6fs", path_, len(self._custom_module_map[module_pyname]["notify"]), module_name, (time.time() - t_start), ) # Add our plugin name to our module map self._module_map[module_name] = { "plugin": set(), "module": module, "path": module_pyname, "native": False, } for schema, _meta in self._custom_module_map[module_pyname][ "notify" ].items(): # For mapping purposes; map our element in our main list self._module_map[module_name]["plugin"].add( self._schema_map[schema] ) # Log our success logger.info("Loaded custom notification: %s://", schema) else: # The code reaches here if we successfully loaded the Python # module but no hooks/triggers were found. So we can safely # just remove/ignore this entry del sys.modules[module_pyname] return # end of _import_module() return for path_ in paths: path = path_decode(path_) if ( cache and path in self._paths_previously_scanned ) or not os.path.exists(path): # We're done as we've already scanned this continue # Store our path as a way of hashing it has been handled self._paths_previously_scanned.add(path) if os.path.isdir(path) and not os.path.isfile( os.path.join(path, "__init__.py") ): logger.debug("Scanning for custom plugins in: %s", path) for entry in os.listdir(path): re_match = module_re.match(entry) if not re_match: # keep going logger.trace("Plugin Scan: Ignoring %s", entry) continue new_path = os.path.join(path, entry) if os.path.isdir(new_path): # Update our path new_path = os.path.join(path, entry, "__init__.py") if not os.path.isfile(new_path): logger.trace( "Plugin Scan: Ignoring %s", os.path.join(path, entry), ) continue if not cache or ( new_path not in self._paths_previously_scanned ): # Load our module _import_module(new_path) # Add our subdir path self._paths_previously_scanned.add(new_path) else: if os.path.isdir(path): # This logic is safe to apply because we already # validated the directories state above; update our # path path = os.path.join(path, "__init__.py") if cache and path in self._paths_previously_scanned: continue self._paths_previously_scanned.add(path) # directly load as is re_match = module_re.match(os.path.basename(path)) # must be a match and must have a .py extension if not re_match or not re_match.group(1): # keep going logger.trace("Plugin Scan: Ignoring %s", path) continue # Load our module _import_module(path) return None def add(self, plugin, schemas=None, url=None, send_func=None, force=False): """Ability to manually add Notification services to our stack.""" if not self: # Lazy load self.load_modules() # Acquire a list of schemas p_schemas = parse_list(plugin.secure_protocol, plugin.protocol) if isinstance(schemas, str): schemas = [ schemas, ] elif schemas is None: # Default schemas = p_schemas if not schemas or not isinstance(schemas, (set, tuple, list)): # We're done logger.error( "The schemas provided (type %s) is" " unsupported; loaded from %s.", type(schemas), send_func.__name__ if send_func else plugin.__class__.__name__, ) return False # Convert our schemas into a set schemas = {s.lower() for s in schemas} | set(p_schemas) # Valdation conflict = [s for s in schemas if s in self] if conflict: if force: # Force implies that we unmap any conflicting schema entries # at the Apprise level, but we do not unload any previously # imported modules. This ensures other classes can safely # subclass from prior notify classes. logger.debug( "The schema(s) (%s) are already defined and will be " "force loaded; overriding %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) self.remove(*conflict, unload=False) else: logger.warning( "The schema(s) (%s) are already defined and could not be " "loaded from %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) return False # Re-check for conflicts after unmapping conflict = [s for s in schemas if s in self] if conflict: logger.warning( "The schema(s) (%s) are already defined and could not be " "loaded from %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) return False if send_func: # Acquire the function name fn_name = send_func.__name__ # Acquire the python filename path path = inspect.getfile(send_func) # Acquire our path to our module module_name = str(send_func.__module__) if module_name not in self._custom_module_map: # Support non-dynamic includes as well... self._custom_module_map[module_name] = { # Name can be useful for indexing back into the # _module_map object; this is the key to do it with: "name": module_name.split(".")[-1], # The path to the module loaded "path": path, # Initialize our template "notify": {}, } for schema in schemas: self._custom_module_map[module_name]["notify"][schema] = { # The name of the send function the @notify decorator # wrapped "fn_name": fn_name, # The URL that was provided in the @notify decorator call # associated with the 'on=' "url": url, } else: module_name = hashlib.sha1( "".join(schemas).encode("utf-8") ).hexdigest() module_pyname = "{prefix}.{name}".format( prefix="apprise.adhoc.module", name=module_name ) # Add our plugin name to our module map self._module_map[module_name] = { "plugin": {plugin}, "module": None, "path": module_pyname, "native": False, } for schema in schemas: # Assign our mapping self._schema_map[schema] = plugin return True def remove(self, *schemas, unload=True): """Removes a loaded element (if defined)""" if not self: # Lazy load self.load_modules() for schema in schemas: with contextlib.suppress(KeyError): self._unmap_schema(schema, unload=unload) def plugins(self, include_disabled=True): """Return all of our loaded plugins.""" if not self: # Lazy load self.load_modules() for module in self._module_map.values(): for plugin in module["plugin"]: if not include_disabled and not plugin.enabled: continue yield plugin def schemas(self, include_disabled=True): """Return all of our loaded schemas. if include_disabled == True, then even disabled notifications are returned """ if not self: # Lazy load self.load_modules() # Return our list return ( list(self._schema_map.keys()) if include_disabled else [s for s in self._schema_map if self._schema_map[s].enabled] ) def _build_dep_counter(self): """Build the runtime library reference counter from loaded plugins. Iterates `_module_map` directly (rather than calling `self.plugins()`) so it is safe to call while `_lock` is held inside `load_modules`. Only enabled plugins contribute to the counter - plugins already disabled because their optional library is not installed are not counted, as there is nothing in memory to evict for them. """ self._dep_counter = {} if not self._module_map: return for module in self._module_map.values(): for plugin in module["plugin"]: # Guard: attachment and other non-notify plugin types do not # carry `enabled` or `runtime_deps`; skip them safely. if not getattr(plugin, "enabled", False): continue fn = getattr(plugin, "runtime_deps", None) if not callable(fn): continue for lib in fn(): self._dep_counter[lib] = self._dep_counter.get(lib, 0) + 1 def _evict_library(self, lib_name): """Remove a library and all its submodules from `sys.modules`. Called when `evict_on_disable` is `True` and the reference counter for *lib_name* reaches zero - meaning no enabled plugin requires it. """ to_remove = [ k for k in sys.modules if k == lib_name or k.startswith(lib_name + ".") ] evicted = 0 for key in to_remove: try: del sys.modules[key] evicted += 1 except KeyError: logger.trace("Eviction skipped: '%s' not in sys.modules", key) if evicted: logger.debug( "Evicted %d module(s) for '%s' from memory", evicted, lib_name, ) elif to_remove: # Keys were found but all raised KeyError (race condition) logger.trace( "Eviction of '%s' found %d candidate(s) but removed none", lib_name, len(to_remove), ) def _update_dep_counter(self, plugin, delta): """Increment or decrement dep counters for *plugin* by *delta* (+1/-1). Evicts libraries from memory when `evict_on_disable` is `True` and the counter reaches zero. """ fn = getattr(plugin, "runtime_deps", None) if not callable(fn): return for lib in fn(): count = self._dep_counter.get(lib, 0) + delta self._dep_counter[lib] = max(0, count) logger.trace( "Dep counter '%s': %d -> %d", lib, count - delta, self._dep_counter[lib], ) if self.evict_on_disable and count <= 0: self._evict_library(lib) def disable(self, *schemas): """Disables the modules associated with the specified schemas.""" if not self: # Lazy load self.load_modules() for schema in schemas: if schema not in self._schema_map: continue plugin = self._schema_map[schema] if not plugin.enabled: continue # Disable via the plugin classmethod so subclasses can hook in plugin.disable() self._disabled.add(schema) logger.debug("Disabled %s plugin (%s://)", self.name, schema) # Decrement dep counters; evict if evict_on_disable and zero self._update_dep_counter(plugin, -1) def enable_only(self, *schemas): """Disables the modules associated with the specified schemas.""" if not self: # Lazy load self.load_modules() # convert to set for faster indexing schemas = set(schemas) for plugin in self.plugins(): # Get our plugin's schema list p_schemas = set( parse_list(plugin.secure_protocol, plugin.protocol) ) if not schemas & p_schemas: if plugin.enabled: # Disable it (only if previously enabled); this prevents us # from adjusting schemas that were disabled due to missing # libraries or other environment reasons plugin.disable() self._disabled |= p_schemas logger.debug( "Disabled %s plugin (%s)", self.name, ", ".join(f"{s}://" for s in p_schemas), ) # Decrement dep counters; evict at zero if evict_on_disable self._update_dep_counter(plugin, -1) continue # If we reach here, our schema was flagged to be enabled if p_schemas & self._disabled: # Previously disabled; no worries, let's clear this up self._disabled -= p_schemas plugin.enable() logger.debug( "Enabled %s plugin (%s)", self.name, ", ".join(f"{s}://" for s in p_schemas), ) # Increment dep counters for the re-enabled plugin self._update_dep_counter(plugin, +1) def __contains__(self, schema): """Checks if a schema exists.""" if not self: # Lazy load self.load_modules() return schema in self._schema_map def __delitem__(self, schema): """ removes schema map and also unloads it from memory """ self._unmap_schema(schema, unload=True) def __setitem__(self, schema, plugin): """Support fast assigning of Plugin/Notification Objects.""" if not self: # Lazy load self.load_modules() # Set default values if not otherwise set if not plugin.service_name: # Assign service name if one doesn't exist plugin.service_name = f"{schema}://" p_schemas = set(parse_list(plugin.secure_protocol, plugin.protocol)) if not p_schemas: # Assign our protocol plugin.secure_protocol = schema p_schemas.add(schema) elif schema not in p_schemas: # Add our others (if defined) plugin.secure_protocol = { schema, *parse_list(plugin.secure_protocol), } p_schemas.add(schema) if not self.add(plugin, schemas=p_schemas): raise KeyError("Conflicting Assignment") def _unmap_schema(self, schema, *, unload=True): """Unmap a schema entry without necessarily unloading modules. This function removes the schema mapping and updates internal cross references. When unload is True (default), modules are removed from sys.modules when they are no longer referenced by Apprise. When unload is False, the unmapping is performed but any imported modules remain intact in sys.modules. """ if not self: # Lazy load self.load_modules() # Get our plugin (otherwise we throw a KeyError) which is intended on # unmap action that doesn't align. plugin = self._schema_map[schema] # Our list of all schema entries p_schemas = {schema} for key in list(self._module_map.keys()): if plugin in self._module_map[key]["plugin"]: # Remove our plugin self._module_map[key]["plugin"].remove(plugin) # Custom Plugin Entry; Clean up cross reference module_pyname = self._module_map[key]["path"] if ( not self._module_map[key]["native"] and module_pyname in self._custom_module_map ): notify = self._custom_module_map[module_pyname]["notify"] del notify[schema] if not self._custom_module_map[module_pyname]["notify"]: # # Last custom loaded element # # Free up custom object entry del self._custom_module_map[module_pyname] if not self._module_map[key]["plugin"]: # # Last element # if self._module_map[key]["native"]: # Get our plugin's schema list p_schemas = { s for s in parse_list( plugin.secure_protocol, plugin.protocol ) if s in self._schema_map } # Free system memory only when unload=True if unload and self._module_map[key]["module"]: with contextlib.suppress(KeyError): del sys.modules[self._module_map[key]["path"]] # Free last remaining pointer in module map del self._module_map[key] for schema in p_schemas: # Final tidy del self._schema_map[schema] def __getitem__(self, schema): """Returns the indexed plugin identified by the schema specified.""" if not self: # Lazy load self.load_modules() return self._schema_map[schema] def __iter__(self): """Returns an iterator so we can iterate over our loaded modules.""" if not self: # Lazy load self.load_modules() return iter(self._module_map.values()) def __len__(self): """Returns the number of modules/plugins loaded.""" if not self: # Lazy load self.load_modules() return len(self._module_map) def __bool__(self): """Determines if object has loaded or not.""" return bool(self._loaded and self._module_map is not None) caronc-apprise-182f859/apprise/manager_attachment.py000066400000000000000000000041261524161175200225560ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class AttachmentManager(PluginManager): """Designed to be a singleton object to maintain all initialized attachment plugins/modules in memory.""" # Description (used for logging) name = "Attachment Plugin" # Filename Prefix to filter on fname_prefix = "Attach" # Memory Space _id = "attachment" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base)[A-Za-z0-9]+)$" ) caronc-apprise-182f859/apprise/manager_config.py000066400000000000000000000041421524161175200216710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class ConfigurationManager(PluginManager): """Designed to be a singleton object to maintain all initialized configuration plugins/modules in memory.""" # Description (used for logging) name = "Configuration Plugin" # Filename Prefix to filter on fname_prefix = "Config" # Memory Space _id = "config" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base|Format)[A-Za-z0-9]+)$" ) caronc-apprise-182f859/apprise/manager_plugins.py000066400000000000000000000041601524161175200221050ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class NotificationManager(PluginManager): """Designed to be a singleton object to maintain all initialized notifications in memory.""" # Description (used for logging) name = "Notification Plugin" # Filename Prefix to filter on fname_prefix = "Notify" # Memory Space _id = "plugins" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base|Format|ImageSize|Type)[A-Za-z0-9]+)$" ) caronc-apprise-182f859/apprise/persistent_store.py000066400000000000000000001670521524161175200223600ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import binascii import builtins import contextlib from datetime import datetime, timedelta, timezone import glob import gzip import hashlib from itertools import chain import json import os import re import tempfile import time from typing import Any, Optional, Union import zlib from . import exception from .common import ( AWARE_DATE_ISO_FORMAT, NAIVE_DATE_ISO_FORMAT, PersistentStoreMode, ) from .logger import logger from .utils.disk import path_decode # Used for writing/reading time stored in cache file EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) def _ntf_tidy(ntf): """Reusable NamedTemporaryFile Cleanup.""" if ntf: # Cleanup with contextlib.suppress(OSError): ntf.close() try: os.unlink(ntf.name) logger.trace("Persistent temporary file removed: %s", ntf.name) except (FileNotFoundError, AttributeError): # AttributeError: something weird was passed in, no action required # FileNotFound: no worries; we were removing it anyway pass except OSError as e: logger.error( "Persistent temporary file removal failed: %s", ntf.name ) logger.debug("Persistent Storage Exception: %s", str(e)) class CacheObject: hash_engine = hashlib.sha256 hash_length = 6 def __init__( self, value: Any = None, expires: Union[bool, float, int, datetime, None] = False, persistent: bool = True, ) -> None: """Tracks our objects and associates a time limit with them.""" self.__value = value self.__class_name = value.__class__.__name__ self.__expires = None if expires: self.set_expiry(expires) # Whether or not we persist this object to disk or not self.__persistent = bool(persistent) def set( self, value: Any, expires: Union[bool, float, int, datetime, None] = None, persistent: Optional[bool] = None, ) -> None: """Sets fields on demand, if set to none, then they are left as is. The intent of set is that it allows you to set a new a value and optionally alter meta information against it. If expires or persistent isn't specified then their previous values are used. """ self.__value = value self.__class_name = value.__class__.__name__ if expires is not None: self.set_expiry(expires) if persistent is not None: self.__persistent = bool(persistent) def set_expiry( self, expires: Union[datetime, bool, float, int, None] = None ) -> None: """Sets a new expiry.""" if isinstance(expires, datetime): self.__expires = expires.astimezone(timezone.utc) elif expires in (None, False): # Accepted - no expiry self.__expires = None elif expires is True: # Force expiry to now self.__expires = datetime.now(tz=timezone.utc) elif isinstance(expires, (float, int)): self.__expires = datetime.now(tz=timezone.utc) + timedelta( seconds=expires ) else: # Unsupported raise AttributeError( f"An invalid expiry time ({expires} was specified" ) def hash(self) -> str: """Our checksum to track the validity of our data.""" return self.hash_engine( str(self).encode("utf-8"), usedforsecurity=False ).hexdigest() def json(self) -> Optional[dict[str, Any]]: """Returns our preparable json object.""" return { "v": self.__value, "x": ( (self.__expires - EPOCH).total_seconds() if self.__expires else None ), "c": ( self.__class_name if not isinstance(self.__value, datetime) else ( "aware_datetime" if self.__value.tzinfo else "naive_datetime" ) ), "!": self.hash()[: self.hash_length], } @staticmethod def instantiate( content: dict[str, Any], persistent: bool = True, verify: bool = True, ) -> Optional["CacheObject"]: """Loads back data read in and returns a CacheObject or None if it could not be loaded. You can pass in the contents of CacheObject.json() and you'll receive a copy assuming the hash checks okay """ try: value = content["v"] expires = content["x"] if expires is not None: expires = datetime.fromtimestamp(expires, timezone.utc) # Acquire some useful integrity objects class_name = content.get("c", "") if not isinstance(class_name, str): raise TypeError("Class name not expected string") hashsum = content.get("!", "") if not isinstance(hashsum, str): raise TypeError("SHA1SUM not expected string") except (TypeError, KeyError) as e: logger.trace(f"CacheObject could not be parsed from {content}") logger.trace("CacheObject exception: %s", str(e)) return None if class_name in ("aware_datetime", "naive_datetime", "datetime"): # If datetime is detected, it will fall under the naive category iso_format = ( AWARE_DATE_ISO_FORMAT if class_name[0] == "a" else NAIVE_DATE_ISO_FORMAT ) try: # Python v3.6 Support value = datetime.strptime(value, iso_format) except (TypeError, ValueError): # TypeError is thrown if content is not string # ValueError is thrown if the string is not a valid format logger.trace( f"CacheObject (dt) corrupted loading from {content}" ) return None elif class_name == "bytes": try: # Convert our object back to a bytes value = base64.b64decode(value) except binascii.Error: logger.trace( f"CacheObject (bin) corrupted loading from {content}" ) return None # Initialize our object co = CacheObject(value, expires, persistent=persistent) if verify and co.hash()[: co.hash_length] != hashsum: # Our object was tampered with logger.debug(f"Tampering detected with cache entry {co}") del co return None return co @property def value(self) -> Any: """Returns our value.""" return self.__value @property def persistent(self) -> bool: """Returns our persistent value.""" return self.__persistent @property def expires(self) -> Optional[datetime]: """Returns the datetime the object will expire.""" return self.__expires @property def expires_sec(self) -> Optional[float]: """Returns the number of seconds from now the object will expire.""" return ( None if self.__expires is None else max( 0.0, ( self.__expires - datetime.now(tz=timezone.utc) ).total_seconds(), ) ) def __bool__(self) -> bool: """Returns True it the object hasn't expired, and False if it has.""" if self.__expires is None: # No Expiry return True # Calculate if we've expired or not return self.__expires > datetime.now(tz=timezone.utc) def __eq__(self, other) -> bool: """Handles equality == flag.""" if isinstance(other, CacheObject): return str(self) == str(other) return self.__value == other def __str__(self) -> str: """String output of our data.""" persistent = "+" if self.persistent else "-" return f"{self.__class_name}:{persistent}:{self.__value} expires: " + ( "never" if self.__expires is None else self.__expires.strftime(NAIVE_DATE_ISO_FORMAT) ) class CacheJSONEncoder(json.JSONEncoder): """A JSON Encoder for handling each of our cache objects.""" def default(self, entry): if isinstance(entry, datetime): return entry.strftime( AWARE_DATE_ISO_FORMAT if entry.tzinfo is not None else NAIVE_DATE_ISO_FORMAT ) elif isinstance(entry, CacheObject): return entry.json() elif isinstance(entry, bytes): return base64.b64encode(entry).decode("utf-8") return super().default(entry) class PersistentStore: """An object to make working with persistent storage easier. read() and write() are used for direct file i/o set(), get() are used for caching """ # The maximum file-size we will allow the persistent store to grow to # 1 MB = 1048576 bytes max_file_size = 1048576 # 30 days in seconds default_file_expiry = 2678400 # File encoding to use encoding = "utf-8" # Default data set base_key = "default" # Directory to store cache __cache_key = "cache" # Our Temporary working directory temp_dir = "tmp" # The directory our persistent store content gets placed in data_dir = "var" # Our Persistent Store File Extension __extension = ".psdata" # Identify our backup file extension __backup_extension = "._psbak" # Used to verify the key specified is valid # - must start with an alpha_numeric # - following optional characters can include period, underscore and # equal __valid_key = re.compile(r"[a-z0-9][a-z0-9._-]*", re.I) # Reference only __not_found_ref = (None, None) def __init__( self, path: Optional[str] = None, namespace: str = "default", mode: Optional[Union[str, PersistentStoreMode]] = None, ) -> None: """Provide the namespace to work within. namespaces can only contain alpha-numeric characters with the exception of '-' (dash), '_' (underscore), and '.' (period). The namespace must be be relative to the current URL being controlled. """ # Initalize our mode so __del__() calls don't go bad on the # error checking below self.__mode = None # Populated only once and after size() is called self.__exclude_list = None # Files to renew on calls to flush self.__renew = set() if not isinstance(namespace, str) or not self.__valid_key.match( namespace ): raise AttributeError( f"Persistent Storage namespace ({namespace}) provided is" " invalid" ) if isinstance(path, str): # A storage path has been defined if mode is None: # Store Default if no mode was provided along side of it mode = PersistentStoreMode.AUTO # Store our information self.__base_path = os.path.join(path_decode(path), namespace) self.__temp_path = os.path.join(self.__base_path, self.temp_dir) self.__data_path = os.path.join(self.__base_path, self.data_dir) else: # If no storage path is provide we set our mode to MEMORY mode = PersistentStoreMode.MEMORY self.__base_path = None self.__temp_path = None self.__data_path = None # Tracks when we have content to flush self.__dirty = False # A caching value to track persistent storage disk size self.__cache_size = None self.__cache_files = {} # Internal Cache self._cache = None try: # Store our mode self.__mode = ( mode if isinstance(mode, PersistentStoreMode) else PersistentStoreMode(mode.lower()) ) except (AttributeError, ValueError): err = ( f"An invalid persistent storage mode ({mode}) was specified.", ) logger.warning(err) raise AttributeError(err) from None # Prepare our environment self.__prepare() def read( self, key: Optional[str] = None, compress: bool = True, expires: Union[bool, float, int] = False, ) -> Optional[bytes]: """Returns the content of the persistent store object. if refresh is set to True, then the file's modify time is updated preventing it from getting caught in prune calls. It's a means of allowing it to persist and not get cleaned up in later prune calls. Content is always returned as a byte object """ try: with self.open(key, mode="rb", compress=compress) as fd: results = fd.read(self.max_file_size) if expires is False: self.__renew.add( os.path.join( self.__data_path, f"{key}{self.__extension}" ) ) return results except (FileNotFoundError, exception.AppriseDiskIOError): # FileNotFoundError: No problem # exception.AppriseDiskIOError: # - Logging of error already occurred inside self.open() pass except (OSError, zlib.error, EOFError, UnicodeDecodeError) as e: # We can't access the file or it does not exist logger.warning("Could not read with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) # return none return None def write( self, data: Union[bytes, str, Any], key: Optional[str] = None, compress: bool = True, _recovery: bool = False, ) -> bool: """Writes the content to the persistent store if it doesn't exceed our filesize limit. Content is always written as a byte object _recovery is reserved for internal usage and should not be changed """ if key is None: key = self.base_key elif not isinstance(key, str) or not self.__valid_key.match(key): raise AttributeError( f"Persistent Storage key ({key} provided is invalid" ) if not isinstance(data, (bytes, str)): # One last check, we will accept read() objets with the expectation # it will return a binary dataset if not (hasattr(data, "read") and callable(data.read)): raise AttributeError( f"Invalid data type {type(data)} provided to Persistent" " Storage" ) try: # Read in our data data = data.read() if not isinstance(data, (bytes, str)): raise AttributeError( f"Invalid data type {type(data)} provided to" " Persistent Storage" ) except Exception as e: logger.warning( "Could read() from potential iostream with persistent " "key: %s", key, ) logger.debug("Persistent Storage Exception: %s", str(e)) raise exception.AppriseDiskIOError( f"Invalid data type {type(data)} provided to Persistent" " Storage" ) from None if self.__mode == PersistentStoreMode.MEMORY: # Nothing further can be done return False if _recovery: # Attempt to recover from a bad directory structure or setup self.__prepare() # generate our filename based on the key provided io_file = os.path.join(self.__data_path, f"{key}{self.__extension}") # Calculate the files current filesize try: prev_size = os.stat(io_file).st_size except FileNotFoundError: # No worries, no size to accommodate prev_size = 0 except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning("Could not write with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) return False # Create a temporary file to write our content into # ntf = NamedTemporaryFile ntf = None new_file_size = 0 try: if isinstance(data, str): data = data.encode(self.encoding) ntf = tempfile.NamedTemporaryFile( # noqa: SIM115 mode="wb", dir=self.__temp_path, delete=False ) # Close our file ntf.close() # Pointer to our open call open_ = open if not compress else gzip.open with open_(ntf.name, mode="wb") as fd: # Write our content fd.write(data) # Get our file size new_file_size = os.stat(ntf.name).st_size # Log our progress logger.trace( "Wrote %d bytes of data to persistent key: %s", new_file_size, key, ) except FileNotFoundError: # This happens if the directory path is gone preventing the file # from being created... if not _recovery: return self.write( data=data, key=key, compress=compress, _recovery=True ) # We've already made our best effort to recover if we are here in # our code base... we're going to have to exit # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except (OSError, UnicodeEncodeError, zlib.error) as e: # We can't access the file or it does not exist logger.warning("Could not write to persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) return False if ( self.max_file_size > 0 and (new_file_size + self.size() - prev_size) > self.max_file_size ): # The content to store is to large logger.warning( "Persistent content exceeds allowable maximum file length" f" ({int(self.max_file_size / 1024)}KB); provide" f" {int(new_file_size / 1024)}KB" ) return False # Return our final move if not self.__move(ntf.name, io_file): # Attempt to restore things as they were # Tidy our Named Temporary File _ntf_tidy(ntf) return False # Resetour reference variables self.__cache_size = None self.__cache_files.clear() # Content installed return True def __move(self, src, dst): """Moves the new file in place and handles the old if it exists already If the transaction fails in any way, the old file is swapped back. Function returns True if successful and False if not. """ # A temporary backup of the file we want to move in place dst_backup = ( dst[: -len(self.__backup_extension)] + self.__backup_extension ) # # Backup the old file (if it exists) allowing us to have a restore # point in the event of a failure # try: # make sure the file isn't already present; if it is; remove it os.unlink(dst_backup) logger.trace( "Removed previous persistent backup file: %s", dst_backup ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not previous persistent data backup: %s", dst_backup ) logger.debug("Persistent Storage Exception: %s", str(e)) return False try: # Back our file up so we have a fallback os.rename(dst, dst_backup) logger.trace( "Persistent storage backup file created: %s", dst_backup ) except FileNotFoundError: # Not a problem; this is a brand new file we're writing # There is nothing to backup pass except OSError as e: # This isn't good... we couldn't put our new file in place logger.warning( "Could not install persistent content %s -> %s", dst, os.path.basename(dst_backup), ) logger.debug("Persistent Storage Exception: %s", str(e)) return False # # Now place the new file # try: os.rename(src, dst) logger.trace("Persistent file installed: %s", dst) except OSError as e: # This isn't good... we couldn't put our new file in place # Begin fall-back process before leaving the funtion logger.warning( "Could not install persistent content %s -> %s", src, os.path.basename(dst), ) logger.debug("Persistent Storage Exception: %s", str(e)) try: # Restore our old backup (if it exists) os.rename(dst_backup, dst) logger.trace("Restoring original persistent content: %s", dst) except FileNotFoundError: # Not a problem pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Failed to restore original persistent file: %s", dst ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return True def open( self, key: Optional[str] = None, mode: str = "r", buffering: int = -1, encoding: Optional[str] = None, errors: Optional[str] = None, newline: Optional[str] = None, closefd: bool = True, opener: Optional[Any] = None, compress: bool = False, compresslevel: int = 9, ) -> Any: """Returns an iterator to our our file within our namespace identified by the key provided. If no key is provided, then the default is used """ if key is None: key = self.base_key elif not isinstance(key, str) or not self.__valid_key.match(key): raise AttributeError( f"Persistent Storage key ({key} provided is invalid" ) if self.__mode == PersistentStoreMode.MEMORY: # Nothing further can be done raise FileNotFoundError() io_file = os.path.join(self.__data_path, f"{key}{self.__extension}") try: return ( open( io_file, mode=mode, buffering=buffering, encoding=encoding, errors=errors, newline=newline, closefd=closefd, opener=opener, ) if not compress else gzip.open( io_file, compresslevel=compresslevel, encoding=encoding, errors=errors, newline=newline, ) ) except FileNotFoundError: # pass along (but wrap with Apprise exception) raise exception.AppriseFileNotFound( f"No such file or directory: '{io_file}'" ) from None except (OSError, zlib.error) as e: # We can't access the file or it does not exist logger.warning("Could not read with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) raise exception.AppriseDiskIOError(str(e)) from None def get( self, key: str, default: Any = None, lazy: bool = True, ) -> Any: """Fetches from cache.""" if self._cache is None and not self.__load_cache(): return default if ( key in self._cache and self.__mode != PersistentStoreMode.MEMORY and not self.__dirty ): # ensure we renew our content self.__renew.add(self.cache_file) return self._cache[key].value if self._cache.get(key) else default def set( self, key: str, value: Any, expires: Union[float, int, datetime, bool, None] = None, persistent: bool = True, lazy: bool = True, ) -> bool: """Cache reference.""" if self._cache is None and not self.__load_cache(): return False cache = CacheObject(value, expires, persistent=persistent) # Fetch our cache value try: if lazy and cache == self._cache[key]: # We're done; nothing further to do return True except KeyError: pass # Store our new cache self._cache[key] = CacheObject(value, expires, persistent=persistent) # Set our dirty flag self.__dirty = persistent if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() return True def clear(self, *args: str) -> Optional[bool]: """Remove one or more cache entry by it's key. e.g: clear('key') clear('key1', 'key2', key-12') Or clear everything: clear() """ if self._cache is None and not self.__load_cache(): return False if args: for arg in args: try: del self._cache[arg] # Set our dirty flag (if not set already) self.__dirty = True except KeyError: pass elif self._cache: # Request to remove everything and there is something to remove # Set our dirty flag (if not set already) self.__dirty = True # Reset our object self._cache.clear() if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() def prune(self) -> bool: """Eliminates expired cache entries.""" if self._cache is None and not self.__load_cache(): return False change = False for key in list(self._cache.keys()): if key not in self: # It's identified as being expired if not change and self._cache[key].persistent: # track change only if content was persistent change = True # Set our dirty flag self.__dirty = True del self._cache[key] if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() return change def __load_cache(self, _recovery=False): """Loads our cache. _recovery is reserved for internal usage and should not be changed """ # Prepare our dirty flag self.__dirty = False if self.__mode == PersistentStoreMode.MEMORY: # Nothing further to do self._cache = {} return True # Prepare our cache file cache_file = self.cache_file try: with gzip.open(cache_file, "rb") as f: # Read our ontent from disk self._cache = {} for k, v in json.loads(f.read().decode(self.encoding)).items(): co = CacheObject.instantiate(v) if co: # Verify our object before assigning it self._cache[k] = co elif not self.__dirty: # Track changes from our loadset self.__dirty = True except ( UnicodeDecodeError, json.decoder.JSONDecodeError, zlib.error, TypeError, AttributeError, EOFError, ): # Let users known there was a problem logger.warning( "Corrupted access persistent cache content: %s", cache_file ) if not _recovery: try: os.unlink(cache_file) logger.trace( "Removed previous persistent cache content: %s", cache_file, ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not remove persistent cache content: %s", cache_file, ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return self.__load_cache(_recovery=True) return False except FileNotFoundError: # No problem; no cache to load self._cache = {} except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not load persistent cache for namespace %s", os.path.basename(self.__base_path), ) logger.debug("Persistent Storage Exception: %s", str(e)) return False # Ensure our dirty flag is set to False return True def __prepare(self, flush=True): """Prepares a working environment.""" if self.__mode != PersistentStoreMode.MEMORY: # Ensure our path exists try: os.makedirs(self.__base_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__base_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY # Ensure our path exists try: os.makedirs(self.__temp_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__temp_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY try: os.makedirs(self.__data_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__data_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY if self.__mode is PersistentStoreMode.MEMORY: logger.warning( "The persistent storage could not be fully initialized; " "operating in MEMORY mode" ) else: if self._cache: # Recovery taking place self.__dirty = True logger.warning( "The persistent storage environment was disrupted" ) if self.__mode is PersistentStoreMode.FLUSH and flush: # Flush changes to disk return self.flush(_recovery=True) def flush( self, force: bool = False, _recovery: bool = False, ) -> bool: """Save's our cache to disk.""" if self._cache is None or self.__mode == PersistentStoreMode.MEMORY: # nothing to do return True while self.__renew: # update our files path = self.__renew.pop() ftime = time.time() try: # (access_time, modify_time) os.utime(path, (ftime, ftime)) logger.trace("file timestamp updated: %s", path) except FileNotFoundError: # No worries... move along pass except OSError as e: # We can't access the file or it does not exist logger.debug("Could not update file timestamp: %s", path) logger.debug("Persistent Storage Exception: %s", str(e)) if not force and self.__dirty is False: # Nothing further to do logger.trace("Persistent cache is consistent with memory map") return True if _recovery: # Attempt to recover from a bad directory structure or setup self.__prepare(flush=False) # Unset our size lazy setting self.__cache_size = None self.__cache_files.clear() # Prepare our cache file cache_file = self.cache_file if not self._cache: # # We're deleting the cache file s there are no entries left in it # backup_file = ( cache_file[: -len(self.__backup_extension)] + self.__backup_extension ) try: os.unlink(backup_file) logger.trace( "Removed previous persistent cache backup: %s", backup_file ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not remove persistent cache backup: %s", backup_file ) logger.debug("Persistent Storage Exception: %s", str(e)) return False try: os.rename(cache_file, backup_file) logger.trace( "Persistent cache backup file created: %s", backup_file ) except FileNotFoundError: # Not a problem; do not create a log entry pass except OSError as e: # This isn't good... we couldn't put our new file in place logger.warning( "Could not remove stale persistent cache file: %s", cache_file, ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return True # # If we get here, we need to update our file based cache # # ntf = NamedTemporaryFile ntf = None try: ntf = tempfile.NamedTemporaryFile( # noqa: SIM115 mode="w+", encoding=self.encoding, dir=self.__temp_path, delete=False, ) ntf.close() except FileNotFoundError: # This happens if the directory path is gone preventing the file # from being created... if not _recovery: return self.flush(force=True, _recovery=True) # We've already made our best effort to recover if we are here in # our code base... we're going to have to exit # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except OSError as e: logger.error( "Persistent temporary directory inaccessible: %s", self.__temp_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False try: # write our content currently saved to disk to our temporary file with gzip.open(ntf.name, "wb") as f: # Write our content to disk f.write( json.dumps( { k: v for k, v in self._cache.items() if v and v.persistent }, separators=(",", ":"), cls=CacheJSONEncoder, ).encode(self.encoding) ) except TypeError as e: # JSON object contains content that can not be encoded to disk logger.error( "Persistent temporary file can not be written to " "due to bad input data: %s", ntf.name, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except (OSError, EOFError, zlib.error) as e: logger.error( "Persistent temporary file inaccessible: %s", ntf.name ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False if not self.__move(ntf.name, cache_file): # Attempt to restore things as they were # Tidy our Named Temporary File _ntf_tidy(ntf) return False # Ensure our dirty flag is set to False self.__dirty = False return True def files( self, exclude: bool = True, lazy: bool = True, ) -> list[str]: """Returns the total files.""" if lazy and exclude in self.__cache_files: # Take an early exit with our cached results return self.__cache_files[exclude] elif self.__mode == PersistentStoreMode.MEMORY: # Take an early exit # exclude is our cache switch and can be either True or False. # For the below, we just set both cases and set them up as an # empty record self.__cache_files.update({True: [], False: []}) return [] if not lazy or self.__exclude_list is None: # A list of criteria that should be excluded from the size count self.__exclude_list = ( # Exclude backup cache file from count re.compile( re.escape( os.path.join( self.__base_path, f"{self.__cache_key}{self.__backup_extension}", ) ) ), # Exclude temporary files re.compile(re.escape(self.__temp_path) + r"[/\\].+"), # Exclude custom backup persistent files re.compile( re.escape(self.__data_path) + r"[/\\].+" + re.escape(self.__backup_extension) ), ) try: if exclude: self.__cache_files[exclude] = [ path for path in filter( os.path.isfile, glob.glob( os.path.join(self.__base_path, "**", "*"), recursive=True, ), ) if next( (False for p in self.__exclude_list if p.match(path)), True, ) ] else: # No exclusion list applied self.__cache_files[exclude] = list( filter( os.path.isfile, glob.glob( os.path.join(self.__base_path, "**", "*"), recursive=True, ), ) ) except OSError: # We can't access the directory or it does not exist self.__cache_files[exclude] = [] return self.__cache_files[exclude] @staticmethod def disk_scan( path: str, namespace: Optional[Union[str, list[str]]] = None, closest: bool = True, ) -> list[str]: """Scansk a path provided and returns namespaces detected.""" logger.trace("Persistent path can of: %s", path) def is_namespace(x): """Validate what was detected is a valid namespace.""" return os.path.isdir( os.path.join(path, x) ) and PersistentStore.__valid_key.match(x) # Handle our namespace searching if namespace: if isinstance(namespace, str): namespace = [namespace] elif not isinstance(namespace, (tuple, set, list)): raise AttributeError( "namespace must be None, a string, or a tuple/set/list " "of strings" ) try: # Acquire all of the files in question namespaces = ( [ ns for ns in filter(is_namespace, os.listdir(path)) if not namespace or next( (True for n in namespace if ns.startswith(n)), False ) ] if closest else [ ns for ns in filter(is_namespace, os.listdir(path)) if not namespace or ns in namespace ] ) except FileNotFoundError: # no worries; Nothing to do logger.debug("Disk Prune path not found; nothing to clean.") return [] except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error("Disk Scan detetcted inaccessible path: %s", path) logger.debug("Persistent Storage Exception: %s", str(e)) return [] return namespaces @staticmethod def disk_prune( path: str, namespace: Optional[Union[str, list[str]]] = None, expires: Optional[Union[int, float]] = None, action: bool = False, ) -> dict[str, list[dict[str, Union[str, bool]]]]: """Prune persistent disk storage entries that are old and/or unreferenced. you must specify a path to perform the prune within if one or more namespaces are provided, then pruning focuses ONLY on those entries (if matched). if action is not set to False, directories to be removed are returned only """ # Prepare our File Expiry expires = ( datetime.now() - timedelta(seconds=expires) if isinstance(expires, (float, int)) and expires >= 0 else PersistentStore.default_file_expiry ) # Get our namespaces namespaces = PersistentStore.disk_scan(path, namespace) # Track matches map_ = {} for namespace in namespaces: # Prepare our map map_[namespace] = [] # Reference Directories base_dir = os.path.join(path, namespace) data_dir = os.path.join(base_dir, PersistentStore.data_dir) temp_dir = os.path.join(base_dir, PersistentStore.temp_dir) # Careful to only focus on files created by this Persistent Store # object files = [ os.path.join( base_dir, f"{PersistentStore.__cache_key}" f"{PersistentStore.__extension}", ), os.path.join( base_dir, f"{PersistentStore.__cache_key}" f"{PersistentStore.__backup_extension}", ), ] # Update our files (applying what was defined above too) valid_data_re = re.compile( r".*(" + re.escape(PersistentStore.__extension) + r"|" + re.escape(PersistentStore.__backup_extension) + r")$" ) files = [ path for path in filter( os.path.isfile, chain( glob.glob( os.path.join(data_dir, "*"), recursive=False ), files, ), ) if valid_data_re.match(path) ] # Now all temporary files files.extend( list( filter( os.path.isfile, glob.glob( os.path.join(temp_dir, "*"), recursive=False ), ) ) ) # Track if we should do a directory sweep later on dir_sweep = True # Scan our files for file in files: try: mtime = datetime.fromtimestamp(os.path.getmtime(file)) except FileNotFoundError: # no worries; we were removing it anyway continue except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error( "Disk Prune (ns=%s, clean=%s) detetcted inaccessible " "file: %s", namespace, "yes" if action else "no", file, ) logger.debug("Persistent Storage Exception: %s", str(e)) # No longer worth doing a directory sweep dir_sweep = False continue if expires < mtime: continue # # Handle Removing # record = { "path": file, "removed": False, } if action: try: os.unlink(file) # Update our record record["removed"] = True logger.info( "Disk Prune (ns=%s, clean=%s) removed persistent " "file: %s", namespace, "yes" if action else "no", file, ) except FileNotFoundError: # no longer worth doing a directory sweep dir_sweep = False # otherwise, no worries; we were removing the file # anyway except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error( "Disk Prune (ns=%s, clean=%s) failed to remove " "persistent file: %s", namespace, "yes" if action else "no", file, ) logger.debug( "Persistent Storage Exception: %s", str(e) ) # No longer worth doing a directory sweep dir_sweep = False # Store our record map_[namespace].append(record) # Memory tidy del files if dir_sweep: # Gracefully cleanup our namespace directory. It's okay if we # fail; This just means there were files in the directory. for dirpath in (temp_dir, data_dir, base_dir): if action: try: os.rmdir(dirpath) logger.info( "Disk Prune (ns=%s, clean=%s) removed " "persistent dir: %s", namespace, "yes" if action else "no", dirpath, ) except OSError: # do nothing; pass return map_ def size( self, exclude: bool = True, lazy: bool = True, ) -> int: """Returns the total size of the persistent storage in bytes.""" if lazy and self.__cache_size is not None: # Take an early exit return self.__cache_size elif self.__mode == PersistentStoreMode.MEMORY: # Take an early exit self.__cache_size = 0 return self.__cache_size # Get a list of files (file paths) in the given directory try: self.__cache_size = sum( os.stat(path).st_size for path in self.files(exclude=exclude, lazy=lazy) ) except OSError: # We can't access the directory or it does not exist self.__cache_size = 0 return self.__cache_size def __del__(self) -> None: """Deconstruction of our object.""" if self.__mode == PersistentStoreMode.AUTO: # Flush changes to disk self.flush() def __delitem__(self, key: str) -> None: """Remove a cache entry by it's key.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") try: if self._cache[key].persistent: # Set our dirty flag in advance self.__dirty = True # Store our new cache del self._cache[key] except KeyError: # Nothing to do raise if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk self.flush() return def __contains__(self, key: str) -> bool: """Verify if our storage contains the key specified or not. In additiont to this, if the content is expired, it is considered to be not contained in the storage. """ if self._cache is None and not self.__load_cache(): return False return key in self._cache and self._cache[key] def __setitem__(self, key: str, value: Any) -> None: """Sets a cache value without disrupting existing settings in place.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") if key not in self._cache and not self.set(key, value): raise KeyError("Could not set cache") else: # Update our value self._cache[key].set(value) if self._cache[key].persistent: # Set our dirty flag in advance self.__dirty = True if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk self.flush() return def __getitem__(self, key: str) -> Any: """Returns the indexed value.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") result = self.get(key, default=self.__not_found_ref, lazy=False) if result is self.__not_found_ref: raise KeyError(f" {key} not found in cache") return result def keys(self) -> builtins.set[str]: """Returns our keys.""" if self._cache is None and not self.__load_cache(): # There are no keys to return return {}.keys() return self._cache.keys() def delete( self, *args: str, all: Optional[bool] = None, temp: Optional[bool] = None, cache: Optional[bool] = None, validate: bool = True, ) -> bool: """Manages our file space and tidys it up. delete('key', 'key2') delete(all=True) delete(temp=True, cache=True) """ # Our failure flag has_error = False valid_key_re = re.compile( r"^(?P.+)(" + re.escape(self.__backup_extension) + r"|" + re.escape(self.__extension) + r")$", re.I, ) # Default asignments if all is None: all = bool(not (len(args) or temp or cache)) if temp is None: temp = bool(all) if cache is None: cache = bool(all) if cache and self._cache: # Reset our object self._cache.clear() # Reset dirt flag self.__dirty = False for path in self.files(exclude=False): # Some information we use to validate the actions of our clean() # call. This is so we don't remove anything we shouldn't base = os.path.dirname(path) fname = os.path.basename(path) # Clean printable path details ppath = os.path.join(os.path.dirname(base), fname) if base == self.__base_path and cache: # We're handling a cache file (hopefully) result = valid_key_re.match(fname) key = ( None if not result else ( result["key"] if self.__valid_key.match(result["key"]) else None ) ) if validate and key != self.__cache_key: # We're not dealing with a cache key logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue # # We should proceed with removing the file if we get here # elif base == self.__data_path and (args or all): # We're handling a file found in our custom data path result = valid_key_re.match(fname) key = ( None if not result else ( result["key"] if self.__valid_key.match(result["key"]) else None ) ) if validate and key is None: # we're set to validate and a non-valid file was found logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue elif not all and (key is None or key not in args): # no match found logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue # # We should proceed with removing the file if we get here # elif base == self.__temp_path and temp: # # This directory is a temporary path and nothing in here needs # to be further verified. Proceed with the removing of the file # pass else: # No match; move on logger.debug("Persistent File cleanup ignoring file: %s", path) continue try: os.unlink(path) logger.info("Removed persistent file: %s", ppath) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point has_error = True logger.error("Failed to remove persistent file: %s", ppath) logger.debug("Persistent Storage Exception: %s", str(e)) # Reset our reference variables self.__cache_size = None self.__cache_files.clear() return not has_error @property def cache_file(self) -> str: """Returns the full path to the namespace directory.""" return os.path.join( self.__base_path, f"{self.__cache_key}{self.__extension}", ) @property def path(self) -> Optional[str]: """Returns the full path to the namespace directory.""" return self.__base_path @property def mode(self) -> PersistentStoreMode: """Returns the Persistent Storage mode.""" return self.__mode caronc-apprise-182f859/apprise/plugins/000077500000000000000000000000001524161175200200405ustar00rootroot00000000000000caronc-apprise-182f859/apprise/plugins/__init__.py000066400000000000000000000443461524161175200221640ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import copy import os from ..common import ( NOTIFY_IMAGE_SIZES, NOTIFY_TYPES, NotifyImageSize, NotifyType, ) from ..locale import LazyTranslation, gettext_lazy as _ from ..logger import logger from ..manager_plugins import NotificationManager from ..utils.cwe312 import cwe312_url from ..utils.parse import GET_SCHEMA_RE, parse_list # Used for testing from .base import NotifyBase # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() __all__ = [ "NOTIFY_IMAGE_SIZES", "NOTIFY_TYPES", "NotifyBase", # Reference "NotifyImageSize", "NotifyType", # Tokenizer "url_to_dict", ] def _sanitize_token(tokens, default_delimiter): """This is called by the details() function and santizes the output by populating expected and consistent arguments if they weren't otherwise specified.""" # Used for tracking groups group_map = {} # Iterate over our tokens for key in tokens: for element in tokens[key]: # Perform translations (if detected to do so) if isinstance(tokens[key][element], LazyTranslation): tokens[key][element] = str(tokens[key][element]) if "alias_of" in tokens[key]: # Do not touch this field continue elif "name" not in tokens[key]: # Default to key tokens[key]["name"] = key if "map_to" not in tokens[key]: # Default type to key tokens[key]["map_to"] = key # Track our map_to objects if tokens[key]["map_to"] not in group_map: group_map[tokens[key]["map_to"]] = set() group_map[tokens[key]["map_to"]].add(key) if "type" not in tokens[key]: # Default type to string tokens[key]["type"] = "string" elif tokens[key]["type"].startswith("list"): if "delim" not in tokens[key]: # Default list delimiter (if not otherwise specified) tokens[key]["delim"] = default_delimiter if key in group_map[tokens[key]["map_to"]]: # pragma: no branch # Remove ourselves from the list group_map[tokens[key]["map_to"]].remove(key) # Pointing to the set directly so we can dynamically update # ourselves tokens[key]["group"] = group_map[tokens[key]["map_to"]] elif ( tokens[key]["type"].startswith("choice") and "default" not in tokens[key] and "values" in tokens[key] and len(tokens[key]["values"]) == 1 ): # If there is only one choice; then make it the default # - support dictionaries too tokens[key]["default"] = ( tokens[key]["values"][0] if not isinstance(tokens[key]["values"], dict) else next(iter(tokens[key]["values"])) ) if "values" in tokens[key] and isinstance(tokens[key]["values"], dict): # Convert values into a list if it was defined as a dictionary tokens[key]["values"] = list(tokens[key]["values"].keys()) if "regex" in tokens[key]: # Verify that we are a tuple; convert strings to tuples if isinstance(tokens[key]["regex"], str): # Default tuple setup tokens[key]["regex"] = (tokens[key]["regex"], None) elif not isinstance(tokens[key]["regex"], (list, tuple)): # Invalid regex del tokens[key]["regex"] if "required" not in tokens[key]: # Default required is False tokens[key]["required"] = False if "private" not in tokens[key]: # Private flag defaults to False if not set tokens[key]["private"] = False return def details(plugin): """Provides templates that can be used by developers to build URLs dynamically. If a list of templates is provided, then they will be used over the default value. If a list of tokens are provided, then they will over-ride any additional settings built from this script and/or will be appended to them afterwards. """ # Our unique list of parsing will be based on the provided templates # if none are provided we will use our own templates = tuple(plugin.templates) # The syntax is simple # { # # The token_name must tie back to an entry found in the # # templates list. # 'token_name': { # # # types can be 'string', 'int', 'choice', 'list, 'float' # # both choice and list may additionally have a : identify # # what the list/choice type is comprised of; the default # # is string. # 'type': 'choice:string', # # # values will only exist the type must be a fixed # # list of inputs (generated from type choice for example) # # # If this is a choice:bool then you should ALWAYS define # # this list as a (True, False) such as ('Yes, 'No') or # # ('Enabled', 'Disabled'), etc # 'values': [ 'http', 'https' ], # # # Identifies if the entry specified is required or not # 'required': True, # # # Identifies all tokens detected to be associated with the # # list:string # # This is ony present in list:string objects and is only set # # if this element acts as an alias for several other # # kwargs/fields. # 'group': [], # # # Identify a default value # 'default': 'http', # # # Optional Verification Entries min and max are for floats # # and/or integers # 'min': 4, # 'max': 5, # # # A list will always identify a delimiter. If this is # # part of a path, this may be a '/', or it could be a # # comma and/or space. delimiters are always in a list # # eg (if space and/or comma is a delimiter the entry # # would look like: 'delim': [',' , ' ' ] # 'delim': None, # # # Use regex if you want to share the regular expression # # required to validate the field. The regex will never # # accommodate the prefix (if one is specified). That is # # up to the user building the URLs to include the prefix # # on the URL when constructing it. # # The format is ('regex', 'reg options') # 'regex': (r'[A-Z0-9]+', 'i'), # # # A Prefix is always a string, to differentiate between # # multiple arguments, sometimes content is prefixed. # 'prefix': '@', # # # By default the key of this object is to be interpreted # # as the argument to the notification in question. However # # To accommodate cases where there are multiple types that # # all map to the same entry, one can find a map_to value. # 'map_to': 'function_arg', # # # Some arguments act as an alias_of an already defined object # # This plays a role more with configuration file generation # # since yaml files allow you to define different argumuments # # in line to simplify things. If this directive is set, then # # it should be treated exactly the same as the object it is # # an alias of # 'alias_of': 'function_arg', # # # Advise developers to consider the potential sensitivity # # of this field owned by the user. This is for passwords, # # and api keys, etc... # 'private': False, # }, # } # Template tokens identify the arguments required to initialize the # plugin itself. It identifies all of the tokens and provides some # details on their use. Each token defined should in some way map # back to at least one URL {token} defined in the templates # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_tokens = copy.deepcopy(plugin.template_tokens) # Arguments and/or Options either have a default value and/or are # optional to be set. # # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_args = copy.deepcopy(plugin.template_args) # Our template keyword arguments ?+key=value&-key=value # Basically the user provides both the key and the value. this is only # possibly by identifying the key prefix required for them to be # interpreted hence the +/- keys are built into apprise by default for easy # reference. In these cases, entry might look like '+' being the prefix: # { # 'arg_name': { # 'name': 'label', # 'prefix': '+', # } # } # # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_kwargs = copy.deepcopy(plugin.template_kwargs) # We automatically create a schema entry template_tokens["schema"] = { "name": _("Schema"), "type": "choice:string", "required": True, "values": parse_list(plugin.secure_protocol, plugin.protocol), } # Sanitize our tokens _sanitize_token(template_tokens, default_delimiter=("/",)) # Delimiter(s) are space and/or comma _sanitize_token(template_args, default_delimiter=(",", " ")) _sanitize_token(template_kwargs, default_delimiter=(",", " ")) # Argument/Option Handling for key in list(template_args.keys()): if "alias_of" in template_args[key]: # Check if the mapped reference is a list; if it is, then # we need to store a different delimiter alias_of = template_tokens.get(template_args[key]["alias_of"], {}) if ( alias_of.get("type", "").startswith("list") and "delim" not in template_args[key] ): # Set a default delimiter of a comma and/or space if one # hasn't already been specified template_args[key]["delim"] = (",", " ") # _lookup_default looks up what the default value if "_lookup_default" in template_args[key]: template_args[key]["default"] = getattr( plugin, template_args[key]["_lookup_default"] ) # Tidy as we don't want to pass this along in response del template_args[key]["_lookup_default"] # _exists_if causes the argument to only exist IF after checking # the return of an internal variable requiring a check if "_exists_if" in template_args[key]: if not getattr(plugin, template_args[key]["_exists_if"]): # Remove entire object del template_args[key] else: # We only nee to remove this key del template_args[key]["_exists_if"] return { "templates": templates, "tokens": template_tokens, "args": template_args, "kwargs": template_kwargs, } def requirements(plugin): """Provides a list of packages and its requirement details.""" requirements = { # Use the description to provide a human interpretable description of # what is required to make the plugin work. This is only nessisary # if there are package dependencies "details": "", # Define any required packages needed for the plugin to run. This is # an array of strings that simply look like lines in the # `requirements.txt` file... # # A single string is perfectly acceptable: # 'packages_required' = 'cryptography' # # Multiple entries should look like the following # 'packages_required' = [ # 'cryptography < 3.4`, # ] # "packages_required": [], # Recommended packages identify packages that are not required to make # your plugin work, but would improve it's use or grant it access to # full functionality (that might otherwise be limited). # Similar to `packages_required`, you would identify each entry in # the array as you would in a `requirements.txt` file. # # - Do not re-provide entries already in the `packages_required` "packages_recommended": [], } # Populate our template differently if we don't find anything above if not ( hasattr(plugin, "requirements") and isinstance(plugin.requirements, dict) ): # We're done early return requirements # Get our required packages req_packages = plugin.requirements.get("packages_required") if isinstance(req_packages, str): # Convert to list req_packages = [req_packages] elif not isinstance(req_packages, (set, list, tuple)): # Allow one to set the required packages to None (as an example) req_packages = [] requirements["packages_required"] = [str(p) for p in req_packages] # Get our recommended packages opt_packages = plugin.requirements.get("packages_recommended") if isinstance(opt_packages, str): # Convert to list opt_packages = [opt_packages] elif not isinstance(opt_packages, (set, list, tuple)): # Allow one to set the recommended packages to None (as an example) opt_packages = [] requirements["packages_recommended"] = [str(p) for p in opt_packages] # Get our package details req_details = plugin.requirements.get("details") if not req_details: if not (req_packages or opt_packages): req_details = _("No dependencies.") elif req_packages: req_details = _("Packages are required to function.") else: # opt_packages req_details = _( "Packages are recommended to improve functionality." ) else: # Store our details if defined requirements["details"] = req_details # Return our compiled package requirements return requirements def url_to_dict(url, secure_logging=True): """Takes an apprise URL and returns the tokens associated with it if they can be acquired based on the plugins available. None is returned if the URL could not be parsed, otherwise the tokens are returned. These tokens can be loaded into apprise through it's add() function. """ # swap hash (#) tag values with their html version url_ = url.replace("/#", "/%23") # CWE-312 (Secure Logging) Handling loggable_url = url if not secure_logging else cwe312_url(url) # Attempt to acquire the schema at the very least to allow our plugins to # determine if they can make a better interpretation of a URL geared for # them. schema = GET_SCHEMA_RE.match(url_) if schema is None: # Not a valid URL; take an early exit logger.error(f"Unsupported URL: {loggable_url}") return None # Ensure our schema is always in lower case schema = schema.group("schema").lower() if schema not in N_MGR: # Give the user the benefit of the doubt that the user may be using # one of the URLs provided to them by their notification service. # Before we fail for good, just scan all the plugins that support the # native_url() parse function results = None for plugin in N_MGR.plugins(): results = plugin.parse_native_url(url_) if results: break if not results: logger.error(f"Unparseable URL {loggable_url}") return None logger.trace( "URL {} unpacked as:{}{}".format( url, os.linesep, os.linesep.join([f'{k}="{v}"' for k, v in results.items()]), ) ) else: # Parse our url details of the server object as dictionary # containing all of the information parsed from our URL results = N_MGR[schema].parse_url(url_) if not results: logger.error( f"Unparseable {N_MGR[schema].service_name} URL {loggable_url}" ) return None logger.trace( "{} URL {} unpacked as:{}{}".format( N_MGR[schema].service_name, url, os.linesep, os.linesep.join([f'{k}="{v}"' for k, v in results.items()]), ) ) # Return our results return results caronc-apprise-182f859/apprise/plugins/africas_talking.py000066400000000000000000000405711524161175200235420ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, you must have a Africas Talking Account setup; See here: # https://account.africastalking.com/ # From here... acquire your APIKey # # API Details: https://developers.africastalking.com/docs/sms/sending/bulk import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase class AfricasTalkingSMSMode: """Africas Talking SMS Mode.""" # BulkSMS Mode BULKSMS = "bulksms" # Premium Mode PREMIUM = "premium" # Sandbox Mode SANDBOX = "sandbox" # Define the types in a list for validation purposes AFRICAS_TALKING_SMS_MODES = ( AfricasTalkingSMSMode.BULKSMS, AfricasTalkingSMSMode.PREMIUM, AfricasTalkingSMSMode.SANDBOX, ) # Extend HTTP Error Messages AFRICAS_TALKING_HTTP_ERROR_MAP = { 100: "Processed", 101: "Sent", 102: "Queued", 401: "Risk Hold", 402: "Invalid Sender ID", 403: "Invalid Phone Number", 404: "Unsupported Number Type", 405: "Insufficient Balance", 406: "User In Blacklist", 407: "Could Not Route", 409: "Do Not Disturb Rejection", 500: "Internal Server Error", 501: "Gateway Error", 502: "Rejected By Gateway", } class NotifyAfricasTalking(NotifyBase): """A wrapper for Africas Talking Notifications.""" # The default descriptive name associated with the Notification service_name = "Africas Talking" # The services URL service_url = "https://africastalking.com/" # The default secure protocol secure_protocol = "atalk" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/africas_talking/" # Africas Talking API Request URLs notify_url = { AfricasTalkingSMSMode.BULKSMS: ( "https://api.africastalking.com/version1/messaging" ), AfricasTalkingSMSMode.PREMIUM: ( "https://content.africastalking.com/version1/messaging" ), AfricasTalkingSMSMode.SANDBOX: ( "https://api.sandbox.africastalking.com/version1/messaging" ), } # The maximum allowable characters allowed in the title per message title_maxlen = 0 # The maximum allowable characters allowed in the body per message body_maxlen = 160 # The maximum amount of phone numbers that can reside within a single # batch transfer default_batch_size = 50 # Define object templates templates = ("{schema}://{appuser}@{apikey}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "appuser": { "name": _("App User Name"), "type": "string", "regex": (r"^[A-Z0-9_-]+$", "i"), "required": True, }, "apikey": { "name": _("API Key"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9_-]+$", "i"), }, "target_phone": { "name": _("Target Phone"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "apikey": { "alias_of": "apikey", }, "from": { # Your registered short code or alphanumeric "name": _("From"), "type": "string", "default": "AFRICASTKNG", "map_to": "sender", }, "mode": { "name": _("SMS Mode"), "type": "choice:string", "values": AFRICAS_TALKING_SMS_MODES, "default": AFRICAS_TALKING_SMS_MODES[0], }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, appuser, apikey, targets=None, sender=None, batch=None, mode=None, **kwargs, ): """Initialize Africas Talking Object.""" super().__init__(**kwargs) self.appuser = validate_regex( appuser, *self.template_tokens["appuser"]["regex"] ) if not self.appuser: msg = ( f"The Africas Talking appuser specified ({appuser}) is" " invalid." ) self.logger.warning(msg) raise TypeError(msg) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = ( f"The Africas Talking apikey specified ({apikey}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Prepare Sender self.sender = ( self.template_args["from"]["default"] if sender is None else sender ) # Prepare Batch Mode Flag self.batch = ( self.template_args["batch"]["default"] if batch is None else batch ) self.mode = ( self.template_args["mode"]["default"] if not isinstance(mode, str) else mode.lower() ) if isinstance(mode, str) and mode: self.mode = next( ( a for a in AFRICAS_TALKING_SMS_MODES if a.startswith(mode.lower()) ), None, ) if self.mode not in AFRICAS_TALKING_SMS_MODES: msg = ( f"The Africas Talking mode specified ({mode}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) else: self.mode = self.template_args["mode"]["default"] # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number # Carry forward '+' if defined, otherwise do not... self.targets.append( ("+" + result["full"]) if target.lstrip()[0] == "+" else result["full"] ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Africas Talking Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Africas Talking recipients to notify" ) return False headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", "apiKey": self.apikey, } # error tracking (used for function return) has_error = False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # Create a copy of the target list for index in range(0, len(self.targets), batch_size): # Prepare our payload payload = { "username": self.appuser, "to": ",".join(self.targets[index : index + batch_size]), "from": self.sender, "message": body, } # Acquire our URL notify_url = self.notify_url[self.mode] self.logger.debug( "Africas Talking POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Africas Talking Payload: {payload!s}") # Printable target detail _batch = self.targets[index : index + batch_size] p_target = ( self.targets[index] if batch_size == 1 else f"{len(_batch)} target(s)" ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # Sample response # { # "SMSMessageData": { # "Message": "Sent to 1/1 Total Cost: KES 0.8000", # "Recipients": [{ # "statusCode": 101, # "number": "+254711XXXYYY", # "status": "Success", # "cost": "KES 0.8000", # "messageId": "ATPid_SampleTxnId123" # }] # } # } if r.status_code not in (100, 101, 102, requests.codes.ok): # We had a problem status_str = ( NotifyAfricasTalking.http_response_code_lookup( r.status_code, AFRICAS_TALKING_HTTP_ERROR_MAP ) ) self.logger.warning( "Failed to send Africas Talking notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( f"Sent Africas Talking notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Africas Talking " f"notification to {p_target}." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.appuser, self.apikey) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } if self.sender != self.template_args["from"]["default"]: # Set our sender if it was set params["from"] = self.sender if self.mode != self.template_args["mode"]["default"]: # Set our mode params["mode"] = self.mode # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{appuser}@{apikey}/{targets}?{params}".format( schema=self.secure_protocol, appuser=NotifyAfricasTalking.quote(self.appuser, safe=""), apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [NotifyAfricasTalking.quote(x, safe="+") for x in self.targets] ), params=NotifyAfricasTalking.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The Application User ID results["appuser"] = NotifyAfricasTalking.unquote(results["user"]) # Prepare our targets results["targets"] = [] # Our Application APIKey if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): # Store our apikey if specified as keyword results["apikey"] = NotifyAfricasTalking.unquote( results["qsd"]["apikey"] ) # This means our host is actually a phone number (target) results["targets"].append( NotifyAfricasTalking.unquote(results["host"]) ) else: # First item is our apikey results["apikey"] = NotifyAfricasTalking.unquote(results["host"]) # Store our remaining targets found on path results["targets"].extend( NotifyAfricasTalking.split_path(results["fullpath"]) ) # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["sender"] = NotifyAfricasTalking.unquote( results["qsd"]["from"] ) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyAfricasTalking.parse_phone_no( results["qsd"]["to"] ) # Get our Mode if "mode" in results["qsd"] and len(results["qsd"]["mode"]): results["mode"] = NotifyAfricasTalking.unquote( results["qsd"]["mode"] ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyAfricasTalking.template_args["batch"]["default"] ) ) return results caronc-apprise-182f859/apprise/plugins/apprise_api.py000066400000000000000000000441711524161175200227150ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging import re import requests from .. import exception from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_PATH_SAFE_CHARS, parse_list, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase class AppriseAPIMethod: """Defines the method to post data tot he remote server.""" JSON = "json" FORM = "form" APPRISE_API_METHODS = ( AppriseAPIMethod.FORM, AppriseAPIMethod.JSON, ) class NotifyAppriseAPI(NotifyBase): """A wrapper for Apprise (Persistent) API Notifications.""" # The default descriptive name associated with the Notification service_name = "Apprise API" # The services URL service_url = "https://github.com/caronc/apprise-api" # The default protocol protocol = "apprise" # The default secure protocol secure_protocol = "apprises" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/apprise_api/" # Support attachments attachment_support = True # Depending on the number of transactions/notifications taking place, this # could take a while. 30 seconds should be enough to perform the task socket_read_timeout = 30.0 # Disable throttle rate for Apprise API requests since they are normally # local anyway request_rate_per_sec = 0.0 # Define object templates templates = ( "{schema}://{host}/{token}", "{schema}://{host}:{port}/{token}", "{schema}://{user}@{host}/{token}", "{schema}://{user}@{host}:{port}/{token}", "{schema}://{user}:{password}@{host}/{token}", "{schema}://{user}:{password}@{host}:{port}/{token}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "token": { "name": _("Token"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9_-]{1,128}$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "tags": { "name": _("Tags"), "type": "string", }, "method": { "name": _("Query Method"), "type": "choice:string", "values": APPRISE_API_METHODS, "default": APPRISE_API_METHODS[0], }, "to": { "alias_of": "token", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, } def __init__( self, token=None, tags=None, method=None, headers=None, **kwargs ): """Initialize Apprise API Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The Apprise API token specified ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.lower() ) if self.method not in APPRISE_API_METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Build list of tags self.__tags = parse_list(tags) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) if self.__tags: params["tags"] = ",".join(list(self.__tags)) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyAppriseAPI.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyAppriseAPI.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 fullpath = self.fullpath.strip("/") return ( "{schema}://{auth}{hostname}{port}{fullpath}{token}" "/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a # valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( "/{}/".format( NotifyAppriseAPI.quote( fullpath, safe=URL_PATH_SAFE_CHARS ) ) if fullpath else "/" ), token=self.pprint(self.token, privacy, safe=""), params=NotifyAppriseAPI.urlencode(params), ) ) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Apprise API Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) attachments = [] files = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Apprise API attachment" f" {attachment.url(privacy=True)}." ) return False try: # Our Attachment filename filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) if self.method == AppriseAPIMethod.JSON: # Output must be in a DataURL format (that's what # PushSafer calls it): attachments.append( { "filename": filename, "base64": attachment.base64(), "mimetype": attachment.mimetype, } ) else: # AppriseAPIMethod.FORM files.append( ( f"file{no:02d}", ( filename, # file handle safely closed in # `finally`; inline open intentional open(attachment.path, "rb"), # noqa: SIM115 attachment.mimetype, ), ) ) except (TypeError, OSError, exception.AppriseException): # We could not access the attachment self.logger.error( "Could not access AppriseAPI attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending AppriseAPI attachment" f" {attachment.url(privacy=True)}" ) # prepare Apprise API Object payload = { # Apprise API Payload "title": title, "body": body, "type": notify_type.value, "format": self.notify_format.value, } if self.method == AppriseAPIMethod.JSON: headers["Content-Type"] = "application/json" if attachments: payload["attachments"] = attachments payload = dumps(payload) if self.__tags: payload["tag"] = self.__tags auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" fullpath = self.fullpath.strip("/") url += "{}".format("/" + fullpath) if fullpath else "" url += f"/notify/{self.token}" # Some entries can not be over-ridden headers.update( { # Our response to be in JSON format always "Accept": "application/json", # Pass our Source UUID4 Identifier "X-Apprise-ID": self.asset._uid, # Pass our current recursion count to our upstream server "X-Apprise-Recursion-Count": str(self.asset._recursion + 1), } ) # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( "Apprise API POST URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug( "Apprise API Payload: %s", sanitize_payload(payload) ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=payload, headers=headers, auth=auth, files=files if files else None, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyAppriseAPI.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Apprise API notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info( "Sent Apprise API notification; method=%s.", self.method ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Apprise API " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False except OSError as e: self.logger.warning( "An I/O error occurred while reading one of the " "attached files." ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: for file in files: # Ensure all files are closed file[1][1].close() return True @staticmethod def parse_native_url(url): """ Support http://hostname/notify/token and http://hostname/path/notify/token """ result = re.match( r"^http(?Ps?)://(?P[A-Z0-9._-]+)" r"(:(?P[0-9]+))?" r"(?P/[^?]+?)?/notify/(?P[A-Z0-9_-]{1,32})/?" r"(?P\?.+)?$", url, re.I, ) if result: return NotifyAppriseAPI.parse_url( "{schema}://{hostname}{port}{path}/{token}/{params}".format( schema=( NotifyAppriseAPI.secure_protocol if result.group("secure") else NotifyAppriseAPI.protocol ), hostname=result.group("hostname"), port=( "" if not result.group("port") else ":{}".format(result.group("port")) ), path=( "" if not result.group("path") else result.group("path") ), token=result.group("token"), params=( "" if not result.group("params") else "?{}".format(result.group("params")) ), ) ) return None @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) for x, y in results["qsd+"].items() } # Support the passing of tags in the URL if "tags" in results["qsd"] and len(results["qsd"]["tags"]): results["tags"] = NotifyAppriseAPI.parse_list( results["qsd"]["tags"] ) # Support the 'to' & 'token' variable so that we can support rooms # this way too. if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyAppriseAPI.unquote( results["qsd"]["token"] ) elif "to" in results["qsd"] and len(results["qsd"]["to"]): results["token"] = NotifyAppriseAPI.unquote(results["qsd"]["to"]) else: # Start with a list of path entries to work with entries = NotifyAppriseAPI.split_path(results["fullpath"]) if entries: # use our last entry found results["token"] = entries[-1] # pop our last entry off entries = entries[:-1] # re-assemble our full path results["fullpath"] = "/".join(entries) # Set method if specified if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyAppriseAPI.unquote( results["qsd"]["method"] ) return results caronc-apprise-182f859/apprise/plugins/aprs.py000066400000000000000000000610141524161175200213610ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # To use this plugin, you need to be a licensed ham radio operator # # Plugin constraints: # # - message length = 67 chars max. # - message content = ASCII 7 bit # - APRS messages will be sent without msg ID, meaning that # ham radio operators cannot acknowledge them # - Bring your own APRS-IS passcode. If you don't know what # this is or how to get it, then this plugin is not for you # - Do NOT change the Device/ToCall ID setting UNLESS this # module is used outside of Apprise. This identifier helps # the ham radio community with determining the software behind # a given APRS message. # - With great (ham radio) power comes great responsibility; do # not use this plugin for spamming other ham radio operators # # In order to digest text input which is not in plain English, # users can install the optional 'unidecode' package as part # of their venv environment. Details: see plugin description # # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - aprs://{user}:{password}@{callsign} # - aprs://{user}:{password}@{callsign1}/{callsign2} # Optional parameters: # - locale --> APRS-IS target server to connect with # Default: EURO --> 'euro.aprs2.net' # Details: https://www.aprs2.net/ # # APRS message format specification: # http://www.aprs.org/doc/APRS101.PDF # import contextlib from itertools import chain import re import socket import sys from .. import __version__ from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_call_sign, parse_call_sign from .base import NotifyBase # Fixed APRS-IS server locales # Default is 'EURO' # See https://www.aprs2.net/ for details # Select the rotating server in case you # don"t care about a specific locale APRS_LOCALES = { "NOAM": "noam.aprs2.net", "SOAM": "soam.aprs2.net", "EURO": "euro.aprs2.net", "ASIA": "asia.aprs2.net", "AUNZ": "aunz.aprs2.net", "ROTA": "rotate.aprs2.net", } # Identify all unsupported characters APRS_BAD_CHARMAP = { r"Γ„": "Ae", r"Γ–": "Oe", r"Ü": "Ue", r"Γ€": "ae", r"ΓΆ": "oe", r"ΓΌ": "ue", r"ß": "ss", } # Our compiled mapping of bad characters APRS_COMPILED_MAP = re.compile(r"(" + "|".join(APRS_BAD_CHARMAP.keys()) + r")") class NotifyAprs(NotifyBase): """A wrapper for APRS Notifications via APRS-IS.""" # The default descriptive name associated with the Notification service_name = "Aprs" # The services URL service_url = "https://www.aprs2.net/" # The default secure protocol secure_protocol = "aprs" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/aprs/" # APRS default port, supported by all core servers # Details: https://www.aprs-is.net/Connecting.aspx notify_port = 10152 # The maximum length of the APRS message body body_maxlen = 67 # Apprise APRS Device ID / TOCALL ID # This is a FIXED value which is associated with this plugin. # Its value MUST NOT be changed. If you use this APRS plugin # code OUTSIDE of Apprise, please request your own TOCALL ID. # Details: see https://github.com/aprsorg/aprs-deviceid # # Do NOT use the generic "APRS" TOCALL ID !!!!! # device_id = "APPRIS" # A title can not be used for APRS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Helps to reduce the number of login-related errors where the # APRS-IS server "isn't ready yet". If we try to receive the rx buffer # without this grace perid in place, we may receive "incomplete" responses # where the login response lacks information. In case you receive too many # "Rx: APRS-IS msg is too short - needs to have at least two lines" error # messages, you might want to increase this value to a larger time span # Per previous experience, do not use values lower than 0.5 (seconds) request_rate_per_sec = 0.8 # Encoding of retrieved content aprs_encoding = "latin-1" # Define object templates templates = ("{schema}://{user}:{password}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_callsign": { "name": _("Target Callsign"), "type": "string", "regex": ( r"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$", "i", ), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "name": _("Target Callsign"), "type": "string", "map_to": "targets", }, "delay": { "name": _("Resend Delay"), "type": "float", "min": 0.0, "max": 5.0, "default": 0.0, }, "locale": { "name": _("Locale"), "type": "choice:string", "values": APRS_LOCALES, "default": "EURO", }, }, ) def __init__(self, targets=None, locale=None, delay=None, **kwargs): """Initialize APRS Object.""" super().__init__(**kwargs) # Our (future) socket sobject self.sock = None # Parse our targets self.targets = [] """ Check if the user has provided credentials """ if not (self.user and self.password): msg = "An APRS user/pass was not provided." self.logger.warning(msg) raise TypeError(msg) """ Check if the user tries to use a read-only access to APRS-IS. We need to send content, meaning that read-only access will not work """ if self.password == "-1": msg = "APRS read-only passwords are not supported." self.logger.warning(msg) raise TypeError(msg) """ Check if the password is numeric """ if not self.password.isnumeric(): msg = "Invalid APRS-IS password" self.logger.warning(msg) raise TypeError(msg) """ Convert given user name (FROM callsign) and device ID to to uppercase """ self.user = self.user.upper() self.device_id = self.device_id.upper() """ Check if the user has provided a locale for the APRS-IS-server and validate it, if necessary """ if locale and locale.upper() not in APRS_LOCALES: msg = ( "Unsupported APRS-IS server locale. " "Received: {}. Valid: {}".format( locale, ", ".join(str(x) for x in APRS_LOCALES) ) ) self.logger.warning(msg) raise TypeError(msg) # Update our delay if delay is None: self.delay = NotifyAprs.template_args["delay"]["default"] else: try: self.delay = float(delay) if ( self.delay < NotifyAprs.template_args["delay"]["min"] or self.delay >= NotifyAprs.template_args["delay"]["max"] ): raise ValueError() except (TypeError, ValueError): msg = f"Unsupported APRS-IS delay ({delay}) specified. " self.logger.warning(msg) raise TypeError(msg) from None # Bump up our request_rate self.request_rate_per_sec += self.delay # Set the transmitter group self.locale = ( NotifyAprs.template_args["locale"]["default"] if not locale else locale.upper() ) # Used for URL generation afterwards only self.invalid_targets = [] for target in parse_call_sign(targets): # Validate targets and drop bad ones # We just need to know if the call sign (including SSID, if # provided) is valid and can then process the input as is result = is_call_sign(target) if not result: self.logger.warning( f"Dropping invalid Amateur radio call sign ({target}).", ) self.invalid_targets.append(target.upper()) continue # Store entry self.targets.append(target.upper()) return def socket_close(self): """Closes the socket connection whereas present.""" if self.sock: with contextlib.suppress(Exception): self.sock.close() self.sock = None def socket_open(self): """Establishes the connection to the APRS-IS socket server.""" self.logger.debug( "Creating socket connection with APRS-IS" f" {APRS_LOCALES[self.locale]}:{self.notify_port}" ) try: self.sock = socket.create_connection( (APRS_LOCALES[self.locale], self.notify_port), self.socket_connect_timeout, ) except ConnectionError as e: self.logger.debug("Socket Exception socket_open: %s", e) self.sock = None return False except socket.gaierror as e: self.logger.debug("Socket Exception socket_open: %s", e) self.sock = None return False except socket.timeout as e: self.logger.debug("Socket Timeout Exception socket_open: %s", e) self.sock = None return False except Exception as e: self.logger.debug("General Exception socket_open: %s", e) self.sock = None return False # We are connected. # getpeername() is not supported by every OS. Therefore, # we MAY receive an exception even though we are # connected successfully. try: # Get the physical host/port of the server host, port = self.sock.getpeername() # and create debug info self.logger.debug(f"Connected to {host}:{port}") except ValueError: # Seens as if we are running on an operating # system that does not support getpeername() # Create a minimal log file entry self.logger.debug("Connected to APRS-IS") # Return success return True def aprsis_login(self): """Generate the APRS-IS login string, send it to the server and parse the response. Returns True/False wrt whether the login was successful """ self.logger.debug("socket_login: init") # Check if we are connected if not self.sock: self.logger.warning("socket_login: Not connected to APRS-IS") return False # APRS-IS login string, see https://www.aprs-is.net/Connecting.aspx login_str = ( f"user {self.user} pass {self.password} vers apprise" f" {__version__}\r\n" ) # Send the data & abort in case of error if not self.socket_send(login_str): self.logger.warning( "socket_login: Login to APRS-IS unsuccessful," " exception occurred" ) self.socket_close() return False rx_buf = self.socket_receive(len(login_str) + 100) # Abort the remaining process in case an error has occurred if not rx_buf: self.logger.warning( "socket_login: Login to APRS-IS " "unsuccessful, exception occurred" ) self.socket_close() return False # APRS-IS sends at least two lines of data # The data that we need is in line #2 so # let's split the content and see what we have rx_lines = rx_buf.splitlines() if len(rx_lines) < 2: self.logger.warning( "socket_login: APRS-IS msg is too short" " - needs to have at least two lines" ) self.socket_close() return False # Now split the 2nd line's content and extract # both call sign and login status try: _, _, callsign, status, _ = rx_lines[1].split(" ", 4) except ValueError: # ValueError is returned if there were not enough elements to # populate the response self.logger.warning( "socket_login: received invalid response from APRS-IS" ) self.socket_close() return False if callsign != self.user: self.logger.warning(f"socket_login: call signs differ: {callsign}") self.socket_close() return False if status.startswith("unverified"): self.logger.warning( "socket_login: invalid APRS-IS password for given call sign" ) self.socket_close() return False # all validations are successful; we are connected return True def socket_send(self, tx_data): """Generic "Send data to a socket".""" self.logger.debug("socket_send: init") # Check if we are connected if not self.sock: self.logger.warning("socket_send: Not connected to APRS-IS") return False # Encode our data if we are on Python3 or later payload = ( tx_data.encode("utf-8") if sys.version_info[0] >= 3 else tx_data ) # Always call throttle before any remote server i/o is made self.throttle() # Try to open the socket # Send the content to APRS-IS try: self.sock.setblocking(True) self.sock.settimeout(self.socket_connect_timeout) self.sock.sendall(payload) except socket.gaierror as e: self.logger.warning(f"Socket Exception socket_send: {e!s}") self.sock = None return False except socket.timeout as e: self.logger.warning(f"Socket Timeout Exception socket_send: {e!s}") self.sock = None return False except Exception as e: self.logger.warning(f"General Exception socket_send: {e!s}") self.sock = None return False self.logger.debug("socket_send: successful") # mandatory on several APRS-IS servers # helps to reduce the number of errors where # the server only returns an abbreviated message return True def socket_reset(self): """Resets the socket's buffer.""" self.logger.debug("socket_reset: init") _ = self.socket_receive(0) self.logger.debug("socket_reset: successful") return True def socket_receive(self, rx_len): """Generic "Receive data from a socket".""" self.logger.debug("socket_receive: init") # Check if we are connected if not self.sock: self.logger.warning("socket_receive: not connected to APRS-IS") return False # len is zero in case we intend to # reset the socket if rx_len > 0: self.logger.debug("socket_receive: Receiving data from APRS-IS") # Receive content from the socket try: self.sock.setblocking(False) self.sock.settimeout(self.socket_connect_timeout) rx_buf = self.sock.recv(rx_len) except socket.gaierror as e: self.logger.warning(f"Socket Exception socket_receive: {e!s}") self.sock = None return False except socket.timeout as e: self.logger.warning( f"Socket Timeout Exception socket_receive: {e!s}" ) self.sock = None return False except Exception as e: self.logger.warning(f"General Exception socket_receive: {e!s}") self.sock = None return False rx_buf = ( rx_buf.decode(self.aprs_encoding) if sys.version_info[0] >= 3 else rx_buf ) # There will be no data in case we reset the socket if rx_len > 0: self.logger.debug(f"Received content: {rx_buf}") self.logger.debug("socket_receive: successful") return rx_buf.rstrip() def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform APRS Notification.""" if not self.targets: # There is no one to notify; we're done self.logger.warning( "There are no amateur radio call signs to notify" ) return False # prepare payload payload = body # sock object is "None" if we were unable to establish a connection # In case of errors, the error message has already been sent # to the logger object if not self.socket_open(): return False # We have established a successful connection # to the socket server. Now send the login information if not self.aprsis_login(): return False # Login & authorization confirmed # reset what is in our buffer self.socket_reset() # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) self.logger.debug("Starting Payload setup") # Prepare the outgoing message # Due to APRS's contraints, we need to do # a lot of filtering before we can send # the actual message # # First remove all characters from the # payload that would break APRS # see https://www.aprs.org/doc/APRS101.PDF pg. 71 payload = re.sub(r"[{}|~]+", "", payload) payload = APRS_COMPILED_MAP.sub( # pragma: no branch lambda x: APRS_BAD_CHARMAP[x.group()], payload ) # Finally, constrain output string to 67 characters as # APRS messages are limited in length payload = payload[:67] # Our outgoing message MUST end with a CRLF so # let's amend our payload respectively payload = payload.rstrip("\r\n") + "\r\n" self.logger.debug(f"Payload setup complete: {payload}") # send the message to our target call sign(s) for index in range(0, len(targets)): # prepare the output string # Format: # Device ID/TOCALL - our call sign - target call sign - body buffer = ( f"{self.user}>{self.device_id}::{targets[index]:9}:{payload}" ) # and send the content to the socket # Note that there will be no response from APRS and # that all exceptions are handled within the 'send' method self.logger.debug(f"Sending APRS message: {buffer}") # send the content if not self.socket_send(buffer): has_error = True break # Finally, reset our socket buffer # we DO NOT read from the socket as we # would simply listen to the default APRS-IS stream self.socket_reset() self.logger.debug("Closing socket.") self.socket_close() self.logger.info( "Sent %d/%d APRS-IS notification(s)", index + 1, len(targets) ) return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self.locale != NotifyAprs.template_args["locale"]["default"]: # Store our locale if not default params["locale"] = self.locale if self.delay != NotifyAprs.template_args["delay"]["default"]: # Store our locale if not default params["delay"] = f"{self.delay:.2f}" # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyAprs.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( chain( [self.pprint(x, privacy, safe="") for x in self.targets], [ self.pprint(x, privacy, safe="") for x in self.invalid_targets ], ) ), params=NotifyAprs.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.user, self.password, self.locale) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) return targets if targets > 0 else 1 def __del__(self): """Ensure we close any lingering connections.""" self.socket_close() @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyAprs.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend(NotifyAprs.split_path(results["fullpath"])) # Get Delay (if set) if "delay" in results["qsd"] and len(results["qsd"]["delay"]): results["delay"] = NotifyAprs.unquote(results["qsd"]["delay"]) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyAprs.parse_list(results["qsd"]["to"]) # Set our APRS-IS server locale's key value and convert it to uppercase if "locale" in results["qsd"] and len(results["qsd"]["locale"]): results["locale"] = NotifyAprs.unquote( results["qsd"]["locale"] ).upper() return results caronc-apprise-182f859/apprise/plugins/bark.py000066400000000000000000000612671524161175200213450ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # API: https://github.com/Finb/bark-server/blob/master/docs/API_V2.md#python # import base64 import json import secrets import requests try: from cryptography.hazmat.primitives.ciphers.aead import AESGCM BARK_AESGCM_SUPPORT = True except ImportError: BARK_AESGCM_SUPPORT = False from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool, parse_list from .base import NotifyBase # Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds BARK_SOUNDS = ( "alarm.caf", "anticipate.caf", "bell.caf", "birdsong.caf", "bloom.caf", "calypso.caf", "chime.caf", "choo.caf", "descent.caf", "electronic.caf", "fanfare.caf", "glass.caf", "gotosleep.caf", "healthnotification.caf", "horn.caf", "ladder.caf", "mailsent.caf", "minuet.caf", "multiwayinvitation.caf", "newmail.caf", "newsflash.caf", "noir.caf", "paymentsuccess.caf", "shake.caf", "sherwoodforest.caf", "silence.caf", "spell.caf", "suspense.caf", "telegraph.caf", "tiptoes.caf", "typewriters.caf", "update.caf", ) # Supported Level Entries class NotifyBarkLevel: """Defines the Bark Level options.""" ACTIVE = "active" TIME_SENSITIVE = "timeSensitive" PASSIVE = "passive" CRITICAL = "critical" BARK_LEVELS = ( NotifyBarkLevel.ACTIVE, NotifyBarkLevel.TIME_SENSITIVE, NotifyBarkLevel.PASSIVE, NotifyBarkLevel.CRITICAL, ) BARK_AES_KEY_LENGTHS = frozenset({16, 24, 32}) BARK_GCM_IV_RANDOM_BYTES = 9 BARK_GCM_IV_LENGTH = 12 class NotifyBark(NotifyBase): """A wrapper for Notify Bark Notifications.""" # The default descriptive name associated with the Notification service_name = "Bark" requirements = { "packages_recommended": "cryptography", } # The services URL service_url = "https://github.com/Finb/Bark" # The default protocol protocol = "bark" # The default secure protocol secure_protocol = "barks" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bark/" # Allows the user to specify the NotifyImageSize object; this is supported # through the webhook image_size = NotifyImageSize.XY_128 # Define object templates templates = ( "{schema}://{host}/{targets}", "{schema}://{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "target_device": { "name": _("Target Device"), "type": "string", "map_to": "targets", "private": True, }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "sound": { "name": _("Sound"), "type": "choice:string", "values": BARK_SOUNDS, }, "level": { "name": _("Level"), "type": "choice:string", "values": BARK_LEVELS, }, "volume": { "name": _("Volume"), "type": "int", "min": 0, "max": 10, }, "click": { "name": _("Click"), "type": "string", }, "badge": { "name": _("Badge"), "type": "int", "min": 0, }, "category": { "name": _("Category"), "type": "string", }, "group": { "name": _("Group"), "type": "string", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "icon": { "name": _("Icon URL"), "type": "string", }, "call": { "name": _("Call"), "type": "bool", "default": False, }, "key": { "name": _("Encryption Key"), "type": "string", "private": True, "map_to": "encryption_key", }, "to": { "alias_of": "targets", }, }, ) def __init__( self, targets=None, include_image=True, sound=None, category=None, group=None, level=None, click=None, badge=None, volume=None, icon=None, call=None, encryption_key=None, **kwargs, ): """Initialize Notify Bark Object.""" super().__init__(**kwargs) # Prepare our URL self.notify_url = "{}://{}{}/push".format( "https" if self.secure else "http", self.host, ( f":{self.port}" if (self.port and isinstance(self.port, int)) else "" ), ) # Assign our category self.category = category if isinstance(category, str) else None # Assign our group self.group = group if isinstance(group, str) else None # Initialize device list self.targets = parse_list(targets) # Place an image inline with the message body self.include_image = include_image # A clickthrough option for notifications self.click = click # Badge try: # Acquire our badge count if we can: # - We accept both the integer form as well as a string # representation self.badge = int(badge) if self.badge < 0: raise ValueError() except TypeError: # NoneType means use Default; this is an okay exception self.badge = None except ValueError: self.badge = None self.logger.warning( "The specified Bark badge ({}) is not valid ", badge ) # Sound (easy-lookup) self.sound = ( None if not sound else next( (f for f in BARK_SOUNDS if f.startswith(sound.lower())), None ) ) if sound and not self.sound: self.logger.warning( "The specified Bark sound ({}) was not found ", sound ) # Volume self.volume = None if volume is not None: try: self.volume = int(volume) if volume is not None else None if self.volume is not None and not (0 <= self.volume <= 10): raise ValueError() except (TypeError, ValueError): self.logger.warning( "The specified Bark volume ({}) is not valid. " "Must be between 0 and 10", volume, ) # Call self.call = parse_bool(call) # Encryption is off unless an encryption key was explicitly given self.encryption_key = None self._cipher = None if encryption_key: # The Bark app expects a raw ASCII key, so reject anything # that can't be represented as one up front try: encryption_key_bytes = encryption_key.encode("ascii") except (AttributeError, UnicodeEncodeError): msg = ( "The Bark encryption key must contain only ASCII " "characters." ) self.logger.warning(msg) raise TypeError(msg) from None # AES-GCM only accepts 128/192/256-bit (16/24/32 byte) keys if len(encryption_key_bytes) not in BARK_AES_KEY_LENGTHS: msg = ( "The Bark encryption key must contain exactly 16, 24, or " "32 ASCII characters." ) self.logger.warning(msg) raise TypeError(msg) # Fail closed; never fall back to sending plaintext when the # caller asked for encryption but the library isn't available if not BARK_AESGCM_SUPPORT: msg = ( "Bark encryption requires the 'cryptography' package. " "Install Apprise with the 'all-plugins' extra." ) self.logger.warning(msg) raise TypeError(msg) # We're ready to encrypt every outbound payload with this key self.encryption_key = encryption_key self._cipher = AESGCM(encryption_key_bytes) # Icon URL self.icon = icon if isinstance(icon, str) else None # Level self.level = ( None if not level else next((f for f in BARK_LEVELS if f[0] == level[0]), None) ) if level and not self.level: self.logger.warning( "The specified Bark level ({}) is not valid ", level ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Bark Notification.""" # error tracking (used for function return) has_error = False if not self.targets: # We have nothing to notify; we're done self.logger.warning("There are no Bark devices to notify") return False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # Prepare our payload (sample below) # { # "body": "Test Bark Server", # "markdown": "# Markdown Content", # "device_key": "nysrshcqielvoxsa", # "title": "bleem", # "category": "category", # "sound": "minuet.caf", # "badge": 1, # "icon": "https://day.app/assets/images/avatar.jpg", # "group": "test", # "level": "active", # "volume": 5, # "call": 1, # "url": "https://mritd.com" # } payload = { "title": title if title else self.app_desc, } if self.notify_format == NotifyFormat.MARKDOWN: payload["markdown"] = body else: payload["body"] = body # Acquire our image url if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) # Use custom icon if provided, otherwise use default image if self.icon: payload["icon"] = self.icon elif image_url: payload["icon"] = image_url if self.sound: payload["sound"] = self.sound if self.click: payload["url"] = self.click if self.badge: payload["badge"] = self.badge if self.level: payload["level"] = self.level if self.category: payload["category"] = self.category if self.group: payload["group"] = self.group if self.volume: payload["volume"] = self.volume if self.call: payload["call"] = 1 auth = None if self.user: auth = (self.user, self.password) # Create a copy of the targets targets = list(self.targets) while len(targets) > 0: # Retrieve our device key target = targets.pop() private_target = self.pprint( target, privacy=True, mode=PrivacyMode.Secret, safe="", ) if self._cipher is not None: # Encrypt the payload; on success this replaces the # plaintext fields with a device_key/ciphertext/iv wire # payload for this target try: ciphertext, iv = self._encrypt_payload(payload) request_payload = { "device_key": target, "ciphertext": ciphertext, "iv": iv, } except Exception as e: # Fail closed; never fall back to sending this # target's notification as plaintext self.logger.warning("Failed to encrypt Bark notification.") self.logger.debug( "Bark encryption failed with %s.", type(e).__name__ ) has_error = True continue else: # Plaintext payload; just tag on this target's device key request_payload = {"device_key": target, **payload} self.logger.debug( "Bark POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r}, " f"encrypted={self._cipher is not None!r})" ) if self._cipher is None: # Never log ciphertext/iv; there's nothing readable in it self.logger.debug(f"Bark Payload: {request_payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(request_payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBark.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Bark notification to {}: " "{}{}error={}.".format( private_target, status_str, ", " if status_str else "", r.status_code, ) ) if self._cipher is None: # The response body is only safe to log when we # sent a plaintext request in the first place self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) # Mark our failure has_error = True continue else: self.logger.info( f"Sent Bark notification to {private_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Bark " f"notification to {private_target}." ) if self._cipher is None: # Suppress low-level exception text once encryption # is active; it may echo back request fragments self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def _encrypt_payload(self, payload): """Encrypt one Bark parameter object with a fresh AES-GCM IV.""" # Generate a fresh IV for every request; Bark's client rebuilds # this string as raw bytes, so it must land on exactly 12 ASCII # characters (a 96-bit GCM nonce) iv = secrets.token_urlsafe(BARK_GCM_IV_RANDOM_BYTES) if len(iv) != BARK_GCM_IV_LENGTH or not iv.isascii(): raise ValueError("Bark generated an incompatible AES-GCM IV") # Bark decrypts a compact JSON object back into its parameters plaintext = json.dumps( payload, ensure_ascii=False, separators=(",", ":"), ).encode("utf-8") # No additional authenticated data; tag is appended to the # ciphertext automatically (GCM "combined" mode) ciphertext = self._cipher.encrypt( iv.encode("ascii"), plaintext, None, ) # Bark expects the ciphertext base64-encoded and the IV as-is return base64.b64encode(ciphertext).decode("ascii"), iv @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port, self.encryption_key, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "image": "yes" if self.include_image else "no", } if self.sound: params["sound"] = self.sound if self.click: params["click"] = self.click if self.badge: params["badge"] = str(self.badge) if self.level: params["level"] = self.level if self.volume: params["volume"] = str(self.volume) if self.category: params["category"] = self.category if self.group: params["group"] = self.group if self.icon: params["icon"] = self.icon if self.call: params["call"] = "yes" if self.encryption_key: params["key"] = self.pprint( self.encryption_key, privacy, mode=PrivacyMode.Secret, safe="", ) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyBark.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyBark.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), targets="/".join( [ self.pprint( f"{x}", privacy, mode=PrivacyMode.Secret, safe="", ) for x in self.targets ] ), params=NotifyBark.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.targets) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Apply our targets results["targets"] = NotifyBark.split_path(results["fullpath"]) # Category if "category" in results["qsd"] and results["qsd"]["category"]: results["category"] = NotifyBark.unquote( results["qsd"]["category"].strip() ) # Group if "group" in results["qsd"] and results["qsd"]["group"]: results["group"] = NotifyBark.unquote( results["qsd"]["group"].strip() ) # Badge if "badge" in results["qsd"] and results["qsd"]["badge"]: results["badge"] = NotifyBark.unquote( results["qsd"]["badge"].strip() ) # Volume if "volume" in results["qsd"] and results["qsd"]["volume"]: results["volume"] = NotifyBark.unquote( results["qsd"]["volume"].strip() ) # Level if "level" in results["qsd"] and results["qsd"]["level"]: results["level"] = NotifyBark.unquote( results["qsd"]["level"].strip() ) # Click (URL) if "click" in results["qsd"] and results["qsd"]["click"]: results["click"] = NotifyBark.unquote( results["qsd"]["click"].strip() ) # Sound if "sound" in results["qsd"] and results["qsd"]["sound"]: results["sound"] = NotifyBark.unquote( results["qsd"]["sound"].strip() ) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBark.parse_list(results["qsd"]["to"]) # use image= for consistency with the other plugins results["include_image"] = parse_bool( results["qsd"].get("image", True) ) # Icon URL if "icon" in results["qsd"] and results["qsd"]["icon"]: results["icon"] = NotifyBark.unquote( results["qsd"]["icon"].strip() ) # Call results["call"] = parse_bool(results["qsd"].get("call", False)) # Encryption Key if "key" in results["qsd"] and results["qsd"]["key"]: results["encryption_key"] = NotifyBark.unquote( results["qsd"]["key"] ) return results @staticmethod def runtime_deps(): """Return optional runtime dependency package names.""" return ("cryptography",) caronc-apprise-182f859/apprise/plugins/base.py000066400000000000000000001456651524161175200213450ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import asyncio from collections.abc import Generator from datetime import tzinfo from functools import partial import math import re from typing import Any, ClassVar, Optional, TypedDict, Union from zoneinfo import ZoneInfo from ..apprise_attachment import AppriseAttachment from ..common import ( APPRISE_MAX_SERVICE_RETRY, APPRISE_MAX_SERVICE_WAIT, NOTIFY_FORMATS, OVERFLOW_MODES, NotifyFormat, NotifyImageSize, NotifyType, OverflowMode, PersistentStoreMode, ) from ..locale import Translatable, gettext_lazy as _ from ..persistent_store import PersistentStore from ..url import URLBase from ..utils.format import smart_split from ..utils.parse import parse_bool from ..utils.time import zoneinfo class RequirementsSpec(TypedDict, total=False): """Defines our plugin requirements.""" packages_required: Optional[Union[str, list[str]]] packages_recommended: Optional[Union[str, list[str]]] details: Optional[Translatable] class NotifyBase(URLBase): """This is the base class for all notification services.""" # An internal flag used to test the state of the plugin. If set to # False, then the plugin is not used. Plugins can disable themselves # due to enviroment issues (such as missing libraries, or platform # dependencies that are not present). By default all plugins are # enabled. enabled = True @staticmethod def runtime_deps(): """Return a tuple of top-level Python package names that this plugin imported as optional runtime dependencies. The plugin manager uses this to maintain a reference counter per library. When every plugin that declared a given library is disabled, its counter reaches zero and the manager evicts the library from `sys.modules`, releasing the associated Python objects from memory. Names must be the importable top-level namespace - the same string you would pass to `import` - not the pip install name: ('paho',) # paho-mqtt installs as 'paho' ('slixmpp',) ('cryptography',) Submodules are handled automatically; declaring the top-level name is sufficient. Override this in any plugin that conditionally imports a heavy optional library. Return an empty tuple (the default) when the plugin has no optional dependencies that are worth evicting. """ return () @classmethod def enable(self): """Mark this plugin as enabled. This is the counterpart to :meth:`disable`. Calling this restores the plugin to an active state so it will be used for notifications again. Note that if the plugin's runtime dependencies were evicted from memory by the plugin manager, re-enabling will restore the flag but the plugin may not function until the process is restarted. """ self.enabled = True @classmethod def disable(self): """Mark this plugin as disabled. The plugin will not be used for notifications. The plugin manager calls this when honouring `APPRISE_DENY_SERVICES` / `APPRISE_ALLOW_SERVICES` and uses the result of :method:`runtime_deps` to decrement its per-library reference counters, potentially evicting unused libraries from `sys.modules`. """ self.enabled = False # The category allows for parent inheritance of this object to alter # this when it's function/use is intended to behave differently. The # following category types exist: # # native: Is a native plugin written/stored in `apprise/plugins/Notify*` # custom: Is a custom plugin written/stored in a users plugin directory # that they loaded at execution time. category = "native" # Some plugins may require additional packages above what is provided # already by Apprise. # # Use this section to relay this information to the users of the script to # help guide them with what they need to know if they plan on using your # plugin. The below configuration should otherwise accommodate all normal # situations and will not requrie any updating: requirements: ClassVar[RequirementsSpec] = { # Use the description to provide a human interpretable description of # what is required to make the plugin work. This is only nessisary # if there are package dependencies. Setting this to default will # cause a general response to be returned. Only set this if you plan # on over-riding the default. Always consider language support here. # So before providing a value do the following in your code base: # # from apprise.AppriseLocale import gettext_lazy as _ # # 'details': _('My detailed requirements') "details": None, # Define any required packages needed for the plugin to run. This is # an array of strings that simply look like lines residing in a # `requirements.txt` file... # # As an example, an entry may look like: # 'packages_required': [ # 'cryptography < 3.4`, # ] "packages_required": [], # Recommended packages identify packages that are not required to make # your plugin work, but would improve it's use or grant it access to # full functionality (that might otherwise be limited). # Similar to `packages_required`, you would identify each entry in # the array as you would in a `requirements.txt` file. # # - Do not re-provide entries already in the `packages_required` "packages_recommended": [], } # The services URL service_url = None # A URL that takes you to the setup/help of the specific protocol setup_url = None # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives # us a safe play range. Override the one defined already in the URLBase request_rate_per_sec = 5.5 # Allows the user to specify the NotifyImageSize object image_size = None # The maximum allowable characters allowed in the body per message body_maxlen = 32768 # Defines the maximum allowable characters in the title; set this to zero # if a title can't be used. Titles that are not used but are defined are # automatically placed into the body title_maxlen = 250 # Set the maximum line count; if this is set to anything larger then zero # the message (prior to it being sent) will be truncated to this number # of lines. Setting this to zero disables this feature. body_max_line_count = 0 # Persistent storage default settings persistent_storage = True # Timezone Default; by setting it to None, the timezone detected # on the server is used timezone = None # Default Notify Format notify_format = NotifyFormat.TEXT # Default Overflow Mode overflow_mode = OverflowMode.UPSTREAM # Our default is to no not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.MEMORY # Default Emoji Interpretation interpret_emojis = False # Default retry count for failed notifications. Plugins may override # via ?retry=N in their URL; the asset's default_service_retry is used # when no per-plugin value is set. # Always in [0, APPRISE_MAX_SERVICE_RETRY]. service_retry = 0 # Default wait (seconds) between retry attempts. Plugins may override # via ?wait=N in their URL; the asset's default_service_wait is used when # no per-plugin value is set. Always in [0.0, APPRISE_MAX_SERVICE_WAIT]. service_wait = 0.0 # When set to True, a delivery failure for this individual service is # silently absorbed by all three dispatch paths (sequential, thread-pool, # and asyncio coroutine). The overall notify() / async_notify() call # still returns True even if this particular endpoint could not be # reached, provided that every *required* (non-optional) service in # the same batch succeeded. # # This flag is designed for "nice to have" services -- endpoints that # you want to notify when they are available but whose unavailability # must not be treated as a delivery failure by the calling application. # # Concrete example -- four Kodi home screens tagged "media": # # Configure every Kodi entry with optional=yes. When the application # calls notify(tag="media"), Apprise attempts delivery to whichever # screens are currently powered on and reachable. If none of the four # respond (all are off or unreachable), the call still returns True # because every failing service was optional. The application is # never penalised for transient screen availability. # # Important behaviour details: # - Retries are still performed up to the configured retry count # before a service is considered to have failed. The optional flag # only fires *after* all retry attempts have been exhausted without # a successful delivery. In other words, optional does not bypass # the retry loop -- it only changes the meaning of the final result. # - Setting optional=True does NOT skip the service. Delivery is # still attempted on every call. Only the *outcome* is affected: # a failure is reported as True instead of False to the caller. # - A batch that mixes optional and required services returns False # only if one of the *required* (optional=False) services fails. # Optional failures are never included in the aggregate result. # - The flag is per-instance. Different URLs that share a tag can # have different optional values, so you can mix required and # optional endpoints within the same tag group. # # Ways to enable this flag: # - URL query parameter : append ?optional=yes to the service URL # - YAML configuration : add optional: yes under the URL entry # - Python constructor : MyPlugin(host="...", optional=True) # - Direct assignment : server.optional = True # # Accepted truthy values : "yes", "true", "on", "1", True # Accepted falsy values : "no", "false", "off", "0", False # # Defaults to False -- every delivery failure is propagated to the # caller unchanged. optional = False # Support Attachments; this defaults to being disabled. # Since apprise allows you to send attachments without a body or title # defined, by letting Apprise know the plugin won't support attachments # up front, it can quickly pass over and ignore calls to these end points. # You must set this to true if your application can handle attachments. # You must also consider a flow change to your notification if this is set # to True as well as now there will be cases where both the body and title # may not be set. There will never be a case where a body, or attachment # isn't set in the same call to your notify() function. attachment_support = False # Default Title HTML Tagging # When a title is specified for a notification service that doesn't accept # titles, by default apprise tries to give a plesant view and convert the # title so that it can be placed into the body. The default is to just # use a tag. The below causes the title to get generated: default_html_tag_id = "b" # Here is where we define all of the arguments we accept on the url # such as: schema://whatever/?overflow=upstream&format=text # These act the same way as tokens except they are optional and/or # have default values set if mandatory. This rule must be followed template_args = dict( URLBase.template_args, **{ "overflow": { "name": _("Overflow Mode"), "type": "choice:string", "values": OVERFLOW_MODES, # Provide a default "default": overflow_mode, # look up default using the following parent class value at # runtime. The variable name identified here (in this case # overflow_mode) is checked and it's result is placed over-top # of the 'default'. This is done because once a parent class # inherits this one, the overflow_mode already set as a default # 'could' be potentially over-ridden and changed to a different # value. "_lookup_default": "overflow_mode", }, "format": { "name": _("Notify Format"), "type": "choice:string", "values": NOTIFY_FORMATS, # Provide a default "default": notify_format, # look up default using the following parent class value at # runtime. "_lookup_default": "notify_format", }, "emojis": { "name": _("Interpret Emojis"), # SSL Certificate Authority Verification "type": "bool", # Provide a default "default": interpret_emojis, # look up default using the following parent class value at # runtime. "_lookup_default": "interpret_emojis", }, "store": { "name": _("Persistent Storage"), # Use Persistent Storage "type": "bool", # Provide a default "default": persistent_storage, # look up default using the following parent class value at # runtime. "_lookup_default": "persistent_storage", }, "tz": { "name": _("Timezone"), "type": "string", # Provide a default "default": timezone, # look up default using the following parent class value at # runtime. "_lookup_default": "timezone", }, "retry": { "name": _("Service Retry"), "type": "int", "min": 0, "max": APPRISE_MAX_SERVICE_RETRY, "default": service_retry, "_lookup_default": "service_retry", }, "wait": { "name": _("Inter-Retry Wait"), "type": "float", "min": 0.0, "max": APPRISE_MAX_SERVICE_WAIT, "default": service_wait, "_lookup_default": "service_wait", }, "optional": { "name": _("Optional Service"), "type": "bool", "default": optional, "_lookup_default": "optional", }, }, ) # # Overflow Defaults / Configuration applicable to SPLIT mode only # # Display Count [X/X] # ^^^^^^ # \\\\\\ # 6 characters (space + count) # Display Count [XX/XX] # ^^^^^^^^ # \\\\\\\\ # 8 characters (space + count) # Display Count [XXX/XXX] # ^^^^^^^^^^ # \\\\\\\\\\ # 10 characters (space + count) # Display Count [XXXX/XXXX] # ^^^^^^^^^^^^ # \\\\\\\\\\\\ # 12 characters (space + count) # # Given the above + some buffer we come up with the following: # If this value is exceeded, display counts automatically shut off overflow_max_display_count_width = 12 # The number of characters to reserver for whitespace buffering # This is detected automatically, but you can enforce a value if # you desire: overflow_buffer = 0 # the min accepted length of a title to allow for a counter display overflow_display_count_threshold = 130 # Whether or not when over-flow occurs, if the title should be repeated # each time the message is split up # - None: Detect # - True: Always display title once # - False: Display the title for each occurance overflow_display_title_once = None # If this is set to to True: # The title_maxlen should be considered as a subset of the body_maxlen # Hence: len(title) + len(body) should never be greater then body_maxlen # # If set to False, then there is no corrorlation between title_maxlen # restrictions and that of body_maxlen overflow_amalgamate_title = False # Identifies the timezone to use; if this is not over-ridden, then the # timezone defined in the AppriseAsset() object is used instead. The # Below is expected to be in a ZoneInfo type already. You can have this # automatically initialized by specifying ?tz= on the Apprise URLs __tzinfo = None @classmethod def __init_subclass__(cls, **kwargs): """Automatically wrap any __len__ defined on a subclass so that it multiplies its base target count by (retry + 1). This ensures every plugin's __len__ reflects the total number of transmission attempts (targets * retry-factor) without requiring each plugin to be updated individually. """ super().__init_subclass__(**kwargs) if "__len__" in cls.__dict__: _orig = cls.__dict__["__len__"] def _retry_aware_len(self, _orig=_orig): return _orig(self) * (getattr(self, "retry", 0) + 1) cls.__len__ = _retry_aware_len def __init__(self, **kwargs): """Initialize some general configuration that will keep things consistent when working with the notifiers that will inherit this class.""" super().__init__(**kwargs) # Initialize retry count from (in priority order): # 1. kwargs["retry"] (from ?retry= URL param or YAML retry: key) # 2. asset.default_service_retry # Always clamped to [0, APPRISE_MAX_SERVICE_RETRY]. _retry_raw = kwargs.get("retry") if _retry_raw is not None: try: _retry_val = int(_retry_raw) if _retry_val < 0: msg = f"Service retry count must be >= 0; got {_retry_raw}" self.logger.warning(msg) raise TypeError(msg) self.retry = min(_retry_val, APPRISE_MAX_SERVICE_RETRY) except (TypeError, ValueError): self.logger.warning( "Invalid retry value specified (%s); using default", _retry_raw, ) self.retry = min( max(0, self.asset.default_service_retry), APPRISE_MAX_SERVICE_RETRY, ) else: self.retry = min( max(0, self.asset.default_service_retry), APPRISE_MAX_SERVICE_RETRY, ) # Initialize wait (inter-retry delay) from (in priority order): # 1. kwargs["wait"] (from ?wait= URL param or YAML wait: key) # 2. asset.default_service_wait # Only valid non-negative finite floats are accepted; integers are # promoted to float. Invalid values fall back to the asset default. # Negative values raise TypeError. _wait_raw = kwargs.get("wait") if _wait_raw is not None: try: _wait_val = float(_wait_raw) if not math.isfinite(_wait_val) or _wait_val < 0.0: msg = f"Service retry wait must be >= 0; got {_wait_raw}" self.logger.warning(msg) raise TypeError(msg) self.wait = min(_wait_val, APPRISE_MAX_SERVICE_WAIT) except (TypeError, ValueError): self.logger.warning( "Invalid wait value specified (%s); using default", _wait_raw, ) self.wait = min( max(0.0, self.asset.default_service_wait), APPRISE_MAX_SERVICE_WAIT, ) else: self.wait = min( max(0.0, self.asset.default_service_wait), APPRISE_MAX_SERVICE_WAIT, ) # Initialize the optional= flag from kwargs if provided, otherwise # fall back to the class-level attribute (default: False). # # Priority order (highest to lowest): # 1. kwargs["optional"] -- supplied via ?optional=yes/no URL # parameter, YAML optional: yes/no key, or a direct Python # keyword argument to the constructor. # 2. The class-level 'optional' attribute on the plugin class # (or any of its superclasses). Plugin authors can ship # with optional=True as the default simply by overriding # the class attribute -- no extra __init__ boilerplate needed. # # parse_bool() is used for the string -> bool conversion so that # all of the canonical truthy/falsy representations that Apprise # accepts elsewhere are valid here too: "yes"/"no", "true"/"false", # "on"/"off", "1"/"0", and native Python booleans. The function # returns the 'default' argument (False) for unrecognised strings, # so garbage input silently falls back to the safe behaviour # (propagate failures) rather than raising an exception. _optional_raw = kwargs.get("optional") if _optional_raw is not None: # An explicit value was passed in -- parse it and store on # this instance, shadowing the class-level attribute only for # this particular service object. self.optional = parse_bool(_optional_raw) # else: kwargs did not include "optional". Leave self.optional # unset so that normal Python attribute lookup falls through to # the class-level default (NotifyBase.optional = False, or the # value set by a plugin subclass that overrides it). # Store our interpret_emoji's setting # If asset emoji value is set to a default of True and the user # specifies it to be false, this is accepted and False over-rides. # # If asset emoji value is set to a default of None, a user may # optionally over-ride this and set it to True from the Apprise # URL. ?emojis=yes # # If asset emoji value is set to a default of False, then all emoji's # are turned off (no user over-rides allowed) # # Our Persistent Storage object is initialized on demand self.__store = None # Take a default self.interpret_emojis = self.asset.interpret_emojis if "emojis" in kwargs: # possibly over-ride default self.interpret_emojis = bool( self.interpret_emojis in (None, True) and parse_bool( kwargs.get("emojis", False), default=NotifyBase.template_args["emojis"]["default"], ) ) if "format" in kwargs: value = kwargs["format"] try: self.notify_format = ( value if isinstance(value, NotifyFormat) else NotifyFormat(value.lower()) ) except (AttributeError, ValueError): err = ( f"An invalid notification format ({value}) was specified." ) self.logger.warning(err) raise TypeError(err) from None if "tz" in kwargs: value = kwargs["tz"] self.__tzinfo = zoneinfo(value) if not self.__tzinfo: err = ( "An invalid notification timezone " f"({value}) was specified." ) self.logger.warning(err) raise TypeError(err) from None if "overflow" in kwargs: value = kwargs["overflow"] try: self.overflow_mode = ( value if isinstance(value, OverflowMode) else OverflowMode(value.lower()) ) except (AttributeError, ValueError): err = f"An invalid overflow method ({value}) was specified." self.logger.warning(err) raise TypeError(err) from None # Prepare our Persistent Storage switch self.persistent_storage = parse_bool( kwargs.get("store", NotifyBase.persistent_storage) ) if not self.persistent_storage: # Enforce the disabling of cache (ortherwise defaults are use) self.url_identifier = False self.__cached_url_identifier = None def image_url( self, notify_type: NotifyType, image_size: Optional[NotifyImageSize] = None, logo: bool = False, extension: Optional[str] = None, ) -> Optional[str]: """Returns Image URL if possible.""" image_size = self.image_size if image_size is None else image_size if not image_size: return None return self.asset.image_url( notify_type=notify_type, image_size=image_size, logo=logo, extension=extension, ) def image_path( self, notify_type: NotifyType, extension: Optional[str] = None, ) -> Optional[str]: """Returns the path of the image if it can.""" if not self.image_size: return None return self.asset.image_path( notify_type=notify_type, image_size=self.image_size, extension=extension, ) def image_raw( self, notify_type: NotifyType, extension: Optional[str] = None, ) -> Optional[bytes]: """Returns the raw image if it can.""" if not self.image_size: return None return self.asset.image_raw( notify_type=notify_type, image_size=self.image_size, extension=extension, ) def color( self, notify_type: NotifyType, color_type: Optional[type] = None, ) -> Union[str, int, tuple[int, int, int]]: """Returns the html color (hex code) associated with the notify_type.""" return self.asset.color( notify_type=notify_type, color_type=color_type, ) def ascii( self, notify_type: NotifyType, ) -> str: """Returns the ascii characters associated with the notify_type.""" return self.asset.ascii( notify_type=notify_type, ) def notify(self, *args: Any, **kwargs: Any) -> bool: """Performs notification.""" try: # Build a list of dictionaries that can be used to call send(). send_calls = list(self._build_send_calls(*args, **kwargs)) except TypeError: # Internal error return False else: # Loop through each call, one at a time. (Use a list rather than a # generator to call all the partials, even in case of a failure.) the_calls = [self.send(**kwargs2) for kwargs2 in send_calls] return all(the_calls) async def async_notify(self, *args: Any, **kwargs: Any) -> bool: """Performs notification for asynchronous callers.""" try: # Build a list of dictionaries that can be used to call send(). send_calls = list(self._build_send_calls(*args, **kwargs)) except TypeError: # Internal error return False else: loop = asyncio.get_event_loop() # Wrap each call in a coroutine that uses the default executor. # TODO: In the future, allow plugins to supply a native # async_send() method. async def do_send(**kwargs2): send = partial(self.send, **kwargs2) result = await loop.run_in_executor(None, send) return result # gather() all calls in parallel. the_cors = (do_send(**kwargs2) for kwargs2 in send_calls) return all(await asyncio.gather(*the_cors)) def _build_send_calls( self, body: Optional[str] = None, title: Optional[str] = None, notify_type: NotifyType = NotifyType.INFO, overflow: Optional[Union[str, OverflowMode]] = None, attach: Optional[Union[list[str], AppriseAttachment]] = None, body_format: Optional[NotifyFormat] = None, **kwargs: Any, ) -> Generator[dict[str, Any], None, None]: """Get a list of dictionaries that can be used to call send() or (in the future) async_send().""" if not self.enabled: # Deny notifications issued to services that are disabled msg = f"{self.service_name} is currently disabled on this system." self.logger.warning(msg) raise TypeError(msg) # Prepare attachments if required if attach is not None and not isinstance(attach, AppriseAttachment): try: attach = AppriseAttachment(attach, asset=self.asset) except TypeError: # bad attachments raise # Handle situations where the body is None body = body if body else "" elif not (body or attach): # If there is not an attachment at the very least, a body must be # present msg = "No message body or attachment was specified." self.logger.warning(msg) raise TypeError(msg) if not body and not self.attachment_support: # If no body was specified, then we know that an attachment # was. This is logic checked earlier in the code. # # Knowing this, if the plugin itself doesn't support sending # attachments, there is nothing further to do here, just move # along. msg = ( f"{self.service_name} does not support " "attachments; service skipped" ) self.logger.warning(msg) raise TypeError(msg) # Handle situations where the title is None title = title if title else "" # Truncate flag set with attachments ensures that only 1 # attachment passes through. In the event there could be many # services specified, we only want to do this logic once. # The logic is only applicable if ther was more then 1 attachment # specified overflow = self.overflow_mode if overflow is None else overflow if attach and len(attach) > 1 and overflow == OverflowMode.TRUNCATE: # Save first attachment attach_ = AppriseAttachment(attach[0], asset=self.asset) else: # reference same attachment attach_ = attach # Apply our overflow (if defined) for chunk in self._apply_overflow( body=body, title=title, overflow=overflow, body_format=body_format ): # Send notification yield { "body": chunk["body"], "title": chunk["title"], "notify_type": notify_type, "attach": attach_, "body_format": body_format, } def _apply_overflow( self, body: Optional[str], title: Optional[str] = None, overflow: Optional[Union[str, OverflowMode]] = None, body_format: Optional[NotifyFormat] = None, ) -> list[dict[str, str]]: """ Apply overflow behaviour (UPSTREAM, TRUNCATE, SPLIT) to title/body. Takes the message body and title as input. This function then applies any defined overflow restrictions associated with the notification service and may alter the message if/as required. The function will always return a list object in the following structure: [ { title: 'the title goes here', body: 'the message body goes here', }, { title: 'the title goes here', body: 'the continued message body goes here', }, ] """ response: list[dict[str, str]] = [] # Tidy title = "" if not title else title.strip() body = "" if not body else body.rstrip() # Default overflow mode if overflow is None: overflow = self.overflow_mode # Default effective body format if body_format is None: body_format = self.notify_format # If the service does not support a title, amalgamate into body if self.title_maxlen <= 0 and len(title) > 0: if self.notify_format == NotifyFormat.HTML: body = ( f"<{self.default_html_tag_id}>{title}" f"" f"
    \r\n{body}" ) elif ( self.notify_format == NotifyFormat.MARKDOWN and body_format in ( NotifyFormat.TEXT, NotifyFormat.HTML, ) ): # Body was plain text or HTML converted to CommonMark; # render the title as a Markdown heading. title = title.lstrip("\r\n \t\v\f#-") if title: body = f"# {title}\n{body}" else: body = f"{title}\r\n{body}" title = "" # Enforce line count if self.body_max_line_count > 0: lines = re.split(r"\r*\n", body) body = "\r\n".join(lines[0 : self.body_max_line_count]) # UPSTREAM mode: do not touch content if overflow == OverflowMode.UPSTREAM: response.append({"body": body, "title": title}) return response # a value of '2' allows for the \r\n that is applied when amalgamating overflow_buffer = ( max(2, self.overflow_buffer) if (self.title_maxlen == 0 and len(title)) else self.overflow_buffer ) # # TRUNCATE and SPLIT require sizing logic # # Handle situations where body and title are amalgamated title_maxlen = ( self.title_maxlen if not self.overflow_amalgamate_title else min( len(title) + self.overflow_max_display_count_width, self.title_maxlen, self.body_maxlen, ) ) if len(title) > title_maxlen: # Truncate our title title = title[:title_maxlen].rstrip() # Compute body_maxlen as per legacy logic if ( self.overflow_amalgamate_title and (self.body_maxlen - overflow_buffer) >= title_maxlen ): # status quo body_maxlen = ( self.body_maxlen if not title else (self.body_maxlen - title_maxlen) ) - overflow_buffer else: # If the body fits, we're done body_maxlen = ( self.body_maxlen if not self.overflow_amalgamate_title else (self.body_maxlen - overflow_buffer) ) # If the body fits, we are done if body_maxlen > 0 and len(body) <= body_maxlen: response.append({"body": body, "title": title}) return response # TRUNCATE mode: hard truncation (no smart-splitting) if overflow == OverflowMode.TRUNCATE: response.append( { "body": body[:body_maxlen].lstrip("\r\n\x0b\x0c").rstrip(), "title": title, } ) return response # # SPLIT mode # # Detect if we only display our title once or not (legacy logic) if self.overflow_display_title_once is None: # Detect if we only display our title once or not: overflow_display_title_once = bool( self.overflow_amalgamate_title and body_maxlen < self.overflow_display_count_threshold ) else: overflow_display_title_once = self.overflow_display_title_once # SPLIT mode with repeated title (with/without counter) if not overflow_display_title_once and not ( # edge case: amalgamated title but no body space self.overflow_amalgamate_title and body_maxlen <= 0 ): # Decide whether to show a counter (legacy condition) show_counter = ( title and len(body) > body_maxlen and ( ( self.overflow_amalgamate_title and body_maxlen >= self.overflow_display_count_threshold ) or ( not self.overflow_amalgamate_title and title_maxlen > self.overflow_display_count_threshold ) ) and ( title_maxlen > (self.overflow_max_display_count_width + overflow_buffer) and self.title_maxlen >= self.overflow_display_count_threshold ) ) effective_body_maxlen = body_maxlen if show_counter: # introduce padding for the counter effective_body_maxlen -= overflow_buffer # Use smart splitting instead of naive slicing chunks = smart_split( body, effective_body_maxlen, body_format, ) count = len(chunks) template = "" if show_counter: digits = len(str(count)) overflow_display_count_width = 4 + (digits * 2) if ( overflow_display_count_width <= self.overflow_max_display_count_width ): # Truncate title further if needed to make room for counter t_max = title_maxlen - overflow_display_count_width if len(title) > t_max: title = title[:t_max] template = f" [{{:0{digits}d}}/{{:0{digits}d}}]" else: # Too many messages; fall back to repeated title without # counter displayed show_counter = False response = [] for idx, chunk_body in enumerate(chunks, start=1): suffix = template.format(idx, count) if show_counter else "" response.append( { "body": chunk_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": f"{title}{suffix}", } ) else: # # SPLIT mode, display title once and move on # (this covers both overflow_display_title_once=True # and the edge case body_maxlen <= 0 with amalgamated title) # response = [] consumed = 0 remainder = body if body_maxlen > 0 and body: # First chunk uses body_maxlen (which already accounts for # the title) first_chunks = smart_split( body, body_maxlen, body_format, ) first_body = first_chunks[0] if first_chunks else "" consumed = len(first_body) remainder = body[consumed:] response.append( { "body": first_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": title, } ) else: # body_maxlen <= 0 or no body; send title only, still honouring # body response.append( { "body": "", "title": title, } ) # remainder stays as full body; will be split below # Remaining chunks: no title, use the full body_maxlen of the # service if remainder: more_chunks = smart_split( remainder, self.body_maxlen, body_format, ) for chunk_body in more_chunks: response.append( { "body": chunk_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": "", } ) return response def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Should preform the actual notification itself.""" raise NotImplementedError( "send() is not implemented by the child class." ) def __len__(self): """Returns the number of HTTP requests this instance will make, factoring in the configured retry count. Subclasses that override this are automatically wrapped by __init_subclass__ to apply the same retry multiplier, so they should return their raw target count without worrying about retry. """ return self.retry + 1 def url_parameters( self, *args: Any, **kwargs: Any, ) -> dict[str, Any]: """Provides a default set of parameters to work with. This can greatly simplify URL construction in the acommpanied url() function in all defined plugin services. """ params = { "format": self.notify_format.value, "overflow": self.overflow_mode.value, } # Timezone Information (if ZoneInfo) if self.__tzinfo and isinstance(self.__tzinfo, ZoneInfo): params["tz"] = self.__tzinfo.key # Persistent Storage Setting if self.persistent_storage != NotifyBase.persistent_storage: params["store"] = "yes" if self.persistent_storage else "no" # Always emit retry=, wait=, and optional= in the URL regardless # of whether they hold their zero/False defaults. Emitting them # unconditionally keeps the URL fully self-describing: a service # URL that is round-tripped through parse_url() -> __init__() will # reconstruct an identical object with the same settings. This # property is important for configuration-file generators, UI # tooling, and any code that serialises service objects to strings # and needs to restore them later without silent data loss. # # retry= and wait= are emitted as strings because URL query strings # are inherently text. The receiving __init__() will re-parse them # with int() and float() respectively. # # optional= is emitted as the canonical lower-case pair "yes"/"no" # that parse_bool() recognises on the receiving end, ensuring the # round-trip is lossless even for the boolean case. params["retry"] = str(self.retry) params["wait"] = str(self.wait) params["optional"] = "yes" if self.optional else "no" params.update(super().url_parameters(*args, **kwargs)) # return default parameters return params @staticmethod def parse_url( url: str, verify_host: bool = True, plus_to_space: bool = False, ) -> Optional[dict[str, Any]]: """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. In addition to the fields extracted by URLBase.parse_url(), this method extracts the NotifyBase-level query-string parameters: ``format``, ``overflow``, ``emojis``, ``tz``, ``store``, ``retry``, ``wait``, and ``optional``. The extracted values are placed directly in the returned results dict under their respective keys, ready to be consumed by NotifyBase.__init__() (or a subclass). Child classes should call this method via ``super()`` and then layer their own parameter extraction on top of the returned dict. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url( url, verify_host=verify_host, plus_to_space=plus_to_space ) if not results: # We're done; we failed to parse our url return results # Allow overriding the default format if "format" in results["qsd"]: results["format"] = results["qsd"].get("format", "").lower() if results["format"] not in NOTIFY_FORMATS: URLBase.logger.warning( "Unsupported format specified %r", results["qsd"]["format"], ) del results["format"] # Allow overriding the default overflow if "overflow" in results["qsd"]: results["overflow"] = results["qsd"].get("overflow", "").lower() if results["overflow"] not in OVERFLOW_MODES: URLBase.logger.warning( "Unsupported overflow mode specified " f"{results['qsd']['overflow']!r}" ) del results["overflow"] # Allow emoji's override if "emojis" in results["qsd"]: results["emojis"] = parse_bool(results["qsd"].get("emojis")) # Store our persistent storage boolean # Allow overriding the default timezone if "tz" in results["qsd"]: results["tz"] = results["qsd"].get("tz", "") if "store" in results["qsd"]: results["store"] = results["qsd"]["store"] # Global per-plugin retry count (?retry=N) if "retry" in results["qsd"] and results["qsd"]["retry"]: results["retry"] = results["qsd"]["retry"] # Global per-plugin inter-retry wait (?wait=N) if "wait" in results["qsd"] and results["qsd"]["wait"]: results["wait"] = results["qsd"]["wait"] # Global optional flag (?optional=yes/no). # # parse_bool() converts the raw query-string value to a native # Python bool using Apprise's canonical truth table: "yes", "true", # "on", "1" -> True; "no", "false", "off", "0" -> False. # Unrecognised strings fall back to False (the safe default). # # The parsed bool is stored in results["optional"] so that # NotifyBase.__init__() receives a value it can pass straight # through parse_bool() again without loss (parse_bool(True) is # True and parse_bool(False) is False). # # When ?optional= is absent from the URL entirely, this block is # skipped and results["optional"] is never set. NotifyBase.__init__ # will then leave self.optional at its class-level default (False), # which means the absence of the parameter is indistinguishable from # passing ?optional=no -- both result in failures being propagated. if "optional" in results["qsd"]: results["optional"] = parse_bool(results["qsd"]["optional"]) return results @staticmethod def parse_native_url(url: str) -> Optional[dict[str, Any]]: """This is a base class that can be optionally over-ridden by child classes who can build their Apprise URL based on the one provided by the notification service they choose to use. The intent of this is to make Apprise a little more userfriendly to people who aren't familiar with constructing URLs and wish to use the ones that were just provied by their notification serivice that they're using. This function will return None if the passed in URL can't be matched as belonging to the notification service. Otherwise this function should return the same set of results that parse_url() does. """ return None @property def store(self): """Returns a pointer to our persistent store for use. The best use cases are: self.store.get('key') self.store.set('key', 'value') self.store.delete('key1', 'key2', ...) You can also access the keys this way: self.store['key'] And clear them: del self.store['key'] """ if self.__store is None: # Initialize our persistent store for use self.__store = PersistentStore( namespace=self.url_id(), path=self.asset.storage_path, mode=self.asset.storage_mode, ) return self.__store @property def tzinfo(self) -> tzinfo: """Returns our tzinfo file associated with this plugin if set otherwise the default timezone is returned. """ return self.__tzinfo if self.__tzinfo else self.asset.tzinfo caronc-apprise-182f859/apprise/plugins/blink1.py000066400000000000000000000272111524161175200215750ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Sources: # - https://blink1.thingm.com/ # - https://github.com/todbot/blink1-python # - https://github.com/todbot/blink1/blob/main/docs/blink1-hid-commands.md import time from ..common import NotifyType from ..locale import gettext_lazy as _ from .base import NotifyBase # Default our global support flag NOTIFY_BLINK1_ENABLED = False try: import hid as blink1_hid NOTIFY_BLINK1_ENABLED = True except ImportError: # No problem -- hidapi is an optional dependency. Users who wish to # use this plugin must install it: pip install hidapi pass # # blink(1) USB HID constants # # USB vendor / product identifiers for all blink(1) revisions BLINK1_VENDOR_ID = 0x27B8 BLINK1_PRODUCT_ID = 0x01ED # HID report size (report ID byte + 8 payload bytes) BLINK1_REPORT_ID = 0x01 BLINK1_REPORT_SIZE = 9 # Command byte: 'c' == fade to RGB with a time argument BLINK1_CMD_FADE = ord("c") class Blink1LED: """ Defines the LED to focus on """ ALL = 0 FIRST = 1 SECOND = 2 BLINK1_LED_CHOICES = { Blink1LED.ALL: "all", Blink1LED.FIRST: "1", Blink1LED.SECOND: "2", } # Maps inbound strings to Blink1LED constants; unknown values fall back to ALL BLINK1_LED_MAP = { "0": Blink1LED.ALL, "all": Blink1LED.ALL, "a": Blink1LED.ALL, "1": Blink1LED.FIRST, "2": Blink1LED.SECOND, } # How long (ms) to hold the LED colour after the fade completes BLINK1_DEFAULT_DURATION_MS = 5000 BLINK1_MIN_DURATION_MS = 0 BLINK1_MAX_DURATION_MS = 300000 # 5 minutes # Fade transition time (ms); 0 = instant BLINK1_DEFAULT_FADE_MS = 0 BLINK1_MIN_FADE_MS = 0 BLINK1_MAX_FADE_MS = 10000 # 10 seconds def _blink1_fade_buf(red, green, blue, fade_ms, ledn): """Build the 9-byte HID feature-report buffer for a fade-to-RGB command. The blink(1) wire format (all values unsigned 8-bit unless noted): [0] REPORT_ID (0x01) [1] command 'c' (0x63) [2] red [3] green [4] blue [5] fade_time high byte (fade_time = fade_ms // 10, 16-bit big-endian) [6] fade_time low byte [7] ledn (0=all, 1=LED1, 2=LED2) [8] 0x00 (padding) """ fade_time = int(fade_ms) // 10 th = (fade_time >> 8) & 0xFF tl = fade_time & 0xFF return [ BLINK1_REPORT_ID, BLINK1_CMD_FADE, int(red), int(green), int(blue), th, tl, int(ledn), 0x00, ] class NotifyBlink1(NotifyBase): """A wrapper for blink(1) USB LED notifications. Colors are driven by Apprise's notification-type color map (info=blue, success=green, warning=yellow, failure=red). No external blink1 library is required; the USB HID wire protocol is implemented directly via the hidapi package. """ # Set our global enabled flag enabled = NOTIFY_BLINK1_ENABLED requirements = { "packages_required": "hidapi", } # The default descriptive name associated with the notification service_name = _("blink(1)") # The services URL service_url = "https://blink1.thingm.com/" # The default protocol protocol = "blink1" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/blink1/" # blink(1) is a local USB device; a title field has no meaning here. title_maxlen = 0 # No throttling needed for a local USB device request_rate_per_sec = 0 # URL templates templates = ( "{schema}://", "{schema}://{serial}/", ) template_tokens = dict( NotifyBase.template_tokens, **{ "serial": { "name": _("Serial Number"), "type": "string", }, }, ) template_args = dict( NotifyBase.template_args, **{ "duration": { "name": _("Duration (ms)"), "type": "int", "min": BLINK1_MIN_DURATION_MS, "max": BLINK1_MAX_DURATION_MS, "default": BLINK1_DEFAULT_DURATION_MS, }, "fade": { "name": _("Fade Time (ms)"), "type": "int", "min": BLINK1_MIN_FADE_MS, "max": BLINK1_MAX_FADE_MS, "default": BLINK1_DEFAULT_FADE_MS, }, "ledn": { "name": _("LED Number"), "type": "choice:int", "values": BLINK1_LED_CHOICES, "default": Blink1LED.ALL, }, }, ) def __init__( self, serial=None, duration=None, fade=None, ledn=None, **kwargs, ): """Initialize Blink1 Object.""" super().__init__(**kwargs) # Device serial number; None means "first available device". # An underscore is accepted as a URL placeholder meaning "any". serial = serial.strip() if isinstance(serial, str) else None self.serial = serial if serial and serial != "_" else None # Duration (ms) to hold the LED colour before turning it off try: self.duration = int( BLINK1_DEFAULT_DURATION_MS if duration is None else duration ) if not ( BLINK1_MIN_DURATION_MS <= self.duration <= BLINK1_MAX_DURATION_MS ): raise ValueError("out of range") except (TypeError, ValueError): msg = ( f"blink(1) duration ({duration}) must be between" f" {BLINK1_MIN_DURATION_MS} and" f" {BLINK1_MAX_DURATION_MS} ms." ) self.logger.warning(msg) raise TypeError(msg) from None # Fade transition time (ms) try: self.fade = int(BLINK1_DEFAULT_FADE_MS if fade is None else fade) if not (BLINK1_MIN_FADE_MS <= self.fade <= BLINK1_MAX_FADE_MS): raise ValueError("out of range") except (TypeError, ValueError): msg = ( f"blink(1) fade ({fade}) must be between" f" {BLINK1_MIN_FADE_MS} and {BLINK1_MAX_FADE_MS} ms." ) self.logger.warning(msg) raise TypeError(msg) from None # LED selector; unrecognised values silently fall back to ALL self.ledn = ( BLINK1_LED_MAP.get(str(ledn).lower(), Blink1LED.ALL) if ledn is not None else Blink1LED.ALL ) def _open_device(self): """Open and return a hidapi device handle for the blink(1). Returns None and logs a warning when the device cannot be found. """ try: dev = blink1_hid.device() dev.open( BLINK1_VENDOR_ID, BLINK1_PRODUCT_ID, self.serial, ) return dev except OSError: msg = "Failed to open blink(1) device" if self.serial: msg += f" (serial={self.serial})" msg += "." self.logger.warning(msg) return None def _send_fade(self, dev, red, green, blue, fade_ms): """Send a single fade-to-RGB HID feature report. Returns True on success, False if the report could not be sent. """ buf = _blink1_fade_buf(red, green, blue, fade_ms, self.ledn) try: rc = dev.send_feature_report(buf) except OSError as e: self.logger.warning("blink(1) HID write failed: %s", e) return False if rc != BLINK1_REPORT_SIZE: self.logger.warning( "blink(1) HID write returned %d (expected %d).", rc, BLINK1_REPORT_SIZE, ) return False return True @property def url_identifier(self): """Returns all fields that uniquely identify this connection.""" return ( self.protocol, self.serial, self.ledn, ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform blink(1) Notification.""" # Resolve the RGB triple for this notification type r, g, b = self.color(notify_type=notify_type, color_type=tuple) dev = self._open_device() if dev is None: return False try: # Always throttle before any device I/O self.throttle() if not self._send_fade(dev, r, g, b, self.fade): return False # Hold for fade + duration, then switch off time.sleep((self.fade + self.duration) / 1000.0) # Turn off: instant fade to black if not self._send_fade(dev, 0, 0, 0, 0): return False finally: dev.close() self.logger.info("Sent blink(1) notification.") return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = { "duration": str(self.duration), "fade": str(self.fade), "ledn": str(self.ledn), } params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) serial = self.serial if self.serial else "_" return f"{self.protocol}://{serial}/?{NotifyBlink1.urlencode(params)}" @staticmethod def parse_url(url): """Parses the URL and returns enough arguments to re-instantiate.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: return results # The hostname, when present, is the device serial number. # An underscore or empty value means "first available device". host = results.get("host", "") if host and host != "_": results["serial"] = NotifyBlink1.unquote(host) if results["qsd"].get("duration"): results["duration"] = NotifyBlink1.unquote( results["qsd"]["duration"] ) if results["qsd"].get("fade"): results["fade"] = NotifyBlink1.unquote(results["qsd"]["fade"]) if results["qsd"].get("ledn"): results["ledn"] = NotifyBlink1.unquote(results["qsd"]["ledn"]) return results @staticmethod def runtime_deps(): """Return optional runtime dependency package names.""" return ("hid",) caronc-apprise-182f859/apprise/plugins/bluesky.py000066400000000000000000000560541524161175200221020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # 1. Create a BlueSky account # 2. Access Settings -> Privacy and Security # 3. Generate an App Password. Optionally grant yourself access to Direct # Messages if you want to be able to send them # 4. Assemble your Apprise URL like: # bluesky://handle@you-token-here # from datetime import datetime, timedelta, timezone import json import re import requests from ..attachment.base import AttachBase from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from .base import NotifyBase # For parsing handles HANDLE_HOST_PARSE_RE = re.compile(r"(?P[^.]+)\.+(?P.+)$") IS_USER = re.compile(r"^\s*@?(?P[A-Z0-9_]+)(\.+(?P.+))?$", re.I) class NotifyBlueSky(NotifyBase): """A wrapper for BlueSky Notifications.""" # The default descriptive name associated with the Notification service_name = "BlueSky" # The services URL service_url = "https://bluesky.us/" # Protocol secure_protocol = ("bsky", "bluesky") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bluesky/" # Support attachments attachment_support = True # XRPC Suffix URLs; Structured as: # https://host/{suffix} # Taken right from google.auth.helpers: clock_skew = timedelta(seconds=10) # 1 hour in seconds (the lifetime of our token) access_token_lifetime_sec = timedelta(seconds=3600) # Detect your Decentralized Identitifer (DID), then you can get your Auth # Token. xrpc_suffix_did = "/xrpc/com.atproto.identity.resolveHandle" xrpc_suffix_session = "/xrpc/com.atproto.server.createSession" xrpc_suffix_record = "/xrpc/com.atproto.repo.createRecord" xrpc_suffix_blob = "/xrpc/com.atproto.repo.uploadBlob" plc_directory = "https://plc.directory/{did}" # BlueSky is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: # RateLimit-Reset: The epoc time (in seconds) we can expect our # rate-limit to be reset. # RateLimit-Remaining: an integer identifying how many requests we're # still allow to make. request_rate_per_sec = 0 # For Tracking Purposes ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Remaining messages ratelimit_remaining = 1 # The default BlueSky host to use if one isn't specified bluesky_default_host = "bsky.social" # Our message body size body_maxlen = 280 # BlueSky does not support a title title_maxlen = 0 # Define object templates templates = ("{schema}://{user}@{password}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("Username"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, }, ) def __init__(self, **kwargs): """Initialize BlueSky Object.""" super().__init__(**kwargs) # Our access token self.__access_token = self.store.get("access_token") self.__refresh_token = None self.__access_token_expiry = datetime.now(timezone.utc) self.__endpoint = self.store.get("endpoint") if not self.user: msg = "A BlueSky UserID/Handle must be specified." self.logger.warning(msg) raise TypeError(msg) # Set our default host self.host = self.bluesky_default_host self.__endpoint = ( f"https://{self.host}" if not self.host else self.__endpoint ) # Identify our Handle (if define) results = HANDLE_HOST_PARSE_RE.match(self.user) if results: self.user = results.group("handle").strip() self.host = results.group("host").strip() return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform BlueSky Notification.""" if not self.__access_token and not self.login(): # We failed to authenticate - we're done return False # Track our returning blob IDs as they're stored on the BlueSky server blobs = [] if attach and self.attachment_support: url = f"{self.__endpoint}{self.xrpc_suffix_blob}" # We need to upload our payload first so that we can source it # in remaining messages for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access attachment" f" {attachment.url(privacy=True)}." ) return False if not re.match(r"^image/.*", attachment.mimetype, re.I): # Only support images at this time self.logger.warning( "Ignoring unsupported BlueSky attachment" f" {attachment.url(privacy=True)}." ) continue self.logger.debug( "Preparing BlueSky attachment" f" {attachment.url(privacy=True)}" ) # Upload our image and get our blob associated with it postokay, response = self._fetch( url, payload=attachment, ) if not postokay: # We can't post our attachment return False # Prepare our filename filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) if not (isinstance(response, dict) and response.get("blob")): self.logger.debug( "Could not attach the file to BlueSky: %s (mime=%s)", filename, attachment.mimetype, ) continue blobs.append((response.get("blob"), filename)) # Prepare our URL did, endpoint = self.get_identifier() url = f"{endpoint}{self.xrpc_suffix_record}" # prepare our batch of payloads to create payloads = [] payload = { "collection": "app.bsky.feed.post", "repo": did, "record": { "text": body, # 'YYYY-mm-ddTHH:MM:SSZ' "createdAt": datetime.now(tz=timezone.utc).strftime("%FT%XZ"), "$type": "app.bsky.feed.post", }, } if blobs: for no, blob in enumerate(blobs, start=1): payload_ = payload.copy() if no > 1: # # multiple instances # # 1. update createdAt time # 2. Change text to identify image no payload_["record"]["createdAt"] = datetime.now( tz=timezone.utc ).strftime("%FT%XZ") payload_["record"]["text"] = f"{no:02d}/{len(blobs):02d}" payload_["record"]["embed"] = { "images": [ { "image": blob[0], "alt": blob[1], } ], "$type": "app.bsky.embed.images", } payloads.append(payload_) else: payloads.append(payload) for payload in payloads: # Send Login Information postokay, response = self._fetch( url, payload=json.dumps(payload), ) if not postokay: # We failed # Bad responses look like: # { # 'error': 'InvalidRequest', # 'message': 'reason' # } return False return True def get_identifier(self, user=None, login=False): """Performs a Decentralized User Lookup and returns the identifier.""" if user is None: user = self.user user = f"{user}.{self.host}" if "." not in user else f"{user}" did_key = f"did.{user}" endpoint_key = f"endpoint.{user}" did = self.store.get(did_key) endpoint = self.store.get(endpoint_key) if did and endpoint: # Early return return did, endpoint # Step 1: Acquire DID from bsky.app url = f"https://public.api.bsky.app{self.xrpc_suffix_did}" params = {"handle": user} # Send Login Information postokay, response = self._fetch( url, params=params, method="GET", # We set this boolean so internal recursion doesn't take place. login=login, ) if not postokay or not response or "did" not in response: # We failed return (False, False) # Store our DID did = response.get("did") # Step 2: Use DID to find the PDS if did.startswith("did:plc:"): pds_url = self.plc_directory.format(did=did) # PDS Query postokay, service_response = self._fetch( pds_url, method="GET", # We set this boolean so internal recursion doesn't take place. login=login, ) if ( not postokay or not service_response or "service" not in service_response ): # We failed return (False, False) endpoint = next( ( s["serviceEndpoint"] for s in service_response.get("service", []) if s["type"] == "AtprotoPersonalDataServer" ), None, ) elif did.startswith("did:web:"): # Convert to domain domain = did[8:] web_did_url = f"https://{domain}/.well-known/did.json" postokay, service_response = self._fetch( web_did_url, method="GET", # We set this boolean so internal recursion doesn't take place. login=login, ) if ( not postokay or not service_response or "service" not in service_response ): # We failed self.logger.warning( "Could not fetch DID document for did:web identity " f"{did}; ensure {web_did_url} is available." ) return (False, False) endpoint = next( ( s["serviceEndpoint"] for s in service_response.get("service", []) if s["type"] == "AtprotoPersonalDataServer" ), None, ) else: self.logger.warning( f"Unknown BlueSky DID scheme detected in {did}" ) return (False, False) # Step 3: Send to correct endpoint if not endpoint: self.logger.warning("Failed to resolve BlueSky PDS endpoint") return (False, False) self.store.set(did_key, did) self.store.set(endpoint_key, endpoint) return (did, endpoint) def login(self): """A simple wrapper to authenticate with the BlueSky Server.""" # Acquire our Decentralized Identitifer did, self.__endpoint = self.get_identifier(self.user, login=True) if not did: return False url = f"{self.__endpoint}{self.xrpc_suffix_session}" payload = { "identifier": did, "password": self.password, } # Send Login Information postokay, response = self._fetch( url, payload=json.dumps(payload), # We set this boolean so internal recursion doesn't take place. login=True, ) # Our response object looks like this (content has been altered for # presentation purposes): # { # 'did': 'did:plc:ruk414jakghak402j1jqekj2', # 'didDoc': { # '@context': [ # 'https://www.w3.org/ns/did/v1', # 'https://w3id.org/security/multikey/v1', # 'https://w3id.org/security/suites/secp256k1-2019/v1' # ], # 'id': 'did:plc:ruk414jakghak402j1jqekj2', # 'alsoKnownAs': ['at://apprise.bsky.social'], # 'verificationMethod': [ # { # 'id': 'did:plc:ruk414jakghak402j1jqekj2#atproto', # 'type': 'Multikey', # 'controller': 'did:plc:ruk414jakghak402j1jqekj2', # 'publicKeyMultibase' 'redacted' # } # ], # 'service': [ # { # 'id': '#atproto_pds', # 'type': 'AtprotoPersonalDataServer', # 'serviceEndpoint': # 'https://woodtuft.us-west.host.bsky.network' # } # ] # }, # 'handle': 'apprise.bsky.social', # 'email': 'whoami@gmail.com', # 'emailConfirmed': True, # 'emailAuthFactor': False, # 'accessJwt': 'redacted', # 'refreshJwt': 'redacted', # 'active': True, # } if not postokay or not response: # We failed return False # Acquire our Token self.__access_token = response.get("accessJwt") # Handle other optional arguments we can use self.__access_token_expiry = ( self.access_token_lifetime_sec + datetime.now(timezone.utc) - self.clock_skew ) # The Refresh Token self.__refresh_token = response.get("refreshJwt", self.__refresh_token) self.store.set( "access_token", self.__access_token, self.__access_token_expiry ) self.store.set( "refresh_token", self.__refresh_token, self.__access_token_expiry ) self.store.set("endpoint", self.__endpoint) self.logger.info( f"Authenticated to BlueSky as {self.user}.{self.host}" ) return True def _fetch( self, url, payload=None, params=None, method="POST", content_type=None, login=False, ): """Wrapper to BlueSky API requests object.""" # use what was specified, otherwise build headers dynamically headers = { "User-Agent": self.app_id, "Content-Type": ( payload.mimetype if isinstance(payload, AttachBase) else ( "application/x-www-form-urlencoded; charset=utf-8" if method == "GET" else "application/json" ) ), } if self.__access_token: # Set our token headers["Authorization"] = f"Bearer {self.__access_token}" # Some Debug Logging self.logger.debug( f"BlueSky {method} URL:" f" {url} (cert_verify={self.verify_certificate})" ) self.logger.debug( "BlueSky Payload: %s", ( str(payload) if not isinstance(payload, AttachBase) else "attach: " + payload.name ), ) # By default set wait to None wait = None if self.ratelimit_remaining == 0: # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the # Twitter server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds # We add 0.3 seconds to the end just to allow a grace # period. wait = (self.ratelimit_reset - now).total_seconds() + 0.3 # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) # Initialize a default value for our content value content = {} # acquire our request mode fn = requests.post if method == "POST" else requests.get try: r = fn( url, data=( payload if not isinstance(payload, AttachBase) else payload.open() ), params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # Get our JSON content if it's possible try: content = json.loads(r.content) except (TypeError, ValueError, AttributeError): # TypeError = r.content is not a String # ValueError = r.content is Unparsable # AttributeError = r.content is None content = {} self.logger.debug( "Failed to parse BlueSky JSON response; body: %r", (r.content or b"")[:2000], ) # Rate limit handling... our header objects at this point are: # 'RateLimit-Limit': '10', # Total # of requests per hour # 'RateLimit-Remaining': '9', # Requests remaining # 'RateLimit-Reset': '1741631362', # Epoch Time # 'RateLimit-Policy': '10;w=86400' # NoEntries;w= try: # Capture rate limiting if possible self.ratelimit_remaining = int( r.headers.get("ratelimit-remaining") ) self.ratelimit_reset = datetime.fromtimestamp( int(r.headers.get("ratelimit-reset")), timezone.utc ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information # gracefully accept this state and move on pass if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBlueSky.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send BlueSky {} to {}: {}error={}.".format( method, url, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure return (False, content) except requests.RequestException as e: self.logger.warning( f"Exception received when sending BlueSky {method} to {url}: " ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure return (False, content) except OSError as e: self.logger.warning( "An I/O error occurred while handling {}.".format( payload.name if isinstance(payload, AttachBase) else payload ) ) self.logger.debug(f"I/O Exception: {e!s}") return (False, content) return (True, content) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol[0], self.user, self.password, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Apply our other parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) user = self.user if self.host != self.bluesky_default_host: user += f".{self.host}" # our URL return "{schema}://{user}@{password}?{params}".format( schema=self.secure_protocol[0], user=NotifyBlueSky.quote(user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), params=NotifyBlueSky.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results if not results.get("password") and results["host"]: results["password"] = NotifyBlueSky.unquote(results["host"]) # Do not use host field results["host"] = None return results caronc-apprise-182f859/apprise/plugins/brevo.py000066400000000000000000000465551524161175200215460ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # API Reference: https://developers.brevo.com/reference/getting-started-1 from json import dumps import logging from os.path import splitext import requests from .. import exception from ..common import NotifyFormat, NotifyType from ..conversion import convert_between from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_list, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase # Extend HTTP Error Messages (most common Brevo SMTP errors) BREVO_HTTP_ERROR_MAP = { 400: "Bad Request - Invalid payload or missing parameters.", 401: "Unauthorized - Invalid Brevo API key.", 402: "Payment Required - Plan limitation or credit issue.", 429: "Too Many Requests - Rate limit exceeded.", } # Comprehensive list of Brevo-supported extensions for Transactional Emails # Source: Brevo API Documentation & Transactional Attachment Guidelines BREVO_VALID_EXTENSIONS = ( # Documents & Text "xlsx", "xls", "ods", "docx", "docm", "doc", "csv", "pdf", "txt", "rtf", "msg", "pub", "mobi", "ppt", "pptx", "eps", "odt", "ics", "xml", "css", "html", "htm", "shtml", # Images "gif", "jpg", "jpeg", "png", "tif", "tiff", "bmp", "cgm", # Archives "zip", "tar", "ez", "pkpass", # Audio "mp3", "m4a", "m4v", "wma", "ogg", "flac", "wav", "aif", "aifc", "aiff", # Video "mp4", "mov", "avi", "mkv", "mpeg", "mpg", "wmv", ) class NotifyBrevo(NotifyBase): """A wrapper for Notify Brevo Notifications.""" # The default descriptive name associated with the Notification service_name = "Brevo" # The services URL service_url = "https://www.brevo.com/" # The default secure protocol secure_protocol = "brevo" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/brevo/" # Default to markdown notify_format = NotifyFormat.HTML # The default Email API URL to use notify_url = "https://api.brevo.com/v3/smtp/email" # Support attachments attachment_support = True # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.2 # The default subject to use if one isn't specified. default_empty_subject = "" # Define object templates templates = ( "{schema}://{apikey}:{from_email}", "{schema}://{apikey}:{from_email}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "regex": (r"^[a-zA-Z0-9._-]+$", "i"), }, "from_email": { "name": _("Source Email"), "type": "string", "required": True, }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "reply": { "name": _("Reply To Email"), "type": "string", "map_to": "reply_to", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) def __init__( self, apikey, from_email, targets=None, reply_to=None, cc=None, bcc=None, **kwargs, ): """Initialize Notify Brevo Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid Brevo API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) result = is_email(from_email) if not result: msg = f"Invalid ~From~ email specified: {from_email}" self.logger.warning(msg) raise TypeError(msg) # Store email address self.from_email = result["full_email"] # Reply-to self.reply_to = None if reply_to: result = is_email(reply_to) if not result: msg = "An invalid Brevo Reply To ({}) was specified.".format( f"{reply_to}" ) self.logger.warning(msg) raise TypeError(msg) self.reply_to = ( result["name"] if result["name"] else False, result["full_email"], ) # Acquire Targets (To Emails) self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Validate recipients (to:) and drop bad ones: if targets: for recipient in parse_list(targets): result = is_email(recipient) if result: self.targets.append(result["full_email"]) continue self.logger.warning( f"Dropped invalid email ({recipient}) specified.", ) else: # add ourselves self.targets.append(self.from_email) # Validate recipients (cc:) and drop bad ones: for recipient in parse_list(cc): result = is_email(recipient) if result: self.cc.add(result["full_email"]) continue self.logger.warning( f"Dropped invalid Carbon Copy email ({recipient}) specified.", ) # Validate recipients (bcc:) and drop bad ones: for recipient in parse_list(bcc): result = is_email(recipient) if result: self.bcc.add(result["full_email"]) continue self.logger.warning( "Dropped invalid Blind Carbon Copy email " f"({recipient}) specified.", ) return @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.apikey, self.from_email) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) if len(self.cc) > 0: # Handle our Carbon Copy Addresses params["cc"] = ",".join(self.cc) if len(self.bcc) > 0: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join(self.bcc) if self.reply_to: # Handle our reply to address params["reply"] = ( "{} <{}>".format(*self.reply_to) if self.reply_to[0] else self.reply_to[1] ) # a simple boolean check as to whether we display our target emails # or not has_targets = not ( len(self.targets) == 1 and self.targets[0] == self.from_email ) return "{schema}://{apikey}:{from_email}/{targets}?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), # never encode email since it plays a huge role in our hostname from_email=self.from_email, targets=( "" if not has_targets else "/".join( [NotifyBrevo.quote(x, safe="") for x in self.targets] ) ), params=NotifyBrevo.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return max(len(self.targets), 1) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Brevo Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Brevo email recipients to notify" ) return False headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", "api-key": self.apikey, } # error tracking (used for function return) has_error = False # A Simple Email Payload Template payload_ = { "sender": { "email": self.from_email, }, # Placeholder, filled per target "to": [{"email": None}], "subject": title if title else self.default_empty_subject, } # Body selection use_html = self.notify_format == NotifyFormat.HTML if use_html: # body already normalised; keep your existing logic payload_["htmlContent"] = body payload_["textContent"] = convert_between( NotifyFormat.HTML, NotifyFormat.TEXT, body ) else: # Plain text requested, but Brevo still wants HTML payload_["textContent"] = body payload_["htmlContent"] = convert_between( NotifyFormat.TEXT, NotifyFormat.HTML, body ) if attach and self.attachment_support: attachments = [] # Send our attachments for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Brevo attachment" f" {attachment.url(privacy=True)}." ) return False # Brevo does not track content/mime type and relies 100% # entirely on the filename extension as to whether or not it # will accept it or not. # # The below prepares a safe_name (which can't be .dat like # other plugins since Brevo rejects that type). For this # reason .txt is chosen intentionally for this circumstance. # Use the attachment name if available, otherwise default to a # generic name raw_name = ( attachment.name if attachment.name else f"file{no:03}.txt" ) # If the filename does NOT match a supported extension, append # .txt _, ext = splitext(raw_name) safe_name = ( f"{raw_name}.txt" if ( not ext or ext[1:].lower() not in BREVO_VALID_EXTENSIONS ) else raw_name ) try: attachments.append( { "content": attachment.base64(), "name": safe_name, } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Brevo attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Brevo attachment" f" {attachment.url(privacy=True)}" ) # Append our attachments to the payload payload_.update( { "attachment": attachments, } ) if self.reply_to: payload_["replyTo"] = {"email": self.reply_to[1]} targets = list(self.targets) while len(targets) > 0: target = targets.pop(0) # Create a copy of our template payload = payload_.copy() # the cc, bcc, to field must be unique or SendMail will fail, the # below code prepares this by ensuring the target isn't in the cc # list or bcc list. It also makes sure the cc list does not contain # any of the bcc entries cc = self.cc - self.bcc - {target} bcc = self.bcc - {target} # Set our main recipient payload["to"] = [{"email": target}] if len(cc): payload["cc"] = [{"email": email} for email in cc] if len(bcc): payload["bcc"] = [{"email": email} for email in bcc] # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io # intensive. # To accommodate this, we only show our debug payload # information if required. self.logger.debug( "Brevo POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "Brevo Payload: %s", sanitize_payload(payload) ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code not in ( requests.codes.ok, requests.codes.accepted, requests.codes.created, ): # We had a problem status_str = NotifyBrevo.http_response_code_lookup( r.status_code, BREVO_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Brevo notification to {}: " "{}{}error={}.".format( target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info(f"Sent Brevo notification to {target}.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Brevo " f"notification to {target}." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Our URL looks like this: # {schema}://{apikey}:{from_email}/{targets} # # which actually equates to: # {schema}://{user}:{password}@{host}/{email1}/{email2}/etc.. # ^ ^ ^ # | | | # apikey -from addr- if not results.get("user"): # An API Key as not properly specified return None if not results.get("password"): # A From Email was not correctly specified return None # Prepare our API Key results["apikey"] = NotifyBrevo.unquote(results["user"]) # Prepare our From Email Address results["from_email"] = "{}@{}".format( NotifyBrevo.unquote(results["password"]), NotifyBrevo.unquote(results["host"]), ) # Acquire our targets results["targets"] = NotifyBrevo.split_path(results["fullpath"]) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBrevo.parse_list(results["qsd"]["to"]) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = NotifyBrevo.parse_list(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = NotifyBrevo.parse_list(results["qsd"]["bcc"]) # Handle Reply To Address if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = NotifyBrevo.unquote(results["qsd"]["reply"]) return results caronc-apprise-182f859/apprise/plugins/bulksms.py000066400000000000000000000413731524161175200221020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a BulkSMS account # You will need credits (new accounts start with a few) # https://www.bulksms.com/account/ # # API is documented here: # - https://www.bulksms.com/developer/json/v1/#tag/Message from itertools import chain import json import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase IS_GROUP_RE = re.compile( r"^(@?(?P[A-Z0-9_-]+))$", re.IGNORECASE, ) class BulkSMSRoutingGroup: """The different categories of routing.""" ECONOMY = "ECONOMY" STANDARD = "STANDARD" PREMIUM = "PREMIUM" # Used for verification purposes BULKSMS_ROUTING_GROUPS = ( BulkSMSRoutingGroup.ECONOMY, BulkSMSRoutingGroup.STANDARD, BulkSMSRoutingGroup.PREMIUM, ) class BulkSMSEncoding: """The different categories of routing.""" TEXT = "TEXT" UNICODE = "UNICODE" BINARY = "BINARY" class NotifyBulkSMS(NotifyBase): """A wrapper for BulkSMS Notifications.""" # The default descriptive name associated with the Notification service_name = "BulkSMS" # The services URL service_url = "https://bulksms.com/" # All notification requests are secure secure_protocol = "bulksms" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bulksms/" # BulkSMS uses the http protocol with JSON requests notify_url = "https://api.bulksms.com/v1/messages" # The maximum length of the body body_maxlen = 160 # The maximum amount of texts that can go out in one batch default_batch_size = 4000 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{user}:{password}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "target_group": { "name": _("Target Group"), "type": "string", "prefix": "@", "regex": (r"^[A-Z0-9 _-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "route": { "name": _("Route Group"), "type": "choice:string", "values": BULKSMS_ROUTING_GROUPS, "default": BulkSMSRoutingGroup.STANDARD, }, "unicode": { # Unicode characters "name": _("Unicode Characters"), "type": "bool", "default": True, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, source=None, targets=None, unicode=None, batch=None, route=None, **kwargs, ): """Initialize BulkSMS Object.""" super().__init__(**kwargs) self.source = None if source: result = is_phone_no(source) if not result: msg = ( "The Account (From) Phone # specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Tidy source self.source = "+{}".format(result["full"]) # Setup our route self.route = ( self.template_args["route"]["default"] if not isinstance(route, str) else route.upper() ) if self.route not in BULKSMS_ROUTING_GROUPS: msg = f"The route specified ({route}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Define whether or not we should set the unicode flag self.unicode = ( self.template_args["unicode"]["default"] if unicode is None else bool(unicode) ) # Define whether or not we should operate in a batch mode self.batch = ( self.template_args["batch"]["default"] if batch is None else bool(batch) ) # Parse our targets self.targets = [] self.groups = [] for target in parse_phone_no(targets): # Parse each phone number we found result = is_phone_no(target) if result: self.targets.append("+{}".format(result["full"])) continue group_re = IS_GROUP_RE.match(target) if group_re and not target.isdigit(): # If the target specified is all digits, it MUST have a @ # in front of it to eliminate any ambiguity self.groups.append(group_re.group("group")) continue self.logger.warning( f"Dropped invalid phone # and/or Group ({target}) specified.", ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform BulkSMS Notification.""" if not (self.password and self.user): self.logger.warning( "There were no valid login credentials provided" ) return False if not (self.targets or self.groups): # We have nothing to notify self.logger.warning("There are no BulkSMS targets to notify") return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Prepare our payload payload = { # The To gets populated in the loop below "to": None, "body": body, "routingGroup": self.route, "encoding": ( BulkSMSEncoding.UNICODE if self.unicode else BulkSMSEncoding.TEXT ), # Options are NONE, ALL and ERRORS "deliveryReports": "ERRORS", } if self.source: payload.update( { "from": self.source, } ) # Authentication auth = (self.user, self.password) # Prepare our targets targets = ( list(self.targets) if batch_size == 1 else [ self.targets[index : index + batch_size] for index in range(0, len(self.targets), batch_size) ] ) targets += [{"type": "GROUP", "name": g} for g in self.groups] while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["to"] = target # Printable reference if isinstance(target, dict): p_target = target["name"] elif isinstance(target, list): p_target = f"{len(target)} targets" else: p_target = target # Some Debug Logging self.logger.debug( "BulkSMS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"BulkSMS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # The responsne might look like: # [ # { # "id": "string", # "type": "SENT", # "from": "string", # "to": "string", # "body": null, # "encoding": "TEXT", # "protocolId": 0, # "messageClass": 0, # "numberOfParts": 0, # "creditCost": 0, # "submission": {...}, # "status": {...}, # "relatedSentMessageId": "string", # "userSuppliedId": "string" # } # ] if r.status_code not in ( requests.codes.created, requests.codes.ok, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) # set up our status code to use status_code = r.status_code self.logger.warning( "Failed to send BulkSMS notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( f"Sent BulkSMS notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending BulkSMS: to %s ", p_target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "unicode": "yes" if self.unicode else "no", "batch": "yes" if self.batch else "no", "route": self.route, } if self.source: params["from"] = self.source # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{user}:{password}@{targets}/?{params}".format( schema=self.secure_protocol, user=self.pprint(self.user, privacy, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( chain( [ NotifyBulkSMS.quote(f"{x}", safe="+") for x in self.targets ], [ NotifyBulkSMS.quote(f"@{x}", safe="@") for x in self.groups ], ) ), params=NotifyBulkSMS.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol, self.user if self.user else None, self.password if self.password else None, ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # # Note: Groups always require a separate request (and can not be # included in batch calculations) batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets + len(self.groups) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = [ NotifyBulkSMS.unquote(results["host"]), *NotifyBulkSMS.split_path(results["fullpath"]), ] # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBulkSMS.unquote(results["qsd"]["from"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBulkSMS.parse_phone_no( results["qsd"]["to"] ) # Unicode Characters results["unicode"] = parse_bool( results["qsd"].get( "unicode", NotifyBulkSMS.template_args["unicode"]["default"] ) ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyBulkSMS.template_args["batch"]["default"] ) ) # Allow one to define a route group if "route" in results["qsd"] and len(results["qsd"]["route"]): results["route"] = NotifyBulkSMS.unquote(results["qsd"]["route"]) return results caronc-apprise-182f859/apprise/plugins/bulkvs.py000066400000000000000000000332531524161175200217260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a BulkVS account # You will need credits (new accounts start with a few) # https://www.bulkvs.com/ # API is documented here: # - https://portal.bulkvs.com/api/v1.0/documentation#/\ # Messaging/post_messageSend import json import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase class NotifyBulkVS(NotifyBase): """A wrapper for BulkVS Notifications.""" # The default descriptive name associated with the Notification service_name = "BulkVS" # The services URL service_url = "https://www.bulkvs.com/" # All notification requests are secure secure_protocol = "bulkvs" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bulkvs/" # BulkVS uses the http protocol with JSON requests notify_url = "https://portal.bulkvs.com/api/v1.0/messageSend" # The maximum length of the body body_maxlen = 160 # The maximum amount of texts that can go out in one batch default_batch_size = 4000 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ( "{schema}://{user}:{password}@{from_phone}/{targets}", "{schema}://{user}:{password}@{from_phone}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "from_phone": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", "required": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__(self, source=None, targets=None, batch=None, **kwargs): """Initialize BulkVS Object.""" super().__init__(**kwargs) if not (self.user and self.password): msg = "A BulkVS user/pass was not provided." self.logger.warning(msg) raise TypeError(msg) result = is_phone_no(source) if not result: msg = ( f"The Account (From) Phone # specified ({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Tidy source self.source = result["full"] # Define whether or not we should operate in a batch mode self.batch = ( self.template_args["batch"]["default"] if batch is None else bool(batch) ) # Parse our targets self.targets = [] has_error = False for target in parse_phone_no(targets): # Parse each phone number we found result = is_phone_no(target) if result: self.targets.append(result["full"]) continue has_error = True self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) if not targets and not has_error: # Default the SMS Message to ourselves self.targets.append(self.source) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform BulkVS Notification.""" if not self.targets: # We have nothing to notify self.logger.warning("There are no BulkVS targets to notify") return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", } # Prepare our payload payload = { # The To gets populated in the loop below "From": self.source, "To": None, "Message": body, } # Authentication auth = (self.user, self.password) # Prepare our targets targets = ( list(self.targets) if batch_size == 1 else [ self.targets[index : index + batch_size] for index in range(0, len(self.targets), batch_size) ] ) while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["To"] = target # Printable reference if isinstance(target, list): p_target = f"{len(target)} targets" else: p_target = target # Some Debug Logging self.logger.debug( "BulkVS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"BulkVS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # A Response may look like: # { # "RefId": "5a66dee6-ff7a-40ee-8218-5805c074dc01", # "From": "13109060901", # "MessageType": "SMS|MMS", # "Results": [ # { # "To": "13105551212", # "Status": "SUCCESS" # }, # { # "To": "13105551213", # "Status": "SUCCESS" # } # ] # } if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) # set up our status code to use status_code = r.status_code self.logger.warning( "Failed to send BulkVS notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( f"Sent BulkVS notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending BulkVS: to %s ", p_target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.source, self.user, self.password) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # A nice way of cleaning up the URL length a bit targets = ( [] if len(self.targets) == 1 and self.targets[0] == self.source else self.targets ) return ( "{schema}://{user}:{password}@{source}/{targets}?{params}".format( schema=self.secure_protocol, source=self.source, user=self.pprint(self.user, privacy, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( [NotifyBulkVS.quote(f"{x}", safe="+") for x in targets] ), params=NotifyBulkVS.urlencode(params), ) ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if self.targets else 1 if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBulkVS.unquote(results["qsd"]["from"]) # hostname will also be a target in this case results["targets"] = [ *NotifyBulkVS.parse_phone_no(results["host"]), *NotifyBulkVS.split_path(results["fullpath"]), ] else: # store our source results["source"] = NotifyBulkVS.unquote(results["host"]) # store targets results["targets"] = NotifyBulkVS.split_path(results["fullpath"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBulkVS.parse_phone_no( results["qsd"]["to"] ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyBulkVS.template_args["batch"]["default"] ) ) return results caronc-apprise-182f859/apprise/plugins/burstsms.py000066400000000000000000000375351524161175200223110ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Sign-up with https://burstsms.com/ # # Define your API Secret here and acquire your API Key # - https://can.transmitsms.com/profile # import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase class BurstSMSCountryCode: # Australia AU = "au" # New Zeland NZ = "nz" # United Kingdom UK = "gb" # United States US = "us" BURST_SMS_COUNTRY_CODES = ( BurstSMSCountryCode.AU, BurstSMSCountryCode.NZ, BurstSMSCountryCode.UK, BurstSMSCountryCode.US, ) class NotifyBurstSMS(NotifyBase): """A wrapper for Burst SMS Notifications.""" # The default descriptive name associated with the Notification service_name = "Burst SMS" # The services URL service_url = "https://burstsms.com/" # The default protocol secure_protocol = "burstsms" # The maximum amount of SMS Messages that can reside within a single # batch transfer based on: # https://developer.transmitsms.com/#74911cf8-dec6-4319-a499-7f535a7fd08c default_batch_size = 500 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/burstsms/" # Burst SMS uses the http protocol with JSON requests notify_url = "https://api.transmitsms.com/send-sms.json" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{apikey}:{secret}@{sender_id}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "required": True, "regex": (r"^[a-z0-9]+$", "i"), "private": True, }, "secret": { "name": _("API Secret"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, "sender_id": { "name": _("Sender ID"), "type": "string", "required": True, "map_to": "source", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "sender_id", }, "key": { "alias_of": "apikey", }, "secret": { "alias_of": "secret", }, "country": { "name": _("Country"), "type": "choice:string", "values": BURST_SMS_COUNTRY_CODES, "default": BurstSMSCountryCode.US, }, # Validity # Expire a message send if it is undeliverable (defined in minutes) # If set to Zero (0); this is the default and sets the max validity # period "validity": {"name": _("validity"), "type": "int", "default": 0}, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, apikey, secret, source, targets=None, country=None, validity=None, batch=None, **kwargs, ): """Initialize Burst SMS Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid Burst SMS API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # API Secret (associated with project) self.secret = validate_regex( secret, *self.template_tokens["secret"]["regex"] ) if not self.secret: msg = f"An invalid Burst SMS API Secret ({secret}) was specified." self.logger.warning(msg) raise TypeError(msg) if not country: self.country = self.template_args["country"]["default"] else: self.country = country.lower().strip() if country not in BURST_SMS_COUNTRY_CODES: msg = ( f"An invalid Burst SMS country ({country}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Set our Validity self.validity = self.template_args["validity"]["default"] if validity: try: self.validity = int(validity) except (ValueError, TypeError): msg = ( f"The Burst SMS Validity specified ({validity}) is" " invalid." ) self.logger.warning(msg) raise TypeError(msg) from None # Prepare Batch Mode Flag self.batch = ( self.template_args["batch"]["default"] if batch is None else batch ) # The Sender ID self.source = validate_regex(source) if not self.source: msg = f"The Account Sender ID specified ({source}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Burst SMS Notification.""" if not self.targets: self.logger.warning( "There are no valid Burst SMS targets to notify." ) return False # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", } # Prepare our authentication auth = (self.apikey, self.secret) # Prepare our payload payload = { "countrycode": self.country, "message": body, # Sender ID "from": self.source, # The to gets populated in the loop below "to": None, } # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # Create a copy of the targets list targets = list(self.targets) for index in range(0, len(targets), batch_size): # Prepare our user payload["to"] = ",".join(self.targets[index : index + batch_size]) # Some Debug Logging self.logger.debug( "Burst SMS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Burst SMS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=payload, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBurstSMS.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Burst SMS notification to {} " "target(s): {}{}error={}.".format( len(self.targets[index : index + batch_size]), status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( "Sent Burst SMS notification to " f"{len(self.targets[index : index + batch_size])} " "target(s)." ) except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending Burst SMS " "notification to " f"{len(self.targets[index : index + batch_size])} " "target(s)." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "country": self.country, "batch": "yes" if self.batch else "no", } if self.validity: params["validity"] = str(self.validity) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{key}:{secret}@{source}/{targets}/?{params}".format( schema=self.secure_protocol, key=self.pprint(self.apikey, privacy, safe=""), secret=self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ), source=NotifyBurstSMS.quote(self.source, safe=""), targets="/".join( [NotifyBurstSMS.quote(x, safe="") for x in self.targets] ), params=NotifyBurstSMS.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.apikey, self.secret, self.source) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The hostname is our source (Sender ID) results["source"] = NotifyBurstSMS.unquote(results["host"]) # Get any remaining targets results["targets"] = NotifyBurstSMS.split_path(results["fullpath"]) # Get our account_side and auth_token from the user/pass config results["apikey"] = NotifyBurstSMS.unquote(results["user"]) results["secret"] = NotifyBurstSMS.unquote(results["password"]) # API Key if "key" in results["qsd"] and len(results["qsd"]["key"]): # Extract the API Key from an argument results["apikey"] = NotifyBurstSMS.unquote(results["qsd"]["key"]) # API Secret if "secret" in results["qsd"] and len(results["qsd"]["secret"]): # Extract the API Secret from an argument results["secret"] = NotifyBurstSMS.unquote( results["qsd"]["secret"] ) # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBurstSMS.unquote(results["qsd"]["from"]) if "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyBurstSMS.unquote( results["qsd"]["source"] ) # Support country if "country" in results["qsd"] and len(results["qsd"]["country"]): results["country"] = NotifyBurstSMS.unquote( results["qsd"]["country"] ) # Support validity value if "validity" in results["qsd"] and len(results["qsd"]["validity"]): results["validity"] = NotifyBurstSMS.unquote( results["qsd"]["validity"] ) # Get Batch Mode Flag if "batch" in results["qsd"] and len(results["qsd"]["batch"]): results["batch"] = parse_bool(results["qsd"]["batch"]) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBurstSMS.parse_phone_no( results["qsd"]["to"] ) return results caronc-apprise-182f859/apprise/plugins/chanify.py000066400000000000000000000154501524161175200220400ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Chanify # 1. Visit https://chanify.net/ # The API URL will look something like this: # https://api.chanify.net/v1/sender/token # import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase class NotifyChanify(NotifyBase): """A wrapper for Chanify Notifications.""" # The default descriptive name associated with the Notification service_name = _("Chanify") # The services URL service_url = "https://chanify.net/" # The default secure protocol secure_protocol = "chanify" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/chanify/" # Notification URL notify_url = "https://api.chanify.net/v1/sender/{token}/" # Define object templates templates = ("{schema}://{token}",) # The title is not used title_maxlen = 0 # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9._-]+$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, }, ) def __init__(self, token, **kwargs): """Initialize Chanify Object.""" super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The Chanify token specified ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Send our notification.""" # prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", } # Our Message payload = {"text": body} self.logger.debug( "Chanify GET URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Chanify Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url.format(token=self.token), data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyChanify.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Chanify notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent Chanify notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Chanify notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{token}/?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), params=NotifyChanify.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.token) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" # parse_url already handles getting the `user` and `password` fields # populated. results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Allow over-ride if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyChanify.unquote(results["qsd"]["token"]) else: results["token"] = NotifyChanify.unquote(results["host"]) return results caronc-apprise-182f859/apprise/plugins/chime.py000066400000000000000000000272331524161175200215060ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Amazon Chime Incoming Webhooks # # To create an incoming webhook for an Amazon Chime chat room: # 1. Open Amazon Chime and navigate to the chat room you want to # send notifications to. # 2. Choose the gear icon in the top-right corner of the chat room. # 3. Choose "Manage webhooks and bots". # 4. Choose "Add webhook", give it a name, then choose "Create". # 5. To copy the webhook URL, choose "Copy URL" next to your new # webhook in the list. # # The webhook URL will look something like: # https://hooks.chime.aws/incomingwebhooks/ # xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?token=AaBbCcDd%3D%3D # # Where the path segment after /incomingwebhooks/ is the Webhook ID # and the ?token= query parameter is the authentication token. # # Your Apprise URL would be assembled as: # chime://{WebhookID}/{Token} # # For example: # chime://xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/AaBbCcDd%3D%3D # # The token value may include base64 padding characters (=). Apprise # handles encoding automatically; you can paste the token exactly as # it appears in the Chime webhook URL (already URL-decoded form) or # as-is from the ?token= query parameter. # # References: # - https://docs.aws.amazon.com/chime/latest/ug/webhooks.html # - https://docs.aws.amazon.com/chime/latest/APIReference/\ # API_CreateIncomingWebhook.html from json import dumps import re import requests from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase # Extend HTTP Error Messages CHIME_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Webhook Token.", 403: "Forbidden - Webhook has been disabled or revoked.", 404: "Not Found - Invalid Webhook ID.", 429: "Too many requests; rate-limit exceeded.", } # Amazon Chime Incoming Webhook base URL CHIME_WEBHOOK_URL = ( "https://hooks.chime.aws/incomingwebhooks/{webhook_id}?token={token}" ) # Validates a Chime webhook ID (UUID-like hex string with hyphens, # but we keep this flexible to handle any future format changes) IS_WEBHOOK_ID = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", re.I) class NotifyChime(NotifyBase): """A wrapper for Amazon Chime Notifications.""" # The default descriptive name associated with the Notification service_name = "Amazon Chime" # The services URL service_url = "https://aws.amazon.com/chime/" # The default secure protocol secure_protocol = "chime" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/chime/" # Amazon Chime Webhook URL (used in send()) notify_url = CHIME_WEBHOOK_URL # Amazon Chime webhooks support Markdown in the Content field notify_format = NotifyFormat.MARKDOWN # Chime webhooks have no native title field; title_maxlen = 0 # causes the framework to prepend the title to the body automatically title_maxlen = 0 # Maximum allowed characters in the Content field body_maxlen = 4096 # Chime incoming webhooks do not support file attachments; # attachments can only be sent through the Chime SDK/API attachment_support = False # Define object URL templates templates = ("{schema}://{webhook_id}/{token}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "webhook_id": { "name": _("Webhook ID"), "type": "string", "private": True, "required": True, }, "token": { "name": _("Webhook Token"), "type": "string", "private": True, "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "webhook_id": { "alias_of": "webhook_id", }, "token": { "alias_of": "token", }, }, ) def __init__(self, webhook_id, token, **kwargs): """Initialize Amazon Chime Object.""" super().__init__(**kwargs) # Validate the Webhook ID (any non-empty, non-whitespace string) self.webhook_id = validate_regex(webhook_id) if not self.webhook_id: msg = ( "An invalid Amazon Chime Webhook ID " "({}) was specified.".format(webhook_id) ) self.logger.warning(msg) raise TypeError(msg) # Validate the Webhook Token self.token = validate_regex(token) if not self.token: msg = ( "An invalid Amazon Chime Webhook Token " "({}) was specified.".format(token) ) self.logger.warning(msg) raise TypeError(msg) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Amazon Chime Notification.""" # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # Prepare our payload; Chime webhooks accept a single Content field payload = { "Content": body, } # Construct the full webhook URL; the token must be URL-encoded # because it may contain base64 padding characters (=, +, /) notify_url = self.notify_url.format( webhook_id=self.webhook_id, token=NotifyChime.quote(self.token, safe=""), ) self.logger.debug( "Amazon Chime POST URL: %s (cert_verify=%s)", notify_url, self.verify_certificate, ) self.logger.debug("Amazon Chime Payload: %s", str(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyChime.http_response_code_lookup( r.status_code, CHIME_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Amazon Chime notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent Amazon Chime notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred posting to Amazon Chime." ) self.logger.debug("Socket Exception: %s", str(e)) return False return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol, self.webhook_id, self.token, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Acquire any global URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{webhook_id}/{token}/?{params}".format( schema=self.secure_protocol, webhook_id=self.pprint(self.webhook_id, privacy, safe=""), token=self.pprint(self.token, privacy, safe=""), params=NotifyChime.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re-instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Webhook ID is in the host position results["webhook_id"] = NotifyChime.unquote(results["host"]) # The token is the first entry in the URL path tokens = NotifyChime.split_path(results["fullpath"]) results["token"] = tokens.pop(0) if tokens else None # Allow ?token= to override the path-supplied token if "token" in results["qsd"] and results["qsd"]["token"]: results["token"] = NotifyChime.unquote(results["qsd"]["token"]) # Allow ?webhook_id= to override the host-supplied webhook ID if "webhook_id" in results["qsd"] and results["qsd"]["webhook_id"]: results["webhook_id"] = NotifyChime.unquote( results["qsd"]["webhook_id"] ) return results @staticmethod def parse_native_url(url): """Support native Amazon Chime webhook URLs. For example: https://hooks.chime.aws/incomingwebhooks/ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?token=AaBbCcDd%3D%3D """ result = re.match( r"^https?://hooks\.chime\.aws/incomingwebhooks/" r"(?P[a-z0-9][a-z0-9-]*[a-z0-9])/?" r"(\?.*token=(?P[^&#]+).*)?$", url, re.I, ) if result: # Unquote the token from the native URL's query string so # parse_url() receives the decoded form and can store it token = result.group("token") or "" return NotifyChime.parse_url( "{schema}://{webhook_id}/{token}".format( schema=NotifyChime.secure_protocol, webhook_id=result.group("webhook_id"), # Re-encode so parse_url / split_path decode it once token=NotifyChime.quote( NotifyChime.unquote(token), safe="" ), ) ) return None caronc-apprise-182f859/apprise/plugins/clickatell.py000066400000000000000000000243621524161175200225300ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from itertools import chain # To use this service you will need a Clickatell account to which you can get # your API_TOKEN at: # https://www.clickatell.com/ import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_phone_no, parse_phone_no, validate_regex from .base import NotifyBase class NotifyClickatell(NotifyBase): """A wrapper for Clickatell Notifications.""" # The default descriptive name associated with the Notification service_name = _("Clickatell") # The services URL service_url = "https://www.clickatell.com/" # All notification requests are secure secure_protocol = "clickatell" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/clickatell/" # Clickatell API Endpoint notify_url = "https://platform.clickatell.com/messages/http/send" # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 templates = ( "{schema}://{apikey}/{targets}", "{schema}://{source}@{apikey}/{targets}", ) template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Token"), "type": "string", "private": True, "required": True, }, "source": { "name": _("From Phone No"), "type": "string", "regex": (r"^[0-9\s)(+-]+$", "i"), }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) template_args = dict( NotifyBase.template_args, **{ "apikey": {"alias_of": "apikey"}, "to": { "alias_of": "targets", }, "from": { "alias_of": "source", }, }, ) def __init__(self, apikey, source=None, targets=None, **kwargs): """Initialize Clickatell Object.""" super().__init__(**kwargs) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid Clickatell API Token ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) self.source = None if source: result = is_phone_no(source) if not result: msg = ( "The Account (From) Phone # specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Tidy source self.source = result["full"] # Used for URL generation afterwards only self._invalid_targets = [] # Parse our targets self.targets = [] for target in parse_phone_no(targets, prefix=True): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) self._invalid_targets.append(target) continue # store valid phone number self.targets.append(result["full"]) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.apikey, self.source) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{source}{apikey}/{targets}/?{params}".format( schema=self.secure_protocol, source=f"{self.source}@" if self.source else "", apikey=self.pprint(self.apikey, privacy, safe="="), targets="/".join( [ NotifyClickatell.quote(t, safe="") for t in chain(self.targets, self._invalid_targets) ] ), params=self.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification. Always return 1 at least """ return len(self.targets) if self.targets else 1 def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Clickatell Notification.""" if not self.targets: # There were no targets to notify self.logger.warning("There were no Clickatell targets to notify") return False headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", } params_base = { "apiKey": self.apikey, "from": self.source, "content": body, } # error tracking (used for function return) has_error = False for target in self.targets: params = params_base.copy() params["to"] = target # Some Debug Logging self.logger.debug( "Clickatell GET URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Clickatell Payload: {params}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.get( self.notify_url, params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if ( r.status_code != requests.codes.ok and r.status_code != requests.codes.accepted ): # We had a problem status_str = self.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send Clickatell notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( "Sent Clickatell notification to %s", target ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Clickatell: to %s ", target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't parse the URL return results results["targets"] = NotifyClickatell.split_path(results["fullpath"]) results["apikey"] = NotifyClickatell.unquote(results["host"]) if results["user"]: results["source"] = NotifyClickatell.unquote(results["user"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyClickatell.parse_phone_no( results["qsd"]["to"] ) # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyClickatell.unquote( results["qsd"]["from"] ) return results caronc-apprise-182f859/apprise/plugins/clicksend.py000066400000000000000000000274671524161175200223710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, simply signup with clicksend: # https://www.clicksend.com/ # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - clicksend://{user}:{password}@{phoneno} # - clicksend://{user}:{password}@{phoneno1}/{phoneno2} # The API reference used to build this plugin was documented here: # https://developers.clicksend.com/docs/rest/v3/ # from json import dumps import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase # Extend HTTP Error Messages CLICKSEND_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", } class NotifyClickSend(NotifyBase): """A wrapper for ClickSend Notifications.""" # The default descriptive name associated with the Notification service_name = "ClickSend" # The services URL service_url = "https://clicksend.com/" # The default secure protocol secure_protocol = "clicksend" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/clicksend/" # ClickSend uses the http protocol with JSON requests notify_url = "https://rest.clicksend.com/v3/sms/send" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # The maximum SMS batch size accepted by the ClickSend API default_batch_size = 1000 # Define object templates templates = ("{schema}://{user}:{apikey}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "map_to": "password", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "key": { "alias_of": "apikey", }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__(self, targets=None, batch=False, **kwargs): """Initialize ClickSend Object.""" super().__init__(**kwargs) # Prepare Batch Mode Flag self.batch = batch # Parse our targets self.targets = [] if not (self.user and self.password): msg = "A ClickSend user/pass was not provided." self.logger.warning(msg) raise TypeError(msg) for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform ClickSend Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no ClickSend targets to notify.") return False headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # error tracking (used for function return) has_error = False # prepare JSON Object payload = {"messages": []} # Send in batches if identified to do so default_batch_size = 1 if not self.batch else self.default_batch_size for index in range(0, len(self.targets), default_batch_size): payload["messages"] = [ { "source": "php", "body": body, "to": f"+{to}", } for to in self.targets[index : index + default_batch_size] ] self.logger.debug( "ClickSend POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"ClickSend Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), auth=(self.user, self.password), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyClickSend.http_response_code_lookup( r.status_code, CLICKSEND_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send {} ClickSend notification{}: " "{}{}error={}.".format( len(payload["messages"]), ( f" to {self.targets[index]}" if default_batch_size == 1 else "(s)" ), status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: self.logger.info( "Sent {} ClickSend notification{}.".format( len(payload["messages"]), ( f" to {self.targets[index]}" if default_batch_size == 1 else "(s)" ), ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending {} ClickSend " "notification(s).".format(len(payload["messages"])) ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyClickSend.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( [NotifyClickSend.quote(x, safe="") for x in self.targets] ), params=NotifyClickSend.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.user, self.password) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyClickSend.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend( NotifyClickSend.split_path(results["fullpath"]) ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) # API Key if "key" in results["qsd"] and len(results["qsd"]["key"]): # Extract the API Key from an argument results["password"] = NotifyClickSend.unquote( results["qsd"]["key"] ) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyClickSend.parse_phone_no( results["qsd"]["to"] ) return results caronc-apprise-182f859/apprise/plugins/custom_form.py000066400000000000000000000453661524161175200227650ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_PATH_SAFE_CHARS from .base import NotifyBase class FORMPayloadField: """Identifies the fields available in the FORM Payload.""" VERSION = "version" TITLE = "title" MESSAGE = "message" MESSAGETYPE = "type" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS", ) class NotifyForm(NotifyBase): """A wrapper for Form Notifications.""" # Support # - file* # - file? # - file*name # - file?name # - ?file # - *file # - file # The code will convert the ? or * to the digit increments __attach_as_re = re.compile( r"((?P(?P[a-z0-9_-]+)?" r"(?P[*?+$:.%]+)(?P[a-z0-9_-]+))" r"|(?P(?P[a-z0-9_-]+)(?P[*?+$:.%]?)))", re.IGNORECASE, ) # Our count attach_as_count = "{:02d}" # the default attach_as value attach_as_default = f"file{attach_as_count}" # The default descriptive name associated with the Notification service_name = "Form" # The default protocol protocol = "form" # The default secure protocol secure_protocol = "forms" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/form/" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for Form requests since they are normally # local anyway request_rate_per_sec = 0 # Define the FORM version to place in all payloads # Version: Major.Minor, Major is only updated if the entire schema is # changed. If just adding new items (or removing old ones, only increment # the Minor! form_version = "1.0" # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, "attach-as": { "name": _("Attach File As"), "type": "string", "default": "file*", "map_to": "attach_as", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, attach_as=None, **kwargs, ): """Initialize Form Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Custom File Attachment Over-Ride Support if not isinstance(attach_as, str): # Default value self.attach_as = self.attach_as_default self.attach_multi_support = True else: result = self.__attach_as_re.match(attach_as.strip()) if not result: msg = f"The attach-as specified ({attach_as}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.attach_as = "" self.attach_multi_support = False if result.group("match1"): if result.group("id1a"): self.attach_as += result.group("id1a") self.attach_as += self.attach_as_count self.attach_multi_support = True self.attach_as += result.group("id1b") else: # result.group('match2'): self.attach_as += result.group("id2") if result.group("wc2"): self.attach_as += self.attach_as_count self.attach_multi_support = True # A payload map allows users to over-ride the default mapping if # they're detected with the :overide=value. Normally this would # create a new key and assign it the value specified. However # if the key you specify is actually an internally mapped one, # then a re-mapping takes place using the value self.payload_map = { FORMPayloadField.VERSION: FORMPayloadField.VERSION, FORMPayloadField.TITLE: FORMPayloadField.TITLE, FORMPayloadField.MESSAGE: FORMPayloadField.MESSAGE, FORMPayloadField.MESSAGETYPE: FORMPayloadField.MESSAGETYPE, } self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries self.payload_extras.update(payload) for key in list(self.payload_extras.keys()): # Any values set in the payload to alter a system related one # alters the system key. Hence :message=msg maps the 'message' # variable that otherwise already contains the payload to be # 'msg' instead (containing the payload) if key in self.payload_map: self.payload_map[key] = self.payload_extras[key] self.payload_overrides[key] = self.payload_extras[key] del self.payload_extras[key] return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Form Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) # Track our potential attachments files = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access attachment" f" {attachment.url(privacy=True)}." ) return False try: files.append( ( ( self.attach_as.format(no) if self.attach_multi_support else self.attach_as ), ( ( attachment.name if attachment.name else f"file{no:03}.dat" ), # file handle safely closed in # `finally`; inline open intentional open(attachment.path, "rb"), # noqa: SIM115 attachment.mimetype, ), ) ) except OSError as e: self.logger.warning( "An I/O error occurred while opening {}.".format( attachment.name if attachment else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False if not self.attach_multi_support and no > 1: self.logger.warning( "Multiple attachments provided while " "form:// Multi-Attachment Support not enabled" ) # prepare Form Object payload = {} for key, value in ( (FORMPayloadField.VERSION, self.form_version), (FORMPayloadField.TITLE, title), (FORMPayloadField.MESSAGE, body), (FORMPayloadField.MESSAGETYPE, notify_type.value), ): if not self.payload_map[key]: # Do not store element in payload response continue payload[self.payload_map[key]] = value # Apply any/all payload over-rides defined payload.update(self.payload_extras) auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath self.logger.debug( f"Form {self.method} URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Form Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() # For GET the payload becomes URL query parameters; for all other # methods it is sent as the request body. if self.method == "GET": payload.update(self.params) try: r = requests.request( self.method, url, files=files if files else None, data=payload if self.method != "GET" else None, params=payload if self.method == "GET" else self.params, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyForm.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Form %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent Form %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Form " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False except OSError as e: self.logger.warning( "An I/O error occurred while reading one of the " "attached files." ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: for file in files: # Ensure all files are closed file[1][1].close() return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) params.update({f":{k}": v for k, v in self.payload_overrides.items()}) if self.attach_as != self.attach_as_default: # Provide Attach-As extension details params["attach-as"] = self.attach_as # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyForm.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyForm.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyForm.quote(self.fullpath, safe=URL_PATH_SAFE_CHARS) if self.fullpath else "/" ), params=NotifyForm.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd-"].items() } # Allow Attach-As Support which over-rides the name of the filename # posted with the form:// # the default is file01, file02, file03, etc if "attach-as" in results["qsd"] and len(results["qsd"]["attach-as"]): results["attach_as"] = results["qsd"]["attach-as"] # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyForm.unquote(results["qsd"]["method"]) return results caronc-apprise-182f859/apprise/plugins/custom_json.py000066400000000000000000000350551524161175200227650ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging import requests from .. import exception from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_PATH_SAFE_CHARS from ..utils.sanitize import sanitize_payload from .base import NotifyBase class JSONPayloadField: """Identifies the fields available in the JSON Payload.""" VERSION = "version" TITLE = "title" MESSAGE = "message" ATTACHMENTS = "attachments" MESSAGETYPE = "type" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS", ) class NotifyJSON(NotifyBase): """A wrapper for JSON Notifications.""" # The default descriptive name associated with the Notification service_name = "JSON" # The default protocol protocol = "json" # The default secure protocol secure_protocol = "jsons" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/json/" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for JSON requests since they are normally # local anyway request_rate_per_sec = 0 # Define the JSON version to place in all payloads # Version: Major.Minor, Major is only updated if the entire schema is # changed. If just adding new items (or removing old ones, only increment # the Minor! json_version = "1.0" # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, **kwargs ): """Initialize JSON Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_extras = {} if payload: # Store our extra payload entries self.payload_extras.update(payload) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform JSON Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Apply any/all header over-rides defined headers.update(self.headers) # Track our potential attachments attachments = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Custom JSON attachment" f" {attachment.url(privacy=True)}." ) return False try: attachments.append( { "filename": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "base64": attachment.base64(), "mimetype": attachment.mimetype, } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Custom JSON attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Custom JSON attachment" f" {attachment.url(privacy=True)}" ) # Prepare JSON Object payload = { JSONPayloadField.VERSION: self.json_version, JSONPayloadField.TITLE: title, JSONPayloadField.MESSAGE: body, JSONPayloadField.ATTACHMENTS: attachments, JSONPayloadField.MESSAGETYPE: notify_type.value, } for key, value in self.payload_extras.items(): if key in payload: if not value: # Do not store element in payload response del payload[key] else: # Re-map payload[value] = payload[key] del payload[key] else: # Append entry payload[key] = value auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( f"JSON POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug("JSON Payload: %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.request( self.method, url, data=dumps(payload), params=self.params, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyJSON.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send JSON %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent JSON %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending JSON " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyJSON.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyJSON.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyJSON.quote(self.fullpath, safe=URL_PATH_SAFE_CHARS) if self.fullpath else "/" ), params=NotifyJSON.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd-"].items() } # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyJSON.unquote(results["qsd"]["method"]) return results caronc-apprise-182f859/apprise/plugins/custom_xml.py000066400000000000000000000435251524161175200226150ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import re import requests from .. import exception from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import URL_PATH_SAFE_CHARS from ..utils.sanitize import sanitize_payload from .base import NotifyBase class XMLPayloadField: """Identifies the fields available in the JSON Payload.""" VERSION = "Version" TITLE = "Subject" MESSAGE = "Message" MESSAGETYPE = "MessageType" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS", ) class NotifyXML(NotifyBase): """A wrapper for XML Notifications.""" # The default descriptive name associated with the Notification service_name = "XML" # The default protocol protocol = "xml" # The default secure protocol secure_protocol = "xmls" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/xml/" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for JSON requests since they are normally # local anyway request_rate_per_sec = 0 # XSD Information xsd_ver = "1.1" xsd_default_url = ( "https://raw.githubusercontent.com/caronc/apprise/master" "/apprise/assets/NotifyXML-{version}.xsd" ) # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, **kwargs ): """Initialize XML Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.payload = """ {{CORE}} {{ATTACHMENTS}} """ self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise TypeError(msg) # A payload map allows users to over-ride the default mapping if # they're detected with the :overide=value. Normally this would # create a new key and assign it the value specified. However # if the key you specify is actually an internally mapped one, # then a re-mapping takes place using the value self.payload_map = { XMLPayloadField.VERSION: XMLPayloadField.VERSION, XMLPayloadField.TITLE: XMLPayloadField.TITLE, XMLPayloadField.MESSAGE: XMLPayloadField.MESSAGE, XMLPayloadField.MESSAGETYPE: XMLPayloadField.MESSAGETYPE, } self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries (but tidy them up since they will # become XML Keys (they can't contain certain characters for k, v in payload.items(): key = re.sub(r"[^A-Za-z0-9_-]*", "", k) if not key: self.logger.warning( f"Ignoring invalid XML Stanza element name({k})" ) continue # Any values set in the payload to alter a system related one # alters the system key. Hence :message=msg maps the 'message' # variable that otherwise already contains the payload to be # 'msg' instead (containing the payload) if key in self.payload_map: self.payload_map[key] = v self.payload_overrides[key] = v else: self.payload_extras[key] = v # Set our xsd url self.xsd_url = ( None if self.payload_overrides or self.payload_extras else self.xsd_default_url.format(version=self.xsd_ver) ) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform XML Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, "Content-Type": "application/xml", } # Apply any/all header over-rides defined headers.update(self.headers) # Our XML Attachmement subsitution xml_attachments = "" payload_base = {} for key, value in ( (XMLPayloadField.VERSION, self.xsd_ver), ( XMLPayloadField.TITLE, NotifyXML.escape_html(title, whitespace=False), ), ( XMLPayloadField.MESSAGE, NotifyXML.escape_html(body, whitespace=False), ), ( XMLPayloadField.MESSAGETYPE, NotifyXML.escape_html(notify_type.value, whitespace=False), ), ): if not self.payload_map[key]: # Do not store element in payload response continue payload_base[self.payload_map[key]] = value # Apply our payload extras payload_base.update( { k: NotifyXML.escape_html(v, whitespace=False) for k, v in self.payload_extras.items() } ) # Base Entres xml_base = "".join( [f"<{k}>{v}" for k, v in payload_base.items()] ) attachments = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Custom XML attachment" f" {attachment.url(privacy=True)}." ) return False try: # Prepare our Attachment in Base64 entry = ''.format( NotifyXML.escape_html( ( attachment.name if attachment.name else f"file{no:03}.dat" ), whitespace=False, ), NotifyXML.escape_html( attachment.mimetype, whitespace=False ), ) entry += attachment.base64() entry += "" attachments.append(entry) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Custom XML attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Custom XML attachment" f" {attachment.url(privacy=True)}" ) # Update our xml_attachments record: xml_attachments = ( '' + "".join(attachments) + "" ) re_map = { "{{XSD_URL}}": ( f' xmlns:xsi="{self.xsd_url}"' if self.xsd_url else "" ), "{{ATTACHMENTS}}": xml_attachments, "{{CORE}}": xml_base, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath payload = re_table.sub(lambda x: re_map[x.group()], self.payload) # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( f"XML POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug("XML Payload: %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.request( self.method, url, data=payload, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyXML.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send JSON %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: self.logger.info("Sent XML %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending XML " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) params.update({f":{k}": v for k, v in self.payload_overrides.items()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyXML.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyXML.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyXML.quote(self.fullpath, safe=URL_PATH_SAFE_CHARS) if self.fullpath else "/" ), params=NotifyXML.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd-"].items() } # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyXML.unquote(results["qsd"]["method"]) return results caronc-apprise-182f859/apprise/plugins/d7networks.py000066400000000000000000000370141524161175200225260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a D7 Networks account from their website # at https://d7networks.com/ # # After you've established your account you can get your api login credentials # (both user and password) from the API Details section from within your # account profile area: https://d7networks.com/accounts/profile/ # # API Reference: https://d7networks.com/docs/Messages/Send_Message/ from json import dumps, loads import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase # Extend HTTP Error Messages D7NETWORKS_HTTP_ERROR_MAP = { 401: "Invalid Argument(s) Specified.", 403: "Unauthorized - Authentication Failure.", 412: "A Routing Error Occured", 500: "A Serverside Error Occured Handling the Request.", } class NotifyD7Networks(NotifyBase): """A wrapper for D7 Networks Notifications.""" # The default descriptive name associated with the Notification service_name = "D7 Networks" # The services URL service_url = "https://d7networks.com/" # All notification requests are secure secure_protocol = "d7sms" # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.20 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/d7networks/" # D7 Networks single notification URL notify_url = "https://api.d7networks.com/messages/v1/send" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{token}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("API Access Token"), "type": "string", "required": True, "private": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "source": { # Originating address,In cases where the rewriting of the # sender's address is supported or permitted by the SMS-C. # This is used to transmit the message, this number is # transmitted as the originating address and is completely # optional. "name": _("Originating Address"), "type": "string", "map_to": "source", }, "from": { "alias_of": "source", }, "unicode": { # Unicode characters (default is 'auto') "name": _("Unicode Characters"), "type": "bool", "default": False, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, token=None, targets=None, source=None, batch=False, unicode=None, **kwargs, ): """Initialize D7 Networks Object.""" super().__init__(**kwargs) # Prepare Batch Mode Flag self.batch = batch # Setup our source address (if defined) self.source = None if not isinstance(source, str) else source.strip() # Define whether or not we should set the unicode flag self.unicode = ( self.template_args["unicode"]["default"] if unicode is None else bool(unicode) ) # The token associated with the account self.token = validate_regex(token) if not self.token: msg = f"The D7 Networks token specified ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Depending on whether we are set to batch mode or single mode this redirects to the appropriate handling.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no D7 Networks targets to notify.") return False # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", "Authorization": f"Bearer {self.token}", } payload = { "message_globals": { "channel": "sms", }, "messages": [ { # Populated later on "recipients": None, "content": body, "data_coding": # auto is a better substitute over 'text' as text # is easier to detect from a post than `unicode`. "auto" if not self.unicode else "unicode", } ], } # use the list directly targets = list(self.targets) if self.source: payload["message_globals"]["originator"] = self.source target = None while len(targets): if self.batch: # Prepare our payload payload["messages"][0]["recipients"] = self.targets # Reset our targets so we don't keep going. This is required # because we're in batch mode; we only need to loop once. targets = [] else: # We're not in a batch mode; so get our next target # Get our target(s) to notify target = targets.pop(0) # Prepare our payload payload["messages"][0]["recipients"] = [target] # Some Debug Logging self.logger.debug( "D7 Networks POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"D7 Networks Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code not in ( requests.codes.created, requests.codes.ok, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code, D7NETWORKS_HTTP_ERROR_MAP ) try: # Update our status response if we can json_response = loads(r.content) status_str = json_response.get("message", status_str) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None # We could not parse JSON response. # We will just use the status we already have. self.logger.debug( "Failed to parse D7Networks JSON response; " "body: %r", (r.content or b"")[:2000], ) pass self.logger.warning( "Failed to send D7 Networks SMS notification to {}: " "{}{}error={}.".format( ", ".join(target) if self.batch else target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True continue else: if self.batch: self.logger.info( "Sent D7 Networks batch SMS notification to " f"{len(self.targets)} target(s)." ) else: self.logger.info( f"Sent D7 Networks SMS notification to {target}." ) self.logger.debug(f"Response Details:\r\n{r.content}") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending" " D7 Networks:{} notification.".format( ", ".join(self.targets) ) ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", "unicode": "yes" if self.unicode else "no", } if self.source: params["from"] = self.source # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{token}@{targets}/?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), targets="/".join( [NotifyD7Networks.quote(x, safe="") for x in self.targets] ), params=NotifyD7Networks.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.token) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # return len(self.targets) if not self.batch else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyD7Networks.unquote( results["qsd"]["token"] ) elif results["user"]: results["token"] = NotifyD7Networks.unquote(results["user"]) if results["password"]: # Support token containing a colon (:) results["token"] += ":" + NotifyD7Networks.unquote( results["password"] ) elif results["password"]: # Support token starting with a colon (:) results["token"] = ":" + NotifyD7Networks.unquote( results["password"] ) # Initialize our targets results["targets"] = [] # The store our first target stored in the hostname results["targets"].append(NotifyD7Networks.unquote(results["host"])) # Get our entries; split_path() looks after unquoting content for us # by default results["targets"].extend( NotifyD7Networks.split_path(results["fullpath"]) ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) # Get Unicode Flag results["unicode"] = parse_bool(results["qsd"].get("unicode", False)) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyD7Networks.parse_phone_no( results["qsd"]["to"] ) # Support the 'from' and source variable if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyD7Networks.unquote( results["qsd"]["from"] ) elif "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyD7Networks.unquote( results["qsd"]["source"] ) return results caronc-apprise-182f859/apprise/plugins/dapnet.py000066400000000000000000000331471524161175200216750ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, sign up with Hampager (you need to be a licensed # ham radio operator # http://www.hampager.de/ # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - dapnet://{user}:{password}@{callsign} # - dapnet://{user}:{password}@{callsign1}/{callsign2} # Optional parameters: # - priority (NORMAL or EMERGENCY). Default: NORMAL # - txgroups --> comma-separated list of DAPNET transmitter # groups. Default: 'dl-all' # https://hampager.de/#/transmitters/groups from json import dumps # The API reference used to build this plugin was documented here: # https://hampager.de/dokuwiki/doku.php#dapnet_api # import requests from requests.auth import HTTPBasicAuth from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_call_sign, parse_bool, parse_call_sign, parse_list from .base import NotifyBase class DapnetPriority: NORMAL = 0 EMERGENCY = 1 DAPNET_PRIORITIES = { DapnetPriority.NORMAL: "normal", DapnetPriority.EMERGENCY: "emergency", } DAPNET_PRIORITY_MAP = { # Maps against string 'normal' "n": DapnetPriority.NORMAL, # Maps against string 'emergency' "e": DapnetPriority.EMERGENCY, # Entries to additionally support (so more like Dapnet's API) "0": DapnetPriority.NORMAL, "1": DapnetPriority.EMERGENCY, } class NotifyDapnet(NotifyBase): """A wrapper for DAPNET / Hampager Notifications.""" # The default descriptive name associated with the Notification service_name = "Dapnet" # The services URL service_url = "https://hampager.de/" # The default secure protocol secure_protocol = "dapnet" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dapnet/" # Dapnet uses the http protocol with JSON requests notify_url = "http://www.hampager.de:8080/calls" # The maximum length of the body body_maxlen = 80 # A title can not be used for Dapnet Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # The maximum amount of emails that can reside within a single transmission default_batch_size = 50 # Define object templates templates = ("{schema}://{user}:{password}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_callsign": { "name": _("Target Callsign"), "type": "string", "regex": ( r"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$", "i", ), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "priority": { "name": _("Priority"), "type": "choice:int", "values": DAPNET_PRIORITIES, "default": DapnetPriority.NORMAL, }, "txgroups": { "name": _("Transmitter Groups"), "type": "string", "default": "dl-all", "private": True, }, "to": { "name": _("Target Callsign"), "type": "string", "map_to": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, targets=None, priority=None, txgroups=None, batch=False, **kwargs ): """Initialize Dapnet Object.""" super().__init__(**kwargs) # Parse our targets self.targets = [] # The Priority of the message self.priority = int( NotifyDapnet.template_args["priority"]["default"] if priority is None else next( ( v for k, v in DAPNET_PRIORITY_MAP.items() if str(priority).lower().startswith(k) ), NotifyDapnet.template_args["priority"]["default"], ) ) if not (self.user and self.password): msg = "A Dapnet user/pass was not provided." self.logger.warning(msg) raise TypeError(msg) # Get the transmitter group self.txgroups = parse_list( txgroups if txgroups else NotifyDapnet.template_args["txgroups"]["default"] ) # Prepare Batch Mode Flag self.batch = batch for target in parse_call_sign(targets): # Validate targets and drop bad ones: result = is_call_sign(target) if not result: self.logger.warning( f"Dropping invalid Amateur radio call sign ({target}).", ) continue # Store callsign without SSID and ignore duplicates if result["callsign"] not in self.targets: self.targets.append(result["callsign"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Dapnet Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Amateur radio callsigns to notify" ) return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) for index in range(0, len(targets), batch_size): # prepare JSON payload payload = { "text": body, "callSignNames": targets[index : index + batch_size], "transmitterGroupNames": self.txgroups, "emergency": self.priority == DapnetPriority.EMERGENCY, } self.logger.debug(f"DAPNET POST URL: {self.notify_url}") self.logger.debug(f"DAPNET Payload: {dumps(payload)}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, auth=HTTPBasicAuth( username=self.user, password=self.password ), verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.created: # We had a problem self.logger.warning( "Failed to send DAPNET notification {} to {}: " "error={}.".format( payload["text"], f" to {self.targets}", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure has_error = True else: self.logger.info( "Sent '{}' DAPNET notification {}".format( payload["text"], f"to {self.targets}" ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending DAPNET " f"notification to {self.targets}" ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "priority": ( DAPNET_PRIORITIES[self.template_args["priority"]["default"]] if self.priority not in DAPNET_PRIORITIES else DAPNET_PRIORITIES[self.priority] ), "batch": "yes" if self.batch else "no", "txgroups": ",".join(self.txgroups), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyDapnet.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( [self.pprint(x, privacy, safe="") for x in self.targets] ), params=NotifyDapnet.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.user, self.password) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyDapnet.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend(NotifyDapnet.split_path(results["fullpath"])) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyDapnet.parse_list(results["qsd"]["to"]) # Set our priority if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyDapnet.unquote( results["qsd"]["priority"] ) # Check for one or multiple transmitter groups (comma separated) # and split them up, when necessary if "txgroups" in results["qsd"]: results["txgroups"] = [ x.lower() for x in NotifyDapnet.parse_list(results["qsd"]["txgroups"]) ] # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyDapnet.template_args["batch"]["default"] ) ) return results caronc-apprise-182f859/apprise/plugins/dbus.py000066400000000000000000000345761524161175200213660ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import sys from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool from .base import NotifyBase # Default our global support flag NOTIFY_DBUS_SUPPORT_ENABLED = False # Image support is dependant on the GdkPixbuf library being available NOTIFY_DBUS_IMAGE_SUPPORT = False # Initialize our mainloops LOOP_GLIB = None LOOP_QT = None try: # D-Bus Message Bus Daemon 1.12.XX Essentials from dbus import Byte, ByteArray, DBusException, Interface, SessionBus # # now we try to determine which mainloop(s) we can access # # glib/dbus try: from dbus.mainloop.glib import DBusGMainLoop LOOP_GLIB = DBusGMainLoop() except ImportError: # pragma: no cover # No problem pass # qt try: from dbus.mainloop.qt import DBusQtMainLoop LOOP_QT = DBusQtMainLoop(set_as_default=True) except ImportError: # No problem pass # We're good as long as at least one NOTIFY_DBUS_SUPPORT_ENABLED = LOOP_GLIB is not None or LOOP_QT is not None # ImportError: When using gi.repository you must not import static modules # like "gobject". Please change all occurrences of "import gobject" to # "from gi.repository import GObject". # See: https://bugzilla.gnome.org/show_bug.cgi?id=709183 if "gobject" in sys.modules: # pragma: no cover del sys.modules["gobject"] try: # The following is required for Image/Icon loading only import gi gi.require_version("GdkPixbuf", "2.0") from gi.repository import GdkPixbuf NOTIFY_DBUS_IMAGE_SUPPORT = True except (ImportError, ValueError, AttributeError): # No problem; this will get caught in outer try/catch # A ValueError will get thrown upon calling gi.require_version() if # GDK/GTK isn't installed on the system but gi is. pass except ImportError: # No problem; we just simply can't support this plugin; we could # be in microsoft windows, or we just don't have the python-gobject # library available to us (or maybe one we don't support)? pass # Define our supported protocols and the loop to assign them. # The key to value pairs are the actual supported schema's matched # up with the Main Loop they should reference when accessed. MAINLOOP_MAP = { "qt": LOOP_QT, "kde": LOOP_QT, "dbus": LOOP_QT if LOOP_QT else LOOP_GLIB, } # Urgencies class DBusUrgency: LOW = 0 NORMAL = 1 HIGH = 2 DBUS_URGENCIES = { # Note: This also acts as a reverse lookup mapping DBusUrgency.LOW: "low", DBusUrgency.NORMAL: "normal", DBusUrgency.HIGH: "high", } DBUS_URGENCY_MAP = { # Maps against string 'low' "l": DBusUrgency.LOW, # Maps against string 'moderate' "m": DBusUrgency.LOW, # Maps against string 'normal' "n": DBusUrgency.NORMAL, # Maps against string 'high' "h": DBusUrgency.HIGH, # Maps against string 'emergency' "e": DBusUrgency.HIGH, # Entries to additionally support (so more like DBus's API) "0": DBusUrgency.LOW, "1": DBusUrgency.NORMAL, "2": DBusUrgency.HIGH, } class NotifyDBus(NotifyBase): """A wrapper for local DBus/Qt Notifications.""" # Set our global enabled flag enabled = NOTIFY_DBUS_SUPPORT_ENABLED requirements = { # Define our required packaging in order to work "details": _("libdbus-1.so.x must be installed.") } # The default descriptive name associated with the Notification service_name = _("DBus Notification") # The services URL service_url = "http://www.freedesktop.org/Software/dbus/" # The default protocols # Python 3 keys() does not return a list object, it is its own dict_keys() # object if we were to reference, we wouldn't be backwards compatible with # Python v2. So converting the result set back into a list makes us # compatible protocol = list(MAINLOOP_MAP.keys()) # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dbus/" # No throttling required for DBus queries request_rate_per_sec = 0 # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # The number of milliseconds to keep the message present for message_timeout_ms = 13000 # Limit results to just the first 10 line otherwise there is just to much # content to display body_max_line_count = 10 # The following are required to hook into the notifications: dbus_interface = "org.freedesktop.Notifications" dbus_setting_location = "/org/freedesktop/Notifications" # No URL Identifier will be defined for this service as there simply isn't # enough details to uniquely identify one dbus:// from another. url_identifier = False # Define object templates templates = ("{schema}://",) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "urgency": { "name": _("Urgency"), "type": "choice:int", "values": DBUS_URGENCIES, "default": DBusUrgency.NORMAL, }, "priority": { # Apprise uses 'priority' everywhere; it's just a nice # consistent feel to be able to use it here as well. Just map # the value back to 'priority' "alias_of": "urgency", }, "x": { "name": _("X-Axis"), "type": "int", "min": 0, "map_to": "x_axis", }, "y": { "name": _("Y-Axis"), "type": "int", "min": 0, "map_to": "y_axis", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, }, ) def __init__( self, urgency=None, x_axis=None, y_axis=None, include_image=True, **kwargs, ): """Initialize DBus Object.""" super().__init__(**kwargs) # Track our notifications self.registry = {} # Store our schema; default to dbus self.schema = kwargs.get("schema", "dbus") if self.schema not in MAINLOOP_MAP: msg = f"The schema specified ({self.schema}) is not supported." self.logger.warning(msg) raise TypeError(msg) # The urgency of the message self.urgency = int( NotifyDBus.template_args["urgency"]["default"] if urgency is None else next( ( v for k, v in DBUS_URGENCY_MAP.items() if str(urgency).lower().startswith(k) ), NotifyDBus.template_args["urgency"]["default"], ) ) # Our x/y axis settings if x_axis or y_axis: try: self.x_axis = int(x_axis) self.y_axis = int(y_axis) except (TypeError, ValueError): # Invalid x/y values specified msg = ( f"The x,y coordinates specified ({x_axis},{y_axis}) are" " invalid." ) self.logger.warning(msg) raise TypeError(msg) from None else: self.x_axis = None self.y_axis = None # Track whether we want to add an image to the notification. self.include_image = include_image def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform DBus Notification.""" # Acquire our session try: session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) except DBusException as e: # Handle exception self.logger.warning("Failed to send DBus notification.") self.logger.debug(f"DBus Exception: {e}") return False # If there is no title, but there is a body, swap the two to get rid # of the weird whitespace if not title: title = body body = "" # acquire our dbus object dbus_obj = session.get_object( self.dbus_interface, self.dbus_setting_location, ) # Acquire our dbus interface dbus_iface = Interface( dbus_obj, dbus_interface=self.dbus_interface, ) # image path icon_path = ( None if not self.include_image else self.image_path(notify_type, extension=".ico") ) # Our meta payload meta_payload = {"urgency": Byte(self.urgency)} if not (self.x_axis is None and self.y_axis is None): # Set x/y access if these were set meta_payload["x"] = self.x_axis meta_payload["y"] = self.y_axis if NOTIFY_DBUS_IMAGE_SUPPORT and icon_path: try: # Use Pixbuf to create the proper image type image = GdkPixbuf.Pixbuf.new_from_file(icon_path) # Associate our image to our notification meta_payload["icon_data"] = ( image.get_width(), image.get_height(), image.get_rowstride(), image.get_has_alpha(), image.get_bits_per_sample(), image.get_n_channels(), ByteArray(image.get_pixels()), ) except Exception as e: self.logger.warning( "Could not load notification icon (%s).", icon_path ) self.logger.debug(f"DBus Exception: {e}") try: # Always call throttle() before any remote execution is made self.throttle() dbus_iface.Notify( # Application Identifier self.app_id, # Message ID (0 = New Message) 0, # Icon (str) - not used "", # Title str(title), # Body str(body), # Actions [], # Meta meta_payload, # Message Timeout self.message_timeout_ms, ) self.logger.info("Sent DBus notification.") except Exception as e: self.logger.warning("Failed to send DBus notification.") self.logger.debug(f"DBus Exception: {e}") return False return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "image": "yes" if self.include_image else "no", "urgency": ( DBUS_URGENCIES[self.template_args["urgency"]["default"]] if self.urgency not in DBUS_URGENCIES else DBUS_URGENCIES[self.urgency] ), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # x in (x,y) screen coordinates # Use an explicit None check so a valid coordinate of 0 (a screen # edge) is preserved; this mirrors the send() payload which guards # on `is None` rather than truthiness. if self.x_axis is not None: params["x"] = str(self.x_axis) # y in (x,y) screen coordinates if self.y_axis is not None: params["y"] = str(self.y_axis) return f"{self.schema}://_/?{NotifyDBus.urlencode(params)}" @staticmethod def parse_url(url): """There are no parameters nessisary for this protocol; simply having gnome:// is all you need. This function just makes sure that is in place. """ results = NotifyBase.parse_url(url, verify_host=False) # Include images with our message results["include_image"] = parse_bool( results["qsd"].get("image", True) ) # DBus supports urgency, but we we also support the keyword priority # so that it is consistent with some of the other plugins if "priority" in results["qsd"] and len(results["qsd"]["priority"]): # We intentionally store the priority in the urgency section results["urgency"] = NotifyDBus.unquote(results["qsd"]["priority"]) if "urgency" in results["qsd"] and len(results["qsd"]["urgency"]): results["urgency"] = NotifyDBus.unquote(results["qsd"]["urgency"]) # handle x,y coordinates if "x" in results["qsd"] and len(results["qsd"]["x"]): results["x_axis"] = NotifyDBus.unquote(results["qsd"].get("x")) if "y" in results["qsd"] and len(results["qsd"]["y"]): results["y_axis"] = NotifyDBus.unquote(results["qsd"].get("y")) return results caronc-apprise-182f859/apprise/plugins/dingtalk.py000066400000000000000000000301701524161175200222100ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import hashlib import hmac from json import dumps import re import time import requests from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_list, validate_regex from .base import NotifyBase # Register at https://dingtalk.com # - Download their PC based software as it is the only way you can create # a custom robot. You can create a custom robot per group. You will # be provided an access_token that Apprise will need. # Syntax: # dingtalk://{access_token}/ # dingtalk://{access_token}/{optional_phone_no} # dingtalk://{access_token}/{phone_no_1}/{phone_no_2}/{phone_no_N/ # Some Phone Number Detection IS_PHONE_NO = re.compile(r"^\+?(?P[0-9\s)(+-]+)\s*$") class NotifyDingTalk(NotifyBase): """A wrapper for DingTalk Notifications.""" # The default descriptive name associated with the Notification service_name = "DingTalk" # The services URL service_url = "https://www.dingtalk.com/" # All notification requests are secure secure_protocol = "dingtalk" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dingtalk/" # DingTalk API notify_url = "https://oapi.dingtalk.com/robot/send?access_token={token}" # Do not set title_maxlen as it is set in a property value below # since the length varies depending if we are doing a markdown # based message or a text based one. # title_maxlen = see below @propery defined # Define object templates templates = ( "{schema}://{token}/", "{schema}://{token}/{targets}/", "{schema}://{secret}@{token}/", "{schema}://{secret}@{token}/{targets}/", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, "secret": { "name": _("Secret"), "type": "string", "private": True, "regex": (r"^[a-z0-9]+$", "i"), }, "target_phone_no": { "name": _("Target Phone No"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, "token": { "alias_of": "token", }, "secret": { "alias_of": "secret", }, }, ) def __init__(self, token, targets=None, secret=None, **kwargs): """Initialize DingTalk Object.""" super().__init__(**kwargs) # Secret Key (associated with project) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"An invalid DingTalk API Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) self.secret = None if secret: self.secret = validate_regex( secret, *self.template_tokens["secret"]["regex"] ) if not self.secret: msg = f"An invalid DingTalk Secret ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # Parse our targets self.targets = [] for target in parse_list(targets): # Validate targets and drop bad ones: result = IS_PHONE_NO.match(target) if result: # Further check our phone # for it's digit count result = "".join(re.findall(r"\d+", result.group("phone"))) if len(result) < 11 or len(result) > 14: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result) continue self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) return def get_signature(self): """Calculates time-based signature so that we can send arbitrary messages.""" timestamp = str(round(time.time() * 1000)) secret_enc = self.secret.encode("utf-8") str_to_sign_enc = f"{timestamp}\n{self.secret}".encode() hmac_code = hmac.new( secret_enc, str_to_sign_enc, digestmod=hashlib.sha256 ).digest() signature = NotifyDingTalk.quote(base64.b64encode(hmac_code), safe="") return timestamp, signature def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform DingTalk Notification.""" payload = { "msgtype": "text", "at": { "atMobiles": self.targets, "isAtAll": False, }, } if self.notify_format == NotifyFormat.MARKDOWN: payload["markdown"] = { "title": title, "text": body, } else: payload["text"] = { "content": body, } # Our Notification URL notify_url = self.notify_url.format(token=self.token) params = None if self.secret: timestamp, signature = self.get_signature() params = { "timestamp": timestamp, "sign": signature, } # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Some Debug Logging self.logger.debug( "DingTalk URL:" f" {notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"DingTalk Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=dumps(payload), headers=headers, params=params, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyDingTalk.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send DingTalk notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return False else: self.logger.info("Sent DingTalk notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occured sending DingTalk notification." ) self.logger.debug(f"Socket Exception: {e!s}") return False return True @property def title_maxlen(self): """The title isn't used when not in markdown mode.""" return ( NotifyBase.title_maxlen if self.notify_format == NotifyFormat.MARKDOWN else 0 ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any arguments set args = { "format": self.notify_format, "overflow": self.overflow_mode, "verify": "yes" if self.verify_certificate else "no", } return "{schema}://{secret}{token}/{targets}/?{args}".format( schema=self.secure_protocol, secret=( "" if not self.secret else "{}@".format( self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ) ) ), token=self.pprint(self.token, privacy, safe=""), targets="/".join( [NotifyDingTalk.quote(x, safe="") for x in self.targets] ), args=NotifyDingTalk.urlencode(args), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.secret, self.token) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to substantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results results["token"] = NotifyDingTalk.unquote(results["host"]) # if a user has been defined, use it's value as the secret if results.get("user"): results["secret"] = results.get("user") # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = NotifyDingTalk.split_path(results["fullpath"]) # Support the use of the `token` keyword argument if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyDingTalk.unquote(results["qsd"]["token"]) # Support the use of the `secret` keyword argument if "secret" in results["qsd"] and len(results["qsd"]["secret"]): results["secret"] = NotifyDingTalk.unquote( results["qsd"]["secret"] ) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyDingTalk.parse_list( results["qsd"]["to"] ) return results caronc-apprise-182f859/apprise/plugins/discord.py000066400000000000000000001231741524161175200220510ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # For this to work correctly you need to create a webhook. To do this just # click on the little gear icon next to the channel you're part of. From # here you'll be able to access the Webhooks menu and create a new one. # # When you've completed, you'll get a URL that looks a little like this: # https://discord.com/api/webhooks/417429632418316298/\ # JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js # # Simplified, it looks like this: # https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN # # This plugin will simply work using the url of: # discord://WEBHOOK_ID/WEBHOOK_TOKEN # # API Documentation on Webhooks: # - https://discord.com/developers/docs/resources/webhook # from __future__ import annotations from datetime import datetime, timedelta, timezone from itertools import chain from json import dumps, loads from json.decoder import JSONDecodeError import re from typing import Any import requests from ..apprise_attachment import AppriseAttachment from ..attachment.base import AttachBase from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, parse_list, validate_regex from ..utils.templates import TemplateType, apply_template from .base import NotifyBase # Used to detect user/role IDs and @here/@everyone tokens. USER_ROLE_DETECTION_RE = re.compile( r"\s*(?:&?)(?P[0-9]+)>?|@(?P[a-z0-9]+))", re.I ) class NotifyDiscord(NotifyBase): """A wrapper to Discord Notifications.""" # The default descriptive name associated with the Notification service_name = "Discord" # The services URL service_url = "https://discord.com/" # The default secure protocol secure_protocol = "discord" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/discord/" # Discord Webhook notify_url = "https://discord.com/api/webhooks" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 # Discord is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: # X-RateLimit-Reset: The epoc time (in seconds) we can expect our # rate-limit to be reset. # X-RateLimit-Remaining: an integer identifying how many requests we're # still allow to make. request_rate_per_sec = 0 # Taken right from google.auth.helpers: clock_skew = timedelta(seconds=10) # The maximum allowable characters allowed in the body per message body_maxlen = 2000 # The 2000 characters above defined by the body_maxlen include that of the # title. Setting this to True ensures overflow options behave properly overflow_amalgamate_title = True # Discord has a limit of the number of fields you can include in an # embeds message. This value allows the discord message to safely # break into multiple messages to handle these cases. discord_max_fields = 10 # There is no reason we should exceed 35KB when reading in a JSON # file. If it is more than this, then it is not accepted max_discord_template_size = 35000 # Maximum number of attachments Discord accepts in a single message discord_max_attachments = 10 # Maximum total attachment bytes per message. # Discord's documented default is 25 MiB for most users. discord_max_attach_bytes = 25 * 1024 * 1024 # Define object templates templates = ( "{schema}://{webhook_id}/{webhook_token}", "{schema}://{botname}@{webhook_id}/{webhook_token}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "botname": { "name": _("Bot Name"), "type": "string", "map_to": "user", }, "webhook_id": { "name": _("Webhook ID"), "type": "string", "private": True, "required": True, }, "webhook_token": { "name": _("Webhook Token"), "type": "string", "private": True, "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "tts": { "name": _("Text To Speech"), "type": "bool", "default": False, }, "avatar": { "name": _("Avatar Image"), "type": "bool", "default": True, }, "avatar_url": { "name": _("Avatar URL"), "type": "string", }, "href": { "name": _("URL"), "type": "string", }, "url": { "alias_of": "href", }, # Send a message to the specified thread within a webhook's # channel. The thread will automatically be unarchived. "thread": { "name": _("Thread ID"), "type": "string", }, "footer": { "name": _("Display Footer"), "type": "bool", "default": False, }, "footer_logo": { "name": _("Footer Logo"), "type": "bool", "default": True, }, "fields": { "name": _("Use Fields"), "type": "bool", "default": True, }, "flags": { "name": _("Discord Flags"), "type": "int", "min": 0, }, "image": { "name": _("Include Image"), "type": "bool", "default": False, "map_to": "include_image", }, # Explicit ping targets. Examples: # - ping=12345,67890 # - ping=<@12345>,<@&67890>,@here "ping": { "name": _("Ping Users/Roles"), "type": "list:string", }, "template": { "name": _("Template Path"), "type": "string", "private": True, }, # When True (default) multiple attachments are grouped into # batches and sent in a single message where possible. # Set to no/false to revert to one attachment per message. "batch": { "name": _("Batch Attachments"), "type": "bool", "default": True, }, }, ) # Define our token control template_kwargs = { "tokens": { "name": _("Template Tokens"), "prefix": ":", }, } def __init__( self, webhook_id: str, webhook_token: str, tts: bool = False, avatar: bool = True, footer: bool = False, footer_logo: bool = True, include_image: bool = False, fields: bool = True, avatar_url: str | None = None, href: str | None = None, thread: str | None = None, flags: int | None = None, ping: list[str] | None = None, template: str | None = None, tokens: dict | None = None, batch: bool | None = None, **kwargs: Any, ) -> None: """Initialize Discord Object.""" super().__init__(**kwargs) # Webhook ID (associated with project) self.webhook_id = validate_regex(webhook_id) if not self.webhook_id: msg = ( f"An invalid Discord Webhook ID ({webhook_id}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Webhook Token (associated with project) self.webhook_token = validate_regex(webhook_token) if not self.webhook_token: msg = ( "An invalid Discord Webhook Token " f"({webhook_token}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Text To Speech self.tts = tts # Over-ride Avatar Icon self.avatar = avatar # Place a footer self.footer = footer # include a footer_logo in footer self.footer_logo = footer_logo # Place a thumbnail image inline with the message body self.include_image = include_image # Use Fields self.fields = fields # Specified Thread ID self.thread_id = thread # Avatar URL # This allows a user to provide an over-ride to the otherwise # dynamically generated avatar url images self.avatar_url = avatar_url # A URL to have the title link to self.href = href # A URL to have the title link to if flags: try: self.flags = int(flags) if self.flags < NotifyDiscord.template_args["flags"]["min"]: raise ValueError() except (TypeError, ValueError): msg = ( f"An invalid Discord flags setting ({flags}) was " "specified." ) self.logger.warning(msg) raise TypeError(msg) from None else: self.flags = None # Ping targets (tokens from URL, already split by parse_list) self.ping: list[str] = parse_list(ping) # When True, group multiple attachments into a single message # where count and size limits allow; False reverts to one per # message (old behaviour) self.batch = ( self.template_args["batch"]["default"] if batch is None else parse_bool( batch, default=self.template_args["batch"]["default"], ) ) self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1.0 self.ratelimit_remaining = 1.0 # Our template object is just an AppriseAttachment object self.template = AppriseAttachment(asset=self.asset) if template: # Add our definition to our template self.template.add(template) if not len(self.template): # add() failed (unsupported schema, unparseable URL, etc.) msg = "The Discord template specified could not be loaded." self.logger.warning(msg) raise TypeError(msg) # Enforce maximum file size self.template[0].max_file_size = self.max_discord_template_size # Template functionality self.tokens: dict = {} if isinstance(tokens, dict): self.tokens.update(tokens) elif tokens: msg = ( "The specified Discord Template Tokens " f"({tokens}) are not identified as a dictionary." ) self.logger.warning(msg) raise TypeError(msg) # else: NoneType - this is okay return def gen_payload( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Return a validated Discord webhook payload from template, or False on failure.""" # Acquire the first template attachment template = self.template[0] if not template: # We could not access the attachment self.logger.error( "Could not access Discord template" f" {template.url(privacy=True)}." ) return False # Acquire our image URL for potential token substitution image_url = self.image_url(notify_type) # Take a copy of our token dictionary tokens = self.tokens.copy() # Apply our standard template variable defaults tokens["app_body"] = body tokens["app_title"] = title tokens["app_type"] = notify_type.value tokens["app_id"] = self.app_id tokens["app_desc"] = self.app_desc tokens["app_color"] = self.color(notify_type) # app_color_hex is an explicit alias for app_color, provided so # templates can name the hex variant unambiguously alongside # app_color_int tokens["app_color_hex"] = self.color(notify_type) # Discord embed payloads require color as a decimal integer; # app_color_int provides that while app_color stays hex for # consistency with all other template-capable plugins tokens["app_color_int"] = self.color(notify_type, int) tokens["app_image_url"] = image_url tokens["app_url"] = self.app_url # Templates are always JSON; enforce JSON escaping tokens["app_mode"] = TemplateType.JSON # Stringify substitutions before JSON escaping; preserve app_mode safe_tokens = { k: ( v if k == "app_mode" or isinstance(v, str) else ("" if v is None else str(v)) ) for k, v in tokens.items() } try: with open(template.path) as fp: content = apply_template(fp.read(), **safe_tokens) except OSError: self.logger.error( "Discord template" f" {template.url(privacy=True)} could not be read." ) return False # Parse and validate as JSON try: content = loads(content) except (JSONDecodeError, ValueError) as e: self.logger.error( "Discord template" f" {template.url(privacy=True)} contains invalid JSON." ) self.logger.debug(f"JSONDecodeError: {e}") return False # Template must parse to a JSON object, not an array or scalar if not isinstance(content, dict): self.logger.error( "Discord template" f" {template.url(privacy=True)} must be a JSON object" " (got {}).".format(type(content).__name__) ) return False # A Discord webhook payload must have a non-empty 'content' string # or a non-empty 'embeds' list containing embed dicts has_content = ( isinstance(content.get("content"), str) and content["content"] ) has_embeds = ( isinstance(content.get("embeds"), list) and content["embeds"] ) if not (has_content or has_embeds): self.logger.error( "Discord template" f" {template.url(privacy=True)} must contain" " a non-empty 'content' string or a non-empty 'embeds'" " list." ) return False # If embeds is present, each entry must be a JSON object (dict) if has_embeds and not all( isinstance(e, dict) for e in content["embeds"] ): self.logger.error( "Discord template" f" {template.url(privacy=True)} contains" " an embed entry that is not a JSON object." ) return False # Return the validated payload content dict return content def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, attach: list[AttachBase] | None = None, **kwargs: Any, ) -> bool: """Perform Discord Notification.""" payload: dict[str, Any] = { "tts": self.tts, # If Text-To-Speech is set to True, then we do not want to wait # for the whole message before continuing. Otherwise, we wait "wait": self.tts is False, } if self.flags: # Set our flag if defined: payload["flags"] = self.flags # Acquire image_url image_url = self.image_url(notify_type) if self.avatar and (image_url or self.avatar_url): payload["avatar_url"] = ( self.avatar_url if self.avatar_url else image_url ) if self.user: # Optionally override the default username of the webhook payload["username"] = self.user # Associate our thread_id with our message params = {"thread_id": self.thread_id} if self.thread_id else None # Template mode bypasses ping detection and embed construction; # the template defines the complete payload content. if not self.template: # Ping handling rules: # - If ping= is set, it is an additive if in MARKDOWN mode # otherwise it is explicit for TEXT/HTML formats. # - Otherwise, ping detection only happens in MARKDOWN mode if self.notify_format == NotifyFormat.MARKDOWN: if self.ping: payload.update( self.ping_payload(body, " ".join(self.ping)) ) else: payload.update(self.ping_payload(body)) # TEXT/HTML: no body parsing, ping= is exclusive elif self.ping: payload.update(self.ping_payload(" ".join(self.ping))) if self.template: # Generate our payload from the user-supplied template file template_payload = self.gen_payload( body=body, title=title, notify_type=notify_type, **kwargs, ) if template_payload is False: # gen_payload() already logged the error; bail early return False # Merge template content over our base payload payload.update(template_payload) if not self._send(payload, params=params): # We failed to post our message return False elif body: # Track extra embed fields (if used) fields: list[dict[str, str]] = [] if self.notify_format == NotifyFormat.MARKDOWN: # Use embeds for payload payload["embeds"] = [ { "author": { "name": self.app_id, "url": self.app_url, }, "title": title, "description": body, # Our color associated with our notification "color": self.color(notify_type, int), } ] if self.href: payload["embeds"][0]["url"] = self.href if self.footer: # Acquire logo URL logo_url = self.image_url(notify_type, logo=True) # Set Footer text to our app description payload["embeds"][0]["footer"] = { "text": self.app_desc, } if self.footer_logo and logo_url: payload["embeds"][0]["footer"]["icon_url"] = logo_url if self.include_image and image_url: payload["embeds"][0]["thumbnail"] = { "url": image_url, "height": 256, "width": 256, } if self.fields: # Break titles out so that we can sort them in embeds description, fields = self.extract_markdown_sections(body) # Swap first entry for description payload["embeds"][0]["description"] = description if fields: # Apply our additional parsing for a better # presentation payload["embeds"][0]["fields"] = fields[ : self.discord_max_fields ] fields = fields[self.discord_max_fields :] else: # TEXT or HTML: # - No ping detection unless ping= was provided. # - If ping= was provided, ping_payload() already generated # payload["content"] starting with "πŸ‘‰ ...", and we # append it. payload["content"] = ( body if not title else f"{title}\r\n{body}" ) + payload.get("content", "") if not self._send(payload, params=params): # We failed to post our message return False # Send remaining fields (if any) if fields: payload["embeds"][0]["description"] = "" for i in range(0, len(fields), self.discord_max_fields): payload["embeds"][0]["fields"] = fields[ i : i + self.discord_max_fields ] if not self._send(payload): # We failed to post our message return False if attach and self.attachment_support: # Update our payload; the idea is to preserve it's other detected # and assigned values for re-use here too payload.update( { # Text-To-Speech "tts": False, # Wait until the upload has posted itself before continuing "wait": True, } ) # # Remove our text/title based content for attachment use # payload.pop("embeds", None) payload.pop("content", None) payload.pop("allow_mentions", None) # # Build attachment batches in strict serial order. # Each batch respects the per-message attachment count and # total byte limits. When batch=False every attachment is # its own message (legacy one-per-message behaviour). # max_per = self.discord_max_attachments if self.batch else 1 batches: list[list[AttachBase]] = [] current: list[AttachBase] = [] current_size = 0 for attachment in attach: # Size is only needed for byte-limit batching; skip the # stat()/download() it triggers when batching is off size = ( (len(attachment) if attachment else 0) if self.batch else 0 ) if current and ( len(current) >= max_per or ( self.batch and current_size + size > self.discord_max_attach_bytes ) ): # Current batch is full; flush and start a new one batches.append(current) current = [] current_size = 0 current.append(attachment) current_size += size # current always holds at least the last attachment here batches.append(current) # # Send each attachment batch # for batch in batches: if not self._send(payload, params=params, attach=batch): # We failed to post our attachment batch return False # Otherwise return return True def _send( self, payload: dict[str, Any], attach: list[AttachBase] | None = None, params: dict[str, str] | None = None, rate_limit: int = 1, **kwargs: Any, ) -> bool: """Wrapper to the requests (post) object.""" # Our headers headers = { "User-Agent": self.app_id, } # Construct Notify URL notify_url = ( f"{self.notify_url}/{self.webhook_id}/{self.webhook_token}" ) self.logger.debug( "Discord POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Discord Payload: {payload!s}") wait: float | None = None if self.ratelimit_remaining <= 0.0: # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the # Discord server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds wait = abs( ( self.ratelimit_reset - now + self.clock_skew ).total_seconds() ) # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) # File handles opened for this call; all closed in `finally` handles: list[Any] = [] # Multipart file list built from the attach batch files: list[Any] | None = None attach_ok = True try: # Open our attachment path(s) if required: if attach: files = [] for idx, attachment in enumerate(attach): # Verify accessibility before opening if not attachment: self.logger.warning( "Could not access Discord attachment %s.", attachment.url(privacy=True), ) attach_ok = False break self.logger.debug( "Posting Discord attachment %s", attachment.url(privacy=True), ) # Catch OSError per attachment open try: handle = attachment.open() except OSError as e: self.logger.warning( "An I/O error occurred while reading %s.", attachment.name or "attachment", ) self.logger.debug("I/O Exception: %s", str(e)) attach_ok = False break # Register handle before appending to files handles.append(handle) files.append( ( f"files[{idx}]", ( attachment.name, handle, attachment.mimetype, ), ) ) if not attach_ok: return False else: headers["Content-Type"] = "application/json; charset=utf-8" r = requests.post( notify_url, params=params, data=( {"payload_json": dumps(payload)} if files else dumps(payload) ), headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) # Handle rate limiting (if specified) try: # Store our rate limiting (if provided) self.ratelimit_remaining = float( r.headers.get("X-RateLimit-Remaining") ) self.ratelimit_reset = datetime.fromtimestamp( int(r.headers.get("X-RateLimit-Reset")), timezone.utc ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this # information gracefully accept this state and move on pass if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) if ( r.status_code == requests.codes.too_many_requests and rate_limit > 0 ): # handle rate limiting self.logger.warning( "Discord rate limiting in effect; " "blocking for %.2f second(s)", self.ratelimit_remaining, ) # Try one more time before failing return self._send( payload=payload, attach=attach, params=params, rate_limit=rate_limit - 1, **kwargs, ) self.logger.warning( "Failed to send Discord %s: %s%serror=%s.", ( "{} attachment(s)".format(len(attach)) if attach else "notification" ), status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Return; we're done return False else: if attach: self.logger.info( "Sent Discord %d attachment(s).", len(attach) ) else: self.logger.info("Sent Discord notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred posting to Discord." ) self.logger.debug(f"Socket Exception: {e!s}") return False except OSError as e: # Catches I/O errors from requests.post() and any unexpected # file-system errors not caught by the per-attachment check above self.logger.warning("An I/O error occurred posting to Discord.") self.logger.debug(f"I/O Exception: {e!s}") return False finally: # Close all handles regardless of success or failure for handle in handles: handle.close() return True def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Returns the URL built dynamically based on specified arguments.""" params: dict[str, str] = { "tts": "yes" if self.tts else "no", "avatar": "yes" if self.avatar else "no", "footer": "yes" if self.footer else "no", "footer_logo": "yes" if self.footer_logo else "no", "image": "yes" if self.include_image else "no", "fields": "yes" if self.fields else "no", "batch": "yes" if self.batch else "no", } if self.avatar_url: params["avatar_url"] = self.avatar_url if self.flags: params["flags"] = str(self.flags) if self.href: params["href"] = self.href if self.thread_id: params["thread"] = self.thread_id if self.ping: # Let Apprise urlencode handle list formatting params["ping"] = ",".join(self.ping) if self.template: # Include our template reference params["template"] = NotifyDiscord.quote( self.template[0].url(), safe="" ) # Store any template token entries if specified params.update({f":{k}": v for k, v in self.tokens.items()}) # Ensure our botname is set botname = f"{self.user}@" if self.user else "" # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return ( "{schema}://{bname}{webhook_id}/{webhook_token}/?{params}".format( schema=self.secure_protocol, bname=botname, webhook_id=self.pprint(self.webhook_id, privacy, safe=""), webhook_token=self.pprint( self.webhook_token, privacy, safe="" ), params=NotifyDiscord.urlencode(params), ) ) @property def url_identifier(self) -> tuple[str, str, str]: """Returns all of the identifiers that make this URL unique.""" return (self.secure_protocol, self.webhook_id, self.webhook_token) @staticmethod def parse_url(url: str) -> dict[str, Any] | None: """Parses the URL and returns arguments for instantiating this object. Syntax: discord://webhook_id/webhook_token """ results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Store our webhook ID webhook_id = NotifyDiscord.unquote(results["host"]) # Now fetch our tokens try: webhook_token = NotifyDiscord.split_path(results["fullpath"])[0] except IndexError: # Force some bad values that will get caught # in parsing later webhook_token = None results["webhook_id"] = webhook_id results["webhook_token"] = webhook_token # Text To Speech results["tts"] = parse_bool(results["qsd"].get("tts", False)) # Use sections # effectively detect multiple fields and break them off # into sections results["fields"] = parse_bool(results["qsd"].get("fields", True)) # Use Footer results["footer"] = parse_bool(results["qsd"].get("footer", False)) # Use Footer Logo results["footer_logo"] = parse_bool( results["qsd"].get("footer_logo", True) ) # Update Avatar Icon results["avatar"] = parse_bool(results["qsd"].get("avatar", True)) # Boolean to include an image or not results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyDiscord.template_args["image"]["default"] ) ) if "botname" in results["qsd"]: # Alias to User results["user"] = NotifyDiscord.unquote(results["qsd"]["botname"]) if "flags" in results["qsd"]: # Alias to User results["flags"] = NotifyDiscord.unquote(results["qsd"]["flags"]) # Extract avatar url if it was specified if "avatar_url" in results["qsd"]: results["avatar_url"] = NotifyDiscord.unquote( results["qsd"]["avatar_url"] ) # Extract url if it was specified if "href" in results["qsd"]: results["href"] = NotifyDiscord.unquote(results["qsd"]["href"]) elif "url" in results["qsd"]: results["href"] = NotifyDiscord.unquote(results["qsd"]["url"]) # Markdown is implied results["format"] = NotifyFormat.MARKDOWN # Extract thread id if it was specified if "thread" in results["qsd"]: results["thread"] = NotifyDiscord.unquote(results["qsd"]["thread"]) # Markdown is implied results["format"] = NotifyFormat.MARKDOWN # Extract ping targets, comma/space separated if "ping" in results["qsd"]: results["ping"] = NotifyDiscord.unquote(results["qsd"]["ping"]) # Template Handling if "template" in results["qsd"] and results["qsd"]["template"]: results["template"] = NotifyDiscord.unquote( results["qsd"]["template"] ) # Store our template tokens results["tokens"] = results["qsd:"] # Batch attachments flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyDiscord.template_args["batch"]["default"], ) ) return results @staticmethod def parse_native_url(url: str) -> dict[str, Any] | None: """ Support https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN Support Legacy URL as well: https://discordapp.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN """ result = re.match( r"^https?://discord(app)?\.com/api/webhooks/" r"(?P[0-9]+)/" r"(?P[A-Z0-9_-]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: return NotifyDiscord.parse_url( "{schema}://{webhook_id}/{webhook_token}/{params}".format( schema=NotifyDiscord.secure_protocol, webhook_id=result.group("webhook_id"), webhook_token=result.group("webhook_token"), params=( "" if not result.group("params") else result.group("params") ), ) ) return None def ping_payload(self, *args: str) -> dict[str, Any]: """ Takes one or more strings and applies the payload associated with pinging the users detected within. This returns a dict that may contain: - allow_mentions - content (starting with "πŸ‘‰ " and containing mention tokens) """ payload: dict[str, Any] = {} roles: set[str] = set() users: set[str] = set() parse: set[str] = set() for arg in args: # parse for user id's <@123> and role IDs <@&456> results = USER_ROLE_DETECTION_RE.findall(arg) if not results: continue for is_role, no, value in results: if value: parse.add(value) elif is_role: roles.add(no) else: # is_user users.add(no) if not (roles or users or parse): # Nothing to add return payload payload["allow_mentions"] = { "parse": list(parse), "users": list(users), "roles": list(roles), } payload["content"] = "πŸ‘‰ " + " ".join( chain( [f"@{value}" for value in parse], [f"<@&{value}>" for value in roles], [f"<@{value}>" for value in users], ) ) return payload @staticmethod def extract_markdown_sections( markdown: str, ) -> tuple[str, list[dict[str, str]]]: """Extract headers and their corresponding sections into embed fields.""" # Search for any header information found without it's own section # identifier match = re.match( r"^\s*(?P[^\s#]+.*?)(?=\s*$|[\r\n]+\s*#)", markdown, flags=re.S, ) description = match.group("desc").strip() if match else "" if description: # Strip description from our string since it has been handled # now. markdown = re.sub(re.escape(description), "", markdown, count=1) regex = re.compile( r"\s*#[# \t\v]*(?P[^\n]+)(\n|\s*$)" r"\s*((?P[^#].+?)(?=\s*$|[\r\n]+\s*#))?", flags=re.S, ) common = regex.finditer(markdown) fields: list[dict[str, str]] = [] for el in common: d = el.groupdict() fields.append( { "name": d.get("name", "").strip("#`* \r\n\t\v"), "value": "```{}\n{}```".format( "md" if d.get("value") else "", ( d.get("value").strip() + "\n" if d.get("value") else "" ), ), } ) return description, fields caronc-apprise-182f859/apprise/plugins/dot.py000066400000000000000000000503061524161175200212040ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # API: https://dot.mindreset.tech/docs/service/open/text_api # https://dot.mindreset.tech/docs/service/open/image_api # # New API endpoints (v2): # - Text API: /api/authV2/open/device/:deviceId/text # - Image API: /api/authV2/open/device/:deviceId/image # # Note: Old endpoints (/api/open/text and /api/open/image) are deprecated # and will be removed in the future. Requests are automatically forwarded # to the new endpoints. # # Text API Fields: # - refreshNow (bool, optional, default true): controls display timing. # - title (string, optional): title text shown on screen. # - message (string, optional): body text shown on screen. # - signature (string, optional): footer/signature text. # - icon (string, optional): base64 PNG icon (40px x 40px). # - link (string, optional): tap-to-interact target URL. # - taskKey (string, optional): specify which text API content to update. # # Image API Fields: # - refreshNow (bool, optional, default true): controls display timing. # - image (string, required): base64 PNG image (296px x 152px). # - link (string, optional): tap-to-interact target URL. # - border (number, optional, default 0): 0=white, 1=black frame. # - ditherType (string, optional, default DIFFUSION): dithering mode. # - ditherKernel (string, optional, default FLOYD_STEINBERG): # dithering kernel. # - taskKey (string, optional): specify which image API content to update. # # Mode selection: # - text (default): smart dual-send mode. Body/title go to the Text API; # image= param or an attachment goes to the Image API. When both are # present, text is dispatched first, then image. If only one is # available, only that API is called. # - image: only the Image API is called; body and title are ignored. # # Providing image= in the URL without an explicit mode= leaves the mode # as the default (text), which will send the image alongside any text. from contextlib import suppress import json import logging import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool from ..utils.sanitize import sanitize_payload from .base import NotifyBase # Supported Dither Types DOT_DITHER_TYPES = ( "DIFFUSION", "ORDERED", "NONE", ) # Supported Dither Kernels DOT_DITHER_KERNELS = ( "THRESHOLD", "ATKINSON", "BURKES", "FLOYD_STEINBERG", "SIERRA2", "STUCKI", "JARVIS_JUDICE_NINKE", "DIFFUSION_ROW", "DIFFUSION_COLUMN", "DIFFUSION_2D", ) # Supported API modes; first entry is the default DOT_MODES = ("text", "image") class NotifyDot(NotifyBase): """A wrapper for Dot. Notifications.""" # The default descriptive name associated with the Notification service_name = "Dot." # The services URL service_url = "https://dot.mindreset.tech" # All notification requests are secure secure_protocol = "dot" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dot/" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Support Attachments attachment_support = True # Define object templates templates = ("{schema}://{apikey}@{device_id}/",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "required": True, "private": True, }, "device_id": { "name": _("Device Serial Number"), "type": "string", "required": True, "map_to": "device_id", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "mode": { "name": _("API Mode"), "type": "choice:string", "values": DOT_MODES, "default": DOT_MODES[0], }, "refresh": { "name": _("Refresh Now"), "type": "bool", "default": True, "map_to": "refresh_now", }, "signature": { "name": _("Text Signature"), "type": "string", }, "icon": { "name": _("Icon Base64 (Text API)"), "type": "string", }, "image": { "name": _("Image Base64 (Image API)"), "type": "string", "map_to": "image_data", }, "link": { "name": _("Link"), "type": "string", }, "border": { "name": _("Border"), "type": "int", "min": 0, "max": 1, "default": 0, }, "dither_type": { "name": _("Dither Type"), "type": "choice:string", "values": DOT_DITHER_TYPES, "default": "DIFFUSION", }, "dither_kernel": { "name": _("Dither Kernel"), "type": "choice:string", "values": DOT_DITHER_KERNELS, "default": "FLOYD_STEINBERG", }, "task_key": { "name": _("Task Key"), "type": "string", }, }, ) def __init__( self, apikey=None, device_id=None, mode=DOT_MODES[0], refresh_now=None, signature=None, icon=None, link=None, border=None, dither_type=None, dither_kernel=None, image_data=None, task_key=None, **kwargs, ): """Initialize Notify Dot Object.""" super().__init__(**kwargs) # API Key (from user) self.apikey = apikey # Device ID tracks the Dot hardware serial. self.device_id = device_id # Refresh Now flag: True shows content immediately (default). self.refresh_now = ( parse_bool( refresh_now, self.template_args["refresh"]["default"], ) if refresh_now is not None else self.template_args["refresh"]["default"] ) # API mode self.mode = ( mode.lower() if isinstance(mode, str) and mode.lower() in DOT_MODES else DOT_MODES[0] ) if not isinstance(mode, str) or mode.lower() not in DOT_MODES: self.logger.warning( "Unsupported Dot mode (%s) specified; defaulting to '%s'.", mode, self.mode, ) # Signature text used by the Text API footer. self.signature = signature if isinstance(signature, str) else None # Icon for the Text API (base64 PNG 40x40, lower-left corner). self.icon = icon if isinstance(icon, str) else None # Image payload for the Image API (base64 PNG 296x152). self.image_data = ( image_data if image_data and isinstance(image_data, str) else None ) # Link for tap-to-interact navigation. self.link = link if isinstance(link, str) else None # Border for the Image API self.border = border # Dither type for Image API self.dither_type = dither_type # Dither kernel for the Image API self.dither_kernel = dither_kernel # Task Key for specifying which content to update self.task_key = ( task_key if task_key and isinstance(task_key, str) else None ) # Text API endpoint (v2) self.text_api_url = ( "https://dot.mindreset.tech/api/authV2/open/device/" f"{self.device_id}/text" ) # Image API endpoint (v2) self.image_api_url = ( "https://dot.mindreset.tech/api/authV2/open/device/" f"{self.device_id}/image" ) return def _post(self, api_url, payload, headers): """POST payload to api_url; return True on success, False otherwise.""" if self.logger.isEnabledFor(logging.DEBUG): self.logger.debug( "Dot POST URL: %s (cert_verify=%r)", api_url, self.verify_certificate, ) self.logger.debug("Dot Payload %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( api_url, data=json.dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code == requests.codes.ok: self.logger.info( "Sent Dot notification to %s.", self.device_id ) return True status_str = NotifyDot.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send Dot notification to %s: %s%serror=%d.", self.device_id, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return False except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Dot notification to %s.", self.device_id, ) self.logger.debug("Socket Exception: %s", str(e)) return False def _send_text(self, body, title, icon_data, headers): """Send body/title via the Text API.""" payload = {"refreshNow": self.refresh_now} if title: payload["title"] = title if body: payload["message"] = body if self.signature: payload["signature"] = self.signature if icon_data: payload["icon"] = icon_data if self.link: payload["link"] = self.link if self.task_key is not None: payload["taskKey"] = self.task_key return self._post(self.text_api_url, payload, headers) def _send_image(self, image_data, headers): """Send image_data via the Image API.""" payload = { "image": image_data, "refreshNow": self.refresh_now, } if self.link: payload["link"] = self.link if self.border is not None: payload["border"] = self.border if self.dither_type is not None: payload["ditherType"] = self.dither_type if self.dither_kernel is not None: payload["ditherKernel"] = self.dither_kernel if self.task_key is not None: payload["taskKey"] = self.task_key return self._post(self.image_api_url, payload, headers) def _resolve_attachment(self, attach, warn_label): """Return base64 string from the first usable attachment, or None.""" if not (attach and self.attachment_support): return None if len(attach) > 1: self.logger.warning( "Multiple attachments provided; only the first" " one will be used as %s.", warn_label, ) try: attachment = attach[0] if attachment: return attachment.base64() except Exception as e: self.logger.warning("Failed to process attachment: %s", str(e)) return None def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Dot Notification.""" if not self.apikey: self.logger.warning("No API key was specified") return False if not self.device_id: self.logger.warning("No device ID was specified") return False headers = { "Authorization": f"Bearer {self.apikey}", "Content-Type": "application/json", "User-Agent": self.app_id, } if self.mode == "image": # Image-only mode: body and title are ignored. if title or body: self.logger.warning( "Title and body are not supported in image mode" " and will be ignored." ) image_data = ( self.image_data if isinstance(self.image_data, str) else self._resolve_attachment(attach, "image") ) if not image_data: self.logger.warning( "Image mode selected but no image data was provided." ) return False return self._send_image(image_data, headers) # Text mode (default): smart dual-send. # Body/title go to the Text API; image data or attachment goes # to the Image API. Text is always dispatched before image. image_data = ( self.image_data if isinstance(self.image_data, str) else self._resolve_attachment(attach, "image") ) has_text = bool(body or title) has_image = bool(image_data) if not has_text and not has_image: self.logger.warning( "Nothing to send to Dot. device %s.", self.device_id ) return False has_error = False if has_text and not self._send_text(body, title, self.icon, headers): has_error = True if has_image and not self._send_image(image_data, headers): has_error = True return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another similar one. """ return ( self.secure_protocol, self.apikey, self.device_id, self.mode, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = {} params["refresh"] = "yes" if self.refresh_now else "no" # Include mode only when non-default if self.mode != DOT_MODES[0]: params["mode"] = self.mode if self.signature: params["signature"] = self.signature if self.icon: params["icon"] = self.icon if self.image_data: params["image"] = self.image_data if self.border is not None: params["border"] = str(self.border) if self.dither_type and self.dither_type != "DIFFUSION": params["dither_type"] = self.dither_type if self.dither_kernel and self.dither_kernel != "FLOYD_STEINBERG": params["dither_kernel"] = self.dither_kernel if self.link: params["link"] = self.link if self.task_key: params["task_key"] = self.task_key params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{apikey}@{device_id}/?{params}".format( schema=self.secure_protocol, apikey=self.pprint( self.apikey, privacy, mode=PrivacyMode.Secret, safe="" ), device_id=NotifyDot.quote(self.device_id, safe=""), params=NotifyDot.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return 1 if self.device_id else 0 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re-instantiate this object.""" results = NotifyBase.parse_url(url) if not results: return results # Determine mode: explicit ?mode= wins; path provides backward compat. mode = DOT_MODES[0] if results["qsd"].get("mode"): candidate = results["qsd"]["mode"].lower().strip() if candidate in DOT_MODES: mode = candidate else: NotifyDot.logger.warning( "Unsupported Dot mode (%s) specified; defaulting to '%s'.", candidate, DOT_MODES[0], ) else: path_tokens = NotifyDot.split_path(results.get("fullpath")) if path_tokens: candidate = path_tokens[0].lower() if candidate in DOT_MODES: mode = candidate else: NotifyDot.logger.warning( "Unsupported Dot mode (%s) specified;" " defaulting to '%s'.", candidate, DOT_MODES[0], ) results["mode"] = mode # Extract API key from user if results.get("user"): results["apikey"] = NotifyDot.unquote(results["user"]) # Extract device ID from hostname if results.get("host"): results["device_id"] = NotifyDot.unquote(results["host"]) # Refresh Now refresh_value = results["qsd"].get("refresh") if refresh_value: results["refresh_now"] = parse_bool(refresh_value.strip()) # Signature if results["qsd"].get("signature"): results["signature"] = NotifyDot.unquote( results["qsd"]["signature"].strip() ) # Icon if results["qsd"].get("icon"): results["icon"] = NotifyDot.unquote(results["qsd"]["icon"].strip()) # Link if results["qsd"].get("link"): results["link"] = NotifyDot.unquote(results["qsd"]["link"].strip()) # Border if results["qsd"].get("border"): with suppress(TypeError, ValueError): results["border"] = int(results["qsd"]["border"].strip()) # Dither Type if results["qsd"].get("dither_type"): results["dither_type"] = NotifyDot.unquote( results["qsd"]["dither_type"].strip() ) # Dither Kernel if results["qsd"].get("dither_kernel"): results["dither_kernel"] = NotifyDot.unquote( results["qsd"]["dither_kernel"].strip() ) # Image (Image API) if results["qsd"].get("image"): results["image_data"] = NotifyDot.unquote( results["qsd"]["image"].strip() ) # Task Key if results["qsd"].get("task_key"): results["task_key"] = NotifyDot.unquote( results["qsd"]["task_key"].strip() ) return results caronc-apprise-182f859/apprise/plugins/eight00com.py000066400000000000000000000412351524161175200223560ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need an 800.com account # # 1. Visit https://www.800.com and sign in or create an account. # 2. Go to User Settings (top-right avatar -> Settings). # 3. In the API section, click "Generate Token". # 4. Copy the token immediately -- it is only displayed once. # # Your Apprise URL should be assembled as: # eight00com://TOKEN@FromPhoneNo # eight00com://TOKEN@FromPhoneNo/ToPhoneNo # eight00com://TOKEN@FromPhoneNo/ToPhoneNo1/ToPhoneNo2 # # Where: # TOKEN : Your 800.com Personal Access Token # FromPhoneNo : Your text-enabled 800.com number (e.g. 8005551234) # ToPhoneNo : Recipient phone number(s) # # The API also supports MMS (picture messaging). Apprise will # automatically send any provided attachments as MMS when possible. # # API Reference: # https://api.800.com/docs from json import dumps import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_phone_no, parse_phone_no, validate_regex from .base import NotifyBase # Extend HTTP Error Messages EIGHT00COM_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid or missing Bearer Token.", 422: "Validation Error - Check sender/recipient number format.", 429: "Too many requests - Rate limit exceeded.", } class NotifyEight00com(NotifyBase): """A wrapper for 800.com SMS/MMS Notifications.""" # The default descriptive name associated with the Notification service_name = "800.com" # The services URL service_url = "https://www.800.com" # All notification requests are sent over HTTPS secure_protocol = "eight00com" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/eight00com/" # 800.com REST API endpoint for outbound messages notify_url = "https://api.800.com/message" # The maximum SMS body length (800.com supports long-form texts # up to 600 characters) body_maxlen = 600 # A title cannot be used for SMS messages; setting this to zero # causes any title to be prepended to the body automatically. title_maxlen = 0 # 800.com supports MMS (picture messaging / attachments) attachment_support = True # Define object URL templates templates = ( "{schema}://{token}@{from_phone}", "{schema}://{token}@{from_phone}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("API Token"), "type": "string", "private": True, "required": True, }, "from_phone": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", "required": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, "from": { "name": _("From Phone No"), "type": "string", "map_to": "source", }, "to": { "alias_of": "targets", }, }, ) def __init__(self, token=None, source=None, targets=None, **kwargs): """Initialize 800.com Object.""" super().__init__(**kwargs) # Validate our Personal Access Token self.token = validate_regex(token) if not self.token: msg = "An 800.com Personal Access Token must be specified." self.logger.warning(msg) raise TypeError(msg) # Validate the from (source) phone number result = is_phone_no(source) if not result: msg = "The 800.com from phone # ({}) is invalid.".format(source) self.logger.warning(msg) raise TypeError(msg) # Store our source number as digits only self.source = result["full"] # Parse our targets list self.targets = [] # Track if we encountered any error parsing targets has_error = False for target in parse_phone_no(targets): # Validate each target phone number result = is_phone_no(target) if result: self.targets.append(result["full"]) continue has_error = True self.logger.warning( "Dropped invalid 800.com phone # (%s).", target, ) if not targets and not has_error: # Default: send to ourselves when no target is provided self.targets.append(self.source) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform 800.com SMS/MMS Notification.""" # Ensure we have at least one target if not self.targets: self.logger.warning("No 800.com targets to notify.") return False # Prepare our authorization header (shared by SMS and MMS) headers = { "User-Agent": self.app_id, "Authorization": "Bearer {}".format(self.token), } # Error tracking variable has_error = False # Work through a copy so the original list is preserved targets = list(self.targets) while targets: # Pop the next recipient target = targets.pop(0) # Build the E.164-style sender and recipient strings sender = "+{}".format(self.source) recipient = "+{}".format(target) self.logger.debug( "800.com POST URL: %s (cert_verify=%s)", self.notify_url, self.verify_certificate, ) if attach and self.attachment_support: # Send as MMS with attachment(s) if not self._send_mms( sender, recipient, body, attach, headers ): has_error = True else: # Send as a plain SMS if not self._send_sms(sender, recipient, body, headers): has_error = True return not has_error def _send_sms(self, sender, recipient, body, headers): """Send a plain text SMS via the 800.com REST API.""" # Prepare our JSON payload payload = { "sender": sender, "recipient": recipient, "message": body, } # Copy headers and add JSON content type sms_headers = dict(headers) sms_headers["Content-Type"] = "application/json" self.logger.debug("800.com SMS Payload: %s", payload) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=sms_headers, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyEight00com.http_response_code_lookup( r.status_code, EIGHT00COM_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send 800.com SMS to %s: %s%serror=%s.", recipient, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) return False else: self.logger.info("Sent 800.com SMS to %s.", recipient) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending 800.com SMS to %s.", recipient, ) self.logger.debug("Socket Exception: %s", str(e)) return False return True def _send_mms(self, sender, recipient, body, attach, headers): """Send an MMS with one or more attachments via the 800.com API. Uses Pattern B (multi-file multipart/form-data). """ # Accumulate open file handles for cleanup in the finally block handles = [] files = [] attach_ok = True try: for attachment in attach: # Guard 1: verify the attachment is accessible if not attachment: self.logger.warning( "Could not access 800.com attachment %s.", attachment.url(privacy=True), ) attach_ok = False break # Guard 2: catch I/O errors when opening the file try: handle = attachment.open() except OSError as exc: self.logger.warning( "An I/O error occurred reading 800.com attachment %s.", attachment.name, ) self.logger.debug("I/O Exception: %s", str(exc)) attach_ok = False break # Register handle immediately so finally closes it handles.append(handle) files.append( ( "media[]", ( attachment.name, handle, attachment.mimetype, ), ) ) if not attach_ok: # Bail early; finally block will close any open handles return False # Prepare the multipart form fields. # Do NOT set Content-Type -- requests sets the multipart # boundary header automatically when files is not None. data = { "sender": sender, "recipient": recipient, "message": body, } self.logger.debug("800.com MMS data: %s", data) # Always call throttle before any remote server i/o is made self.throttle() r = requests.post( self.notify_url, data=data, headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, allow_redirects=self.redirects, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyEight00com.http_response_code_lookup( r.status_code, EIGHT00COM_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send 800.com MMS to %s: %s%serror=%s.", recipient, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) return False else: self.logger.info("Sent 800.com MMS to %s.", recipient) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending 800.com MMS to %s.", recipient, ) self.logger.debug("Socket Exception: %s", str(e)) return False finally: # Guard 3: close every file handle regardless of outcome for handle in handles: handle.close() return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.source, self.token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our standard parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) # Omit the target list when the only target is ourselves targets = ( [] if len(self.targets) == 1 and self.targets[0] == self.source else self.targets ) return "{schema}://{token}@{source}/{targets}?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), source=self.source, targets="/".join( NotifyEight00com.quote(x, safe="+") for x in targets ), params=NotifyEight00com.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.targets) if self.targets else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re-instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: return results # Support ?from= to specify the source number separately, # in which case the hostname becomes an additional target. if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyEight00com.unquote( results["qsd"]["from"] ) results["targets"] = [ *NotifyEight00com.parse_phone_no(results["host"]), *NotifyEight00com.split_path(results["fullpath"]), ] else: # Hostname is the source (from) phone number results["source"] = NotifyEight00com.unquote(results["host"]) # Path entries are target phone numbers results["targets"] = NotifyEight00com.split_path( results["fullpath"] ) # Support ?to= as an alias for targets if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyEight00com.parse_phone_no( results["qsd"]["to"] ) # Support ?token= to override the token in the URL user field if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyEight00com.unquote( results["qsd"]["token"] ) else: # Token lives in the user position of the URL results["token"] = NotifyEight00com.unquote(results["user"] or "") return results caronc-apprise-182f859/apprise/plugins/email/000077500000000000000000000000001524161175200211275ustar00rootroot00000000000000caronc-apprise-182f859/apprise/plugins/email/__init__.py000066400000000000000000000035751524161175200232520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from email import charset from .base import NotifyEmail from .common import ( SECURE_MODES, AppriseEmailException, EmailMessage, SecureMailMode, WebBaseLogin, ) from .templates import EMAIL_TEMPLATES # Globally Default encoding mode set to Quoted Printable. charset.add_charset("utf-8", charset.QP, charset.QP, "utf-8") __all__ = [ "EMAIL_TEMPLATES", "SECURE_MODES", "AppriseEmailException", "EmailMessage", "NotifyEmail", "SecureMailMode", "WebBaseLogin", ] caronc-apprise-182f859/apprise/plugins/email/base.py000066400000000000000000001661571524161175200224330ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib from datetime import datetime from email.header import Header from email.mime.application import MIMEApplication from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import format_datetime, formataddr, make_msgid import re import smtplib import ssl from typing import Optional from ...common import NotifyFormat, NotifyType, PersistentStoreMode from ...conversion import convert_between from ...locale import gettext_lazy as _ from ...logger import logger from ...url import PrivacyMode from ...utils import pgp as _pgp, wkd as _wkd from ...utils.parse import ( is_email, is_hostname, is_ipaddr, parse_bool, parse_emails, ) from ..base import NotifyBase from . import templates from .common import ( SECURE_MODES, AppriseEmailException, EmailMessage, SecureMailMode, WebBaseLogin, ) class PGPMode: """PGP security mode for outbound email.""" # No PGP applied (default) NONE = "no" # Sign using the sender's private key; encrypt opportunistically when # a recipient public key is available (via WKD or pgppub=) SIGN = "sign" # Encrypt using the recipient's public key (fails if no key found) ENCRYPT = "encrypt" # Ordered tuple of all valid PGP modes; prefix matching uses this order PGP_MODES = ( PGPMode.NONE, PGPMode.SIGN, PGPMode.ENCRYPT, ) # The mode used when ?pgp= is absent or empty PGP_MODE_DEFAULT = PGPMode.NONE class NotifyEmail(NotifyBase): """ A wrapper to Email Notifications """ # The default descriptive name associated with the Notification service_name = "E-Mail" # The default simple (insecure) protocol protocol = "mailto" # The default secure protocol secure_protocol = "mailtos" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/email/" # Support attachments attachment_support = True # Our default is to no not use persistent storage beyond in-memory # reference; this allows us to auto-generate our config if needed storage_mode = PersistentStoreMode.AUTO # Default Notify Format notify_format = NotifyFormat.HTML # Default SMTP Timeout (in seconds) socket_connect_timeout = 15 # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{host}:{port}/{targets}", "{schema}://{host}/{targets}", "{schema}://{user}@{host}", "{schema}://{user}@{host}/{targets}", "{schema}://{user}@{host}:{port}", "{schema}://{user}@{host}/{targets}", "{schema}://{user}@{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "host": { "name": _("Domain"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Email"), "type": "string", "map_to": "from_addr", }, "name": { "name": _("From Name"), "type": "string", "map_to": "from_addr", }, "smtp": { "name": _("SMTP Server"), "type": "string", "map_to": "smtp_host", }, "mode": { "name": _("Secure Mode"), "type": "choice:string", "values": SECURE_MODES, "default": SecureMailMode.STARTTLS, "map_to": "secure_mode", }, "reply": { "name": _("Reply To"), "type": "list:string", "map_to": "reply_to", }, "pgp": { "name": _("PGP Security Mode"), "type": "choice:string", "values": PGP_MODES, "default": PGP_MODE_DEFAULT, "map_to": "pgp_mode", }, "pgppub": { "name": _("PGP Public Key Path"), "type": "string", "private": True, # By default persistent storage is referenced "default": "", "map_to": "pgp_key", }, # Backward-compat alias; deprecated in favour of pgppub= # Handled in parse_url() with a deprecation warning. "pgpkey": { "alias_of": "pgppub", }, "pgpprv": { "name": _("PGP Private Key Path"), "type": "string", "private": True, # By default persistent storage is referenced "default": "", "map_to": "pgp_privkey", }, "wkd": { "name": _("Web Key Directory"), "type": "bool", "default": False, "map_to": "use_wkd", }, "inline": { "name": _("Inline Attachments"), "type": "bool", "default": False, }, "to": { "name": _("To Email"), "type": "string", "map_to": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("Email Header"), "prefix": "+", }, } def __init__( self, smtp_host=None, from_addr=None, secure_mode=None, targets=None, cc=None, bcc=None, reply_to=None, headers=None, pgp_mode=None, pgp_key=None, pgp_privkey=None, use_wkd=False, inline=None, **kwargs, ): """ Initialize Email Object The smtp_host and secure_mode can be automatically detected depending on how the URL was built """ super().__init__(**kwargs) # Acquire Email 'To' self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Acquire Reply To self.reply_to = set() # For tracking our email -> name lookups self.names = {} self.headers = {} if headers: # Store our extra headers self.headers.update(headers) # Now we want to construct the To and From email # addresses from the URL provided self.from_addr = [False, ""] # Now detect the SMTP Server self.smtp_host = smtp_host if isinstance(smtp_host, str) else "" # Now detect secure mode if secure_mode: self.secure_mode = ( None if not isinstance(secure_mode, str) else secure_mode.lower() ) else: self.secure_mode = ( SecureMailMode.INSECURE if not self.secure else self.template_args["mode"]["default"] ) if self.secure_mode not in SECURE_MODES: msg = "The secure mode specified ({}) is invalid.".format( secure_mode ) self.logger.warning(msg) raise TypeError(msg) # Validate recipients (cc:) and drop bad ones: for recipient in parse_emails(cc): email = is_email(recipient) if email: self.cc.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Carbon Copy email ({}) specified.".format( recipient ), ) # Validate recipients (bcc:) and drop bad ones: for recipient in parse_emails(bcc): email = is_email(recipient) if email: self.bcc.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Blind Carbon Copy email " "({}) specified.".format(recipient), ) # Validate recipients (reply-to:) and drop bad ones: for recipient in parse_emails(reply_to): email = is_email(recipient) if email: self.reply_to.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Reply To email ({}) specified.".format( recipient ), ) # Apply any defaults based on certain known configurations self.apply_email_defaults(secure_mode=secure_mode, **kwargs) if self.user: if self.host: # Prepare the bases of our email self.from_addr = [ self.app_id, "{}@{}".format( re.split(r"[\s@]+", self.user)[0], self.host, ), ] else: result = is_email(self.user) if result: # Prepare the bases of our email and include domain self.host = result["domain"] self.from_addr = [self.app_id, self.user] if from_addr: result = is_email(from_addr) if result: self.from_addr = ( result["name"] if result["name"] else False, result["full_email"], ) else: # Only update the string but use the already detected info self.from_addr[0] = from_addr result = is_email(self.from_addr[1]) if not result: # Parse Source domain based on from_addr msg = "Invalid ~From~ email specified: {}".format( "{} <{}>".format(self.from_addr[0], self.from_addr[1]) if self.from_addr[0] else "{}".format(self.from_addr[1]) ) self.logger.warning(msg) raise TypeError(msg) # Store our lookup self.names[self.from_addr[1]] = self.from_addr[0] if targets: # Validate recipients (to:) and drop bad ones: for recipient in parse_emails(targets): result = is_email(recipient) if result: self.targets.append( ( result["name"] if result["name"] else False, result["full_email"], ) ) continue self.logger.warning( "Dropped invalid To email ({}) specified.".format( recipient ), ) else: # If our target email list is empty we want to add ourselves to it self.targets.append((False, self.from_addr[1])) if not self.secure and self.secure_mode != SecureMailMode.INSECURE: # Enable Secure mode if not otherwise set self.secure = True if not self.port: # Assign our port based on our secure_mode if not otherwise # detected self.port = SECURE_MODES[self.secure_mode]["default_port"] # if there is still no smtp_host then we fall back to the hostname if not self.smtp_host: self.smtp_host = self.host # Track whether pgp_mode was explicitly provided so wkd= implication # does not override a deliberate pgp=none choice pgp_mode_explicit = pgp_mode is not None # Handle deprecated use_pgp boolean for backward compatibility; # kept in **kwargs rather than as a named parameter so the template # verification test does not flag it as an unmapped __init__ argument use_pgp = kwargs.pop("use_pgp", None) if use_pgp is not None: self.logger.warning( "Email use_pgp= is deprecated; use pgp_mode='encrypt' instead" ) # Only override pgp_mode when the caller did not also pass it if pgp_mode is None: pgp_mode = PGPMode.ENCRYPT if use_pgp else PGPMode.NONE # use_pgp counts as an explicit setting pgp_mode_explicit = True # Resolve PGP mode via prefix match (allows 'e', 'en', 'encrypt') if not pgp_mode: self.pgp_mode = PGP_MODE_DEFAULT else: self.pgp_mode = next( (m for m in PGP_MODES if m.startswith(str(pgp_mode).lower())), PGP_MODE_DEFAULT, ) # WKD flag -- use parse_bool() so string values like 'no'/'false' # are treated as disabled (bool('no') would incorrectly return True) self.use_wkd = parse_bool(use_wkd) # wkd=yes implies pgp=encrypt when pgp= was not explicitly set if self.use_wkd and not pgp_mode_explicit: self.pgp_mode = PGPMode.ENCRYPT # Build a WKD controller when WKD key discovery is requested wkd_ctrl = ( _wkd.AppriseWKDController( asset=self.asset, verify_certificate=self.verify_certificate, request_timeout=self.request_timeout, allow_redirects=self.redirects, ) if self.use_wkd else None ) # Prepare our Pretty Good Privacy Object self.pgp = _pgp.ApprisePGPController( path=self.store.path, pub_keyfile=pgp_key, prv_keyfile=pgp_privkey, email=self.from_addr[1], asset=self.asset, wkd=wkd_ctrl, ) # We store so we can generate a URL later on self.pgp_key = pgp_key # Store the private key path so it can be round-tripped via url() self.pgp_privkey = pgp_privkey if self.pgp_mode != PGP_MODE_DEFAULT and not _pgp.PGP_SUPPORT: self.logger.warning( "PGP Support is not available on this installation; " "ask admin to install PGPy" ) # Store inline attachment mode (RFC 2387); fall back to the # template default when the value is None or unrecognisable self.inline = parse_bool( inline, self.template_args["inline"]["default"] ) return def apply_email_defaults(self, secure_mode=None, port=None, **kwargs): """ A function that prefills defaults based on the email it was provided. """ if self.smtp_host: # SMTP Server was explicitly specified, therefore it is assumed # the caller knows what he's doing and is intentionally # over-riding any smarts to be applied. We also can not apply # any default if there was no user specified. return # detect our email address using our user/host combo from_addr = ( "{}@{}".format( re.split(r"[\s@]+", self.user)[0], self.host, ) if self.user else self.host ) for i in range(len(templates.EMAIL_TEMPLATES)): # pragma: no branch self.logger.trace( "Scanning %s against %s", from_addr, templates.EMAIL_TEMPLATES[i][0], ) match = templates.EMAIL_TEMPLATES[i][1].match(from_addr) if match: self.logger.info( f"Applying {templates.EMAIL_TEMPLATES[i][0]} Defaults" ) # the secure flag can not be altered if defined in the template self.secure = templates.EMAIL_TEMPLATES[i][2].get( "secure", self.secure ) # The SMTP Host check is already done above; if it was # specified we wouldn't even reach this part of the code. self.smtp_host = templates.EMAIL_TEMPLATES[i][2].get( "smtp_host", self.smtp_host ) # The following can be over-ridden if defined manually in the # Apprise URL. Otherwise they take on the template value if not port: self.port = templates.EMAIL_TEMPLATES[i][2].get( "port", self.port ) if not secure_mode: self.secure_mode = templates.EMAIL_TEMPLATES[i][2].get( "secure_mode", self.secure_mode ) # Adjust email login based on the defined usertype. If no entry # was specified, then we default to having them all set (which # basically implies that there are no restrictions and use use # whatever was specified) login_type = templates.EMAIL_TEMPLATES[i][2].get( "login_type", [] ) if login_type: # only apply additional logic to our user if a login_type # was specified. if is_email(self.user): if WebBaseLogin.EMAIL not in login_type: # Email specified but login type # not supported; switch it to user id self.user = match.group("id") else: # Enforce our host information self.host = self.user.split("@")[1] elif WebBaseLogin.USERID not in login_type: # user specified but login type # not supported; switch it to email self.user = "{}@{}".format(self.user, self.host) if ( "from_user" in templates.EMAIL_TEMPLATES[i][2] and not self.from_addr[1] ): # Update our from address if defined self.from_addr[1] = "{}@{}".format( templates.EMAIL_TEMPLATES[i][2]["from_user"], self.host ) break def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): if not self.targets: # There is no one to email; we're done logger.warning("There are no Email recipients to notify") return False # error tracking (used for function return) has_error = False # bind the socket variable to the current namespace socket = None # Always call throttle before any remote server i/o is made self.throttle() # Build an SSL context that honours our verify_certificate setting so # that SMTPS/STARTTLS connections actually validate the remote server's # certificate (and hostname) instead of silently accepting any # certificate presented to us. context = ssl.create_default_context() if not self.verify_certificate: context.check_hostname = False context.verify_mode = ssl.CERT_NONE try: self.logger.debug("Connecting to remote SMTP server...") socket_func = smtplib.SMTP socket_args = {} if self.secure_mode == SecureMailMode.SSL: self.logger.debug("Securing connection with SSL...") socket_func = smtplib.SMTP_SSL socket_args["context"] = context socket = socket_func( self.smtp_host, self.port, None, timeout=self.socket_connect_timeout, **socket_args, ) if self.secure_mode == SecureMailMode.STARTTLS: # Handle Secure Connections self.logger.debug("Securing connection with STARTTLS...") socket.starttls(context=context) self.logger.trace("Login ID: {}".format(self.user)) if self.user and self.password: # Apply Login credentials self.logger.debug("Applying user credentials...") socket.login(self.user, self.password) # Prepare our headers headers = { "X-Application": self.app_id, } headers.update(self.headers) # Iterate over our email messages we can generate and then # send them off. for message in NotifyEmail.prepare_emails( subject=title, body=body, notify_format=self.notify_format, from_addr=self.from_addr, to=self.targets, cc=self.cc, bcc=self.bcc, reply_to=self.reply_to, smtp_host=self.smtp_host, attach=attach, headers=headers, names=self.names, pgp=self.pgp, pgp_mode=self.pgp_mode, inline=self.inline, tzinfo=self.tzinfo, ): try: socket.sendmail( self.from_addr[1], message.to_addrs, message.body ) self.logger.info("Sent Email to %s", message.recipient) except (OSError, smtplib.SMTPException, RuntimeError) as e: self.logger.warning( 'Sending email to "%s" failed.', message.recipient ) self.logger.debug(f"Socket Exception: {e}") # Mark as failure has_error = True except (OSError, smtplib.SMTPException, RuntimeError) as e: self.logger.warning( 'Connection error while submitting email to "%s"', self.smtp_host, ) self.logger.debug(f"Socket Exception: {e}") # Mark as failure has_error = True except AppriseEmailException as e: self.logger.debug(f"Socket Exception: {e}") # Mark as failure has_error = True finally: # Gracefully terminate the connection with the server if socket is not None: with contextlib.suppress( OSError, smtplib.SMTPException, RuntimeError ): # Handles a failed TLS handshake that may have already # invalidated the socket. socket.quit() pass # Reduce our dictionary (eliminate expired keys if any) self.pgp.prune() return not has_error def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ # Define an URL parameters params = {} # Always emit pgp= when wkd=yes is set, even when the mode is the # default 'none'. Without this, a URL like ?pgp=none&wkd=yes would # round-trip as ?wkd=yes and the wkd=yesβ†’pgp=encrypt implication # would silently re-enable encryption on re-parse. if self.pgp_mode != PGP_MODE_DEFAULT or self.use_wkd: params["pgp"] = self.pgp_mode # Store our public key back into the URL when one was supplied; # mask the value when building a privacy-safe URL because the key # path can reveal local filesystem layout or remote key server URLs if self.pgp_key is not None: params["pgppub"] = ( "****" if privacy else NotifyEmail.quote(self.pgp_key, safe=":\\/") ) # Store the private key path in the URL in the same way; masked # in privacy mode because private key paths are sensitive if self.pgp_privkey is not None: params["pgpprv"] = ( "****" if privacy else NotifyEmail.quote(self.pgp_privkey, safe=":\\/") ) # Include wkd= when Web Key Directory lookup is enabled if self.use_wkd: params["wkd"] = "yes" # Emit inline flag only when enabled if self.inline: params["inline"] = "yes" # Append our headers into our parameters params.update({"+{}".format(k): v for k, v in self.headers.items()}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) from_addr = None if len(self.targets) == 1 and self.targets[0][1] != self.from_addr[1]: # A custom email was provided from_addr = self.from_addr[1] if self.smtp_host != self.host: # Apply our SMTP Host only if it differs from the provided hostname params["smtp"] = self.smtp_host if self.secure: # Mode is only required if we're dealing with a secure connection params["mode"] = self.secure_mode if self.from_addr[0] and self.from_addr[0] != self.app_id: # A custom name was provided params["from"] = ( self.from_addr[0] if not from_addr else formataddr( (self.from_addr[0], from_addr), charset="utf-8" ) ) elif from_addr: params["from"] = formataddr((False, from_addr), charset="utf-8") elif not self.user: params["from"] = formataddr( (False, self.from_addr[1]), charset="utf-8" ) if self.cc: # Handle our Carbon Copy Addresses params["cc"] = ",".join( [ formataddr( (self.names.get(e, False), e), # Swap comma for its escaped url code (if # detected) since we use it as a delimiter charset="utf-8", ).replace(",", "%2C") for e in self.cc ] ) if self.bcc: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join( [ formataddr( (self.names.get(e, False), e), # Swap comma for its escaped url code (if # detected) since we use it as a delimiter charset="utf-8", ).replace(",", "%2C") for e in self.bcc ] ) if self.reply_to: # Handle our Reply-To Addresses params["reply"] = ",".join( [ formataddr( (self.names.get(e, False), e), # Swap comma for its escaped url code (if # detected) since we use it as a delimiter charset="utf-8", ).replace(",", "%2C") for e in self.reply_to ] ) # pull email suffix from username (if present) user = None if not self.user else self.user.split("@")[0] # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyEmail.quote(user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif user: # user url auth = "{user}@".format( user=NotifyEmail.quote(user, safe=""), ) # Default Port setup default_port = SECURE_MODES[self.secure_mode]["default_port"] # a simple boolean check as to whether we display our target emails # or not has_targets = not ( len(self.targets) == 1 and self.targets[0][1] == self.from_addr[1] ) return "{schema}://{auth}{hostname}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else ":{}".format(self.port) ), targets=( "" if not has_targets else "/".join( [ NotifyEmail.quote( "{}{}".format( "" if not e[0] else "{}:".format(e[0]), e[1] ), safe="", ) for e in self.targets ] ) ), params=NotifyEmail.urlencode(params, safe="*"), ) @property def url_identifier(self): """ Returns all of the identifiers that make this URL unique from another similar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.smtp_host, ( self.port if self.port else SECURE_MODES[self.secure_mode]["default_port"] ), ) def __len__(self): """ Returns the number of targets associated with this notification """ return len(self.targets) if self.targets else 1 @staticmethod def parse_url(url): """ Parses the URL and returns enough arguments that can allow us to re-instantiate this object. """ results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Prepare our target lists results["targets"] = [] if is_ipaddr(results["host"]): # Silently move on and do not disrupt any configuration pass elif not is_hostname( results["host"], ipv4=False, ipv6=False, underscore=False ): if is_email(NotifyEmail.unquote(results["host"])): # Don't lose defined email addresses results["targets"].append(NotifyEmail.unquote(results["host"])) # Detect if we have a valid hostname or not; be sure to reset it's # value if invalid; we'll attempt to figure this out later on results["host"] = "" # Get PGP mode -- accept the new choice strings (none, encrypt) # with prefix matching, and fall back to legacy boolean parsing # for backward compatibility with existing ?pgp=yes/no URLs. pgp_raw = results["qsd"].get("pgp", "") if pgp_raw: pgp_mode = next( (m for m in PGP_MODES if m.startswith(str(pgp_raw).lower())), None, ) if pgp_mode is None: # Not a recognised mode string; try legacy boolean if parse_bool(pgp_raw): logger.warning( "Email ?pgp=%s is deprecated;" " use ?pgp=encrypt instead", pgp_raw, ) pgp_mode = PGPMode.ENCRYPT else: pgp_mode = PGP_MODE_DEFAULT results["pgp_mode"] = pgp_mode # Get Web Key Directory flag if "wkd" in results["qsd"] and results["qsd"]["wkd"]: results["use_wkd"] = parse_bool(results["qsd"]["wkd"]) # wkd=yes implies pgp=encrypt when pgp= was not present in the URL if results.get("use_wkd") and "pgp_mode" not in results: results["pgp_mode"] = PGPMode.ENCRYPT # Get PGP Public Key Override (canonical name: pgppub=) if "pgppub" in results["qsd"] and results["qsd"]["pgppub"]: results["pgp_key"] = NotifyEmail.unquote(results["qsd"]["pgppub"]) # Backward-compat: pgpkey= is the old name for pgppub=; emit a # deprecation warning and map it through so existing URLs still work. if "pgpkey" in results["qsd"] and results["qsd"]["pgpkey"]: logger.warning( "Email ?pgpkey= is deprecated; use ?pgppub= instead." " Support for pgpkey= will be removed in a future release." ) # Only override pgp_key when pgppub= was not also present if "pgp_key" not in results: results["pgp_key"] = NotifyEmail.unquote( results["qsd"]["pgpkey"] ) # Get PGP Private Key Override (canonical name: pgpprv=) if "pgpprv" in results["qsd"] and results["qsd"]["pgpprv"]: results["pgp_privkey"] = NotifyEmail.unquote( results["qsd"]["pgpprv"] ) # Get inline attachment flag if "inline" in results["qsd"]: results["inline"] = parse_bool( results["qsd"]["inline"], NotifyEmail.template_args["inline"]["default"], ) # The From address is a must; either through the use of templates # from= entry and/or merging the user and hostname together, this # must be calculated or parse_url will fail. from_addr = "" # The server we connect to to send our mail to smtp_host = "" # Get our potential email targets; if none our found we'll just # add one to ourselves results["targets"] += NotifyEmail.split_path(results["fullpath"]) # Attempt to detect 'to' email address if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append(results["qsd"]["to"]) # Attempt to detect 'from' email address if "from" in results["qsd"] and len(results["qsd"]["from"]): from_addr = NotifyEmail.unquote(results["qsd"]["from"]) if "name" in results["qsd"] and len(results["qsd"]["name"]): from_addr = formataddr( (NotifyEmail.unquote(results["qsd"]["name"]), from_addr), charset="utf-8", ) elif "name" in results["qsd"] and len(results["qsd"]["name"]): # Extract from name to associate with from address from_addr = NotifyEmail.unquote(results["qsd"]["name"]) # Store SMTP Host if specified if "smtp" in results["qsd"] and len(results["qsd"]["smtp"]): # Extract the smtp server smtp_host = NotifyEmail.unquote(results["qsd"]["smtp"]) if "mode" in results["qsd"] and len(results["qsd"]["mode"]): # Extract the secure mode to over-ride the default results["secure_mode"] = results["qsd"]["mode"].lower() # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = results["qsd"]["cc"] # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = results["qsd"]["bcc"] # Handle Reply To Addresses if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = results["qsd"]["reply"] results["from_addr"] = from_addr results["smtp_host"] = smtp_host # Add our Meta Headers that the user can provide with their outbound # emails results["headers"] = { NotifyBase.unquote(x): NotifyBase.unquote(y) for x, y in results["qsd+"].items() } return results @staticmethod def _get_charset(input_string): """ Get utf-8 charset if non ascii string only Encode an ascii string to utf-8 is bad for email deliverability because some anti-spam gives a bad score for that like SUBJ_EXCESS_QP flag on Rspamd """ if not input_string: return None return "utf-8" if not all(ord(c) < 128 for c in input_string) else None @staticmethod def prepare_emails( subject, body, from_addr, to, cc: Optional[set] = None, bcc: Optional[set] = None, reply_to: Optional[set] = None, # Providing an SMTP Host helps improve Email Message-ID # and avoids getting flagged as spam smtp_host=None, # Can be either 'html' or 'text' notify_format=NotifyFormat.HTML, attach=None, headers: Optional[dict] = None, # Names can be a dictionary names=None, # Pretty Good Privacy Support; Pass in an # ApprisePGPController if you wish to use it pgp=None, # PGP mode string (PGPMode.NONE / PGPMode.SIGN / PGPMode.ENCRYPT) pgp_mode=PGP_MODE_DEFAULT, # When True, image attachments are embedded inline (RFC 2387) # so HTML bodies can reference them via inline=False, # Define our timezone; if one isn't provided, then we use # the system time instead tzinfo=None, ): """ Generator for emails from_addr: must be in format: (from_name, from_addr) to: must be in the format: [(to_name, to_addr), (to_name, to_addr)), ...] cc: must be a set of email addresses bcc: must be a set of email addresses reply_to: must be either None, or an email address smtp_host: This is used to generate the email's Message-ID. Set this correctly to avoid getting flagged as Spam notify_format: can be either 'text' or 'html' attach: must be of class AppriseAttachment headers: Optionally provide a dictionary of additional headers you would like to include in the email payload names: This is a dictionary of email addresses as keys and the Names to associate with them when sending the email. This is cross referenced for the cc and bcc lists pgp: ApprisePGPController for signing and/or encrypting email via Pretty Good Privacy. Pass None to skip. pgp_mode: PGPMode string controlling what PGP operation to perform. PGPMode.ENCRYPT encrypts (existing behaviour). PGPMode.SIGN signs with the sender's private key and opportunistically encrypts when a recipient public key is available. """ if not to: # There is no one to email; we're done msg = "There are no Email recipients to notify" logger.warning(msg) raise AppriseEmailException(msg) from None elif pgp and pgp_mode != PGP_MODE_DEFAULT and not _pgp.PGP_SUPPORT: msg = "PGP Support unavailable; install PGPy library" logger.warning(msg) raise AppriseEmailException(msg) from None if headers is None: headers = {} if cc is None: cc = set() if bcc is None: bcc = set() if reply_to is None: reply_to = set() if not names: # Prepare a empty dictionary to prevent errors/warnings names = {} if not smtp_host: # Generate a host identifier (used for Message-ID Creation) smtp_host = from_addr[1].split("@")[1] if not tzinfo: # use server time tzinfo = datetime.now().astimezone().tzinfo logger.debug(f"SMTP Host: {smtp_host}") # Create a copy of the targets list emails = list(to) while len(emails): # Get our email to notify to_name, to_addr = emails.pop(0) # Strip target out of cc list if in To or Bcc cc_ = cc - bcc - {to_addr} # Strip target out of bcc list if in To bcc_ = bcc - {to_addr} # Strip target out of reply_to list if in To reply_to_ = reply_to - {to_addr} # Format our cc addresses to support the Name field cc_ = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in cc_ ] # Format our bcc addresses to support the Name field bcc_ = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in bcc_ ] if reply_to_: # Format our reply-to addresses to support the Name field reply_to = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in reply_to_ ] logger.debug( "Email From: {}".format(formataddr(from_addr, charset="utf-8")) ) logger.debug("Email To: {}".format(to_addr)) if cc_: logger.debug("Email Cc: {}".format(", ".join(cc_))) if bcc_: logger.debug("Email Bcc: {}".format(", ".join(bcc_))) if reply_to_: logger.debug("Email Reply-To: {}".format(", ".join(reply_to_))) # When inline mode is active, pre-scan image attachments # before the MIME body is built so the modified body is # picked up by all parts (text/plain alternative included). # # HTML emails: collect any existing cid:filename refs from # the body, then append
    for each # image attachment that is not yet referenced. This ensures # all images are embedded inline -- even when the caller did # not write the cid: anchors manually. # # Plain-text emails: images cannot be embedded; append a # short [Image: name] marker so the recipient knows the # attachment is there. # # Non-image attachments are never modified here; they always # fall back to standard Content-Disposition: attachment. cid_refs = set() if inline and attach: if notify_format == NotifyFormat.HTML: # Collect filenames already referenced via cid:; # decode %20 to spaces so filenames with spaces match # the raw attachment name regardless of how the caller # encoded the URI. cid_refs = { ref.replace("%20", " ") for ref in re.findall(r'cid:([^\s"\'>\)]+)', body) } # Build a set of all attachment filenames so we can # warn about cid: refs that have no matching file _attach_names = { _a.name or f"file{_no:03}.dat" for _no, _a in enumerate(attach, start=1) } # Warn for every cid: ref in the body that has no # corresponding attachment -- the user likely made a # typo or forgot to include the file for _ref in sorted(cid_refs - _attach_names): logger.warning( "Email inline: no attachment matches " "cid:%s -- check the filename.", _ref, ) # Drop refs that have no matching attachment so they # do not influence the multipart/related decision. # Note: only IMAGE attachments are ever auto-appended # (see img_appends loop below), but explicit cid: refs # in the caller-supplied body are honored for any # attachment type -- a cid: URI can only resolve # within the same MIME package, so if the caller wrote # it they clearly attached the file on purpose. cid_refs &= _attach_names # Append inline anchors for any image not yet listed img_appends = [] for _no, _a in enumerate(attach, start=1): _fname = _a.name or f"file{_no:03}.dat" if (_a.mimetype or "").lower().startswith( "image/" ) and _fname not in cid_refs: img_appends.append(_fname) cid_refs.add(_fname) if img_appends: # Encode spaces as %20 in cid: URIs so filenames # with spaces remain valid HTML attribute values body = body + "".join( '
    '.format( n.replace(" ", "%20") ) for n in img_appends ) else: # Plain text: annotate inline images with a short # text placeholder (images cannot be embedded here) txt_imgs = [] for _no, _a in enumerate(attach, start=1): _fname = _a.name or f"file{_no:03}.dat" if (_a.mimetype or "").lower().startswith("image/"): txt_imgs.append(_fname) if txt_imgs: body = ( body + "\n" + "\n".join(f"[Image: {n}]" for n in txt_imgs) ) # Prepare Email Message if notify_format == NotifyFormat.HTML: base = MIMEMultipart("alternative") base.attach( MIMEText( convert_between( NotifyFormat.HTML, NotifyFormat.TEXT, body ), "plain", "utf-8", ) ) base.attach(MIMEText(body, "html", "utf-8")) else: base = MIMEText(body, "plain", "utf-8") if attach: # Use multipart/related when any image will be embedded # inline (RFC 2387); otherwise keep multipart/mixed mixed = MIMEMultipart("related" if cid_refs else "mixed") mixed.attach(base) # Now store our attachments for no, attachment in enumerate(attach, start=1): if not attachment: # We could not load the attachment; take an early # exit since this isn't what the end user wanted # We could not access the attachment msg = "Could not access attachment {}.".format( attachment.url(privacy=True) ) logger.warning(msg) raise AppriseEmailException(msg) logger.debug( "Preparing Email attachment {}".format( attachment.url(privacy=True) ) ) with attachment.open() as abody: app = MIMEApplication(abody.read()) app.set_type(attachment.mimetype) # Prepare our attachment name filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) if filename in cid_refs: # Filename is referenced by a cid: anchor in # the HTML body; embed it inline so clients # render it in-place rather than as a download app.add_header( "Content-Disposition", 'inline; filename="{}"'.format( Header(filename, "utf-8") ), ) app.add_header( "Content-ID", # Encode spaces so the Content-ID matches # the cid: URI in the HTML anchor "<{}>".format(filename.replace(" ", "%20")), ) else: # Non-image or plain-text-email attachment; # keep it as a regular downloadable file app.add_header( "Content-Disposition", 'attachment; filename="{}"'.format( Header(filename, "utf-8") ), ) mixed.attach(app) base = mixed if pgp and pgp_mode == PGPMode.SIGN: logger.debug("Securing Email with PGP Signature") # RFC 3156 requires the signature to be computed over the CRLF # to guarantee all recipients can verify against it. sig_result = pgp.sign( re.sub(r"(?:\r\n|\n|\r(?!\n))", "\r\n", base.as_string()) ) if not sig_result: # Signing was requested but could not be performed; # this is a hard failure for the sign mode. Include the # plugin-specific hint here (pgp.py logs only generic # debug detail so it stays reusable across plugins). msg = ( "Unable to sign email via PGP; supply a private key" " via pgpprv= or place one in the cache directory" ) logger.warning(msg) raise AppriseEmailException(msg) # Unpack the signature and its hash algorithm label sig_str, micalg = sig_result # Build the RFC 3156 multipart/signed container signed = MIMEMultipart( "signed", micalg=micalg, protocol="application/pgp-signature", ) # First part: the original message body (must not be # modified after this point as the signature covers it) signed.attach(base) # Second part: the detached PGP signature block sig_part = MIMEBase("application", "pgp-signature") sig_part.set_payload(sig_str) sig_part.add_header( "Content-Disposition", "attachment", filename="signature.asc", ) signed.attach(sig_part) # Replace base with the signed container so the rest of # the function continues to work unchanged base = signed # Opportunistic encryption: try to find the recipient's # public key and encrypt the signed payload if found. # autogen=False prevents a fake key from being auto-created # for the recipient -- only pre-existing keys (WKD lookups # or explicit pgppub= files) qualify for encryption. # A missing key is NOT an error in sign mode -- we simply # send the signed (unencrypted) email instead. enc_content = pgp.encrypt( base.as_string(), to_addr, autogen=False ) if enc_content: logger.debug("PGP public key found; adding encryption") # Wrap the signed body in a multipart/encrypted # container (the signed payload becomes the ciphertext) enc = MIMEMultipart( "encrypted", protocol="application/pgp-encrypted", ) # Autocrypt header for DeltaChat / compatible clients enc.add_header( "Autocrypt", "addr={}; prefer-encrypt=mutual".format( formataddr((False, to_addr), charset="utf-8") ), ) # Version identifier part (required by RFC 3156) ver_part = MIMEText("Version: 1", "plain") ver_part.set_type("application/pgp-encrypted") enc.attach(ver_part) # Encrypted data part data_part = MIMEBase("application", "octet-stream") data_part.set_payload(enc_content) enc.attach(data_part) # Replace base with the fully encrypted container base = enc elif pgp and pgp_mode == PGPMode.ENCRYPT: logger.debug("Securing Email with PGP Encryption") # Set our header information to include in the encryption base["From"] = formataddr( (None, from_addr[1]), charset="utf-8" ) base["To"] = formataddr((None, to_addr), charset="utf-8") base["Subject"] = Header( subject, NotifyEmail._get_charset(subject) ) # Apply our encryption encrypted_content = pgp.encrypt(base.as_string(), to_addr) if not encrypted_content: # Unable to send notification. Include the plugin-specific # hint here (pgp.py logs only generic debug detail so it # stays reusable across plugins). msg = ( "Unable to encrypt email via PGP; supply a public key" " via pgppub= or place one in the cache directory" ) logger.warning(msg) raise AppriseEmailException(msg) # prepare our message base = MIMEMultipart( "encrypted", protocol="application/pgp-encrypted" ) # Store Autocrypt header (DeltaChat Support) base.add_header( "Autocrypt", f"addr={formataddr((False, to_addr), charset='utf-8')}; " "prefer-encrypt=mutual", ) # Set Encryption Info Part enc_payload = MIMEText("Version: 1", "plain") enc_payload.set_type("application/pgp-encrypted") base.attach(enc_payload) enc_payload = MIMEBase("application", "octet-stream") enc_payload.set_payload(encrypted_content) base.attach(enc_payload) # Apply any provided custom headers for k, v in headers.items(): base[k] = Header(v, NotifyEmail._get_charset(v)) base["Subject"] = Header( subject, NotifyEmail._get_charset(subject) ) base["From"] = formataddr(from_addr, charset="utf-8") base["To"] = formataddr((to_name, to_addr), charset="utf-8") base["Message-ID"] = make_msgid(domain=smtp_host) base["Date"] = format_datetime(datetime.now(tz=tzinfo)) if cc: base["Cc"] = ",".join(cc_) if reply_to_: base["Reply-To"] = ",".join(reply_to) yield EmailMessage( recipient=to_addr, to_addrs=[to_addr, *list(cc_), *list(bcc_)], body=base.as_string(), ) @staticmethod def runtime_deps(): """Return a tuple of top-level Python package names that this plugin imported as optional runtime dependencies. """ return ("pgpy",) caronc-apprise-182f859/apprise/plugins/email/common.py000066400000000000000000000047371524161175200230040ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import dataclasses from ...exception import ApprisePluginException class AppriseEmailException(ApprisePluginException): """ Thrown when there is an error with the Email Attachment """ def __init__(self, message, error_code=601): super().__init__(message, error_code=error_code) class WebBaseLogin: """ This class is just used in conjunction of the default emailers to best formulate a login to it using the data detected """ # User Login must be Email Based EMAIL = "Email" # User Login must UserID Based USERID = "UserID" # Secure Email Modes class SecureMailMode: INSECURE = "insecure" SSL = "ssl" STARTTLS = "starttls" # Define all of the secure modes (used during validation) SECURE_MODES = { SecureMailMode.STARTTLS: { "default_port": 587, }, SecureMailMode.SSL: { "default_port": 465, }, SecureMailMode.INSECURE: { "default_port": 25, }, } @dataclasses.dataclass class EmailMessage: """ Our message structure """ recipient: str to_addrs: list[str] body: str caronc-apprise-182f859/apprise/plugins/email/templates.py000066400000000000000000000241541524161175200235050ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re from .common import SecureMailMode, WebBaseLogin # To attempt to make this script stupid proof, if we detect an email address # that is part of the this table, we can pre-use a lot more defaults if they # aren't otherwise specified on the users input. EMAIL_TEMPLATES = ( # Google GMail ( "Google Mail", re.compile( r"^((?P
    1. apprise(1)
    2. apprise(1)

    NAME

    apprise - Push Notifications that work with just about every platform!

    SYNOPSIS

    apprise [options...] service-url...
    apprise storage [options...] [action] [url-or-uid...]

    DESCRIPTION

    Apprise allows you to send a notification to almost all of the most popular notification services available to us today such as: Discord, Telegram, Pushbullet, Slack, Twitter, etc.

    • One notification library to rule them all.
    • A common and intuitive notification syntax.
    • Supports the handling of images (to the notification services that will accept them).
    • It's incredibly lightweight.
    • Amazing response times because all messages sent asynchronously.

    OPTIONS

    The Apprise options are as follows:

    -b, --body=VALUE: Specify the message body. If no body is specified then content is read from stdin.

    -t, --title=VALUE: Specify the message title. This field is completely optional.

    -c, --config=CONFIG-URL: Specify one or more configuration locations.

    -a, --attach=ATTACH-URL: Specify one or more file attachment locations.

    -P, --plugin-path=PATH: Specify a path to scan for custom notification plugin support. You can create your own notification by simply creating a Python file that contains the @notify("schema") decorator.

    You can optionally choose to specify more than one --plugin-path (-P) to increase the modules included.

    -n, --notification-type=VALUE: Specify the message type (default=info). Possible values are "info", "success", "failure", and "warning".

    -i, --input-format=VALUE: Specify the input message format (default=text). Possible values are "text", "html", and "markdown".

    -T, --theme=VALUE: Specify the default theme.

    -g, --tag=VALUE: Specify one or more tags to filter which services to notify:

    • -g "tagA" -g "tagB": Match tagA OR tagB (Union).
    • -g "tagA,tagB": Match tagA AND tagB (Strict).
    • -g "all": Notify ALL services.
    • (Omitted): Notify untagged services only.

    Tag values may include an optional priority prefix and/or retry suffix:

    • -g "2:tagA": Match only entries for tagA assigned priority 2.
    • -g "tagA:3": Match all tagA entries, retrying each up to 3 times.
    • -g "2:tagA:3": Priority-2 tagA entries only, with up to 3 retries.

    -Da, --disable-async: Send notifications synchronously (one after the other) instead of all at once.

    -R, --recursion-depthINTEGER: The number of recursive import entries that can be loaded from within Apprise configuration. By default this is set to 1. If this is set to zero, then import statements found in any configuration is ignored.

    -e, --interpret-escapes Enable interpretation of backslash escapes. For example, this would convert sequences such as \n and \r to their respected ascii new-line and carriage return characters prior to the delivery of the notification.

    -j, --interpret-emojis Enable interpretation of emoji strings. For example, this would convert sequences such as :smile: or :grin: to their respected unicode emoji character.

    -S, --storage-path=PATH: Specify the path to the persistent storage caching location

    -SM, --storage-mode=MODE: Specify the persistent storage operational mode. Possible values are "auto", "flush", and "memory". The default is "auto" if not specified.

    -SPD, --storage-prune-days=INTEGER: Define the number of days the storage prune should run using. Setting this to zero (0) will eliminate all accumulated content. By default this value is 30 (days).

    -SUL, --storage-uid-length=INTEGER: Define the number of unique characters to store persistent cache in. By default this value is 8 (characters).

    -d, --dry-run: Perform a trial run but only prints the notification services to-be triggered to stdout. Notifications are never sent using this mode.

    -l, --details Prints details about the current services supported by Apprise.

    -v, --verbose: The more of these you specify, the more verbose the output is. e.g: -vvvv

    -D, --debug: A debug mode; useful for troubleshooting.

    -V, --version: Display the apprise version and exit.

    -h, --help: Show this message and exit.

    PERSISTENT STORAGE

    Persistent storage by default writes to the following location unless the environment variable APPRISE_STORAGE_PATH overrides it and/or --storage-path (-SP) is specified to override it:

    ~/.local/share/apprise/cache
    

    To utilize the persistent storage element associated with Apprise, simply specify the keyword storage

    $ apprise storage
    

    The storage action has the following sub actions:

    list: List all of the detected persistent storage elements and their state (stale, active, or unused). This is the default action if nothing further is identified. Results may be filtered by passing one or more 8-character UID prefix strings, or by passing full Apprise service URLs β€” each URL is resolved to its storage namespace automatically, making it easy to look up a specific plugin's cache entry without knowing the hash in advance.

    prune: Removes all persistent storage that has not been referenced for more than 30 days. You can optionally set the --storage-prune-days to alter this default value.

    clean: Removes all persistent storage regardless of age.

    EXIT STATUS

    apprise exits with a status of:

    • 0 if all of the notifications were sent successfully.
    • 1 if one or more notifications could not be sent.
    • 2 if there was an error specified on the command line such as not providing an valid argument.
    • 3 if there was one or more Apprise Service URLs successfully loaded but none could be notified due to user filtering (via tags).

    SERVICE URLS

    There are too many service URL and combinations to list here. It's best to visit the Apprise GitHub page and see what's available. Also try out the Apprise URL Builder to easily construct the URLS this tool supports and works with.

    The environment variable of APPRISE_URLS (comma/space delimited) can be specified to provide the default set of URLs you wish to notify if none are otherwise specified.

    EXAMPLES

    Send a notification to as many servers as you want to specify as you can easily chain them together:

    $ apprise -vv -t "my title" -b "my notification body" \
       "mailto://myemail:mypass@gmail.com" \
       "pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b"
    

    If you don't specify a --body (-b) then stdin is used allowing you to use the tool as part of your every day administration:

    $ cat /proc/cpuinfo | apprise -vv -t "cpu info" \
        "mailto://myemail:mypass@gmail.com"
    

    Load in a configuration file which identifies all of your notification service URLs and notify them all:

    $ apprise -vv -t "my title" -b "my notification body" \
       --config=~/apprise.yml
    

    Load in a configuration file from a remote server that identifies all of your notification service URLs and only notify the ones tagged as devops.

    $ apprise -vv -t "my title" -b "my notification body" \
       --config=https://localhost/my/apprise/config \
       -t devops
    

    Tagging Logic Examples:

    Notify any service tagged with "devops" OR "admin" (Union):

    $ apprise -vv -t "Union Test" \
       --config=~/apprise.yml \
       -g devops -g admin
    

    Notify only services tagged with BOTH "devops" AND "critical" (Intersection):

    $ apprise -vv -t "Intersection Test" \
       --config=~/apprise.yml \
       -g devops,critical
    

    Notify only priority-2 "alerts" services (exclusive priority filter):

    $ apprise -vv -t "High Alert" \
       --config=~/apprise.yml \
       -g "2:alerts"
    

    Notify all "alerts" services and retry each up to 3 times on failure:

    $ apprise -vv -t "Critical Event" \
       --config=~/apprise.yml \
       -g "alerts:3"
    

    Include an attachment:

    $ apprise -vv -t "School Assignment" -b "See attached" \
       --attach=Documents/FinalReport.docx
    

    List all of the notifications loaded:

    $ apprise --dry-run --tag=all
    

    List all of the details around the current persistent storage setup:

    $ apprise storage list
    

    Look up the storage namespace for a specific notification URL (shows the UID even when no data has been written yet):

    $ apprise storage list "mailtos://user:pass@example.com"
    

    Prune all persistent storage that has not been referenced for at least 10 days or more

    $ apprise storage prune --storage-prune-days=10
    

    CUSTOM PLUGIN/NOTIFICATIONS

    Apprise can additionally allow you to define your own custom schema:// entries that you can trigger on and call services you've defined.

    By default apprise looks in the following local locations for custom plugin files and loads them:

    ~/.apprise/plugins
    ~/.config/apprise/plugins
    /var/lib/apprise/plugins
    

    The environment variable of APPRISE_PLUGIN_PATH can be specified to override the list identified above with one of your own. use a semi-colon (;), line-feed (\n), and/or carriage return (\r) to delimit multiple entries.

    Simply create your own python file with the following bare minimum content in it:

    from apprise.decorators import notify
    
    # This example assumes you want your function to trigger on foobar://
    # references:
    @notify(on="foobar", name="My Custom Notification")
    def my_wrapper(body, title, notify_type, *args, **kwargs):
    
         print("Define your custom code here")
    
         # Returning True/False will relay your status back through Apprise
         # Returning nothing (None by default) is always interpreted as True
         return True
    

    CONFIGURATION

    An Apprise configuration file can be in the format of either TEXT or YAML where TEXT is the easiest and most ideal solution for most users. However YAML configuration files grants the user a bit more leverage and access to some of the internal features of Apprise. Regardless of which format you choose, both provide the users the ability to leverage tagging which adds a more rich and powerful notification environment.

    Configuration files can be directly referenced via apprise when referencing the --config= (-c) CLI directive. You can identify as many as you like on the command line and all of them will be loaded. You can also point your configuration to a cloud location (by referencing http:// or https://. By default apprise looks in the following local locations for configuration files and loads them:

    ~/.apprise.conf
    ~/.apprise.yaml
    ~/.config/apprise.conf
    ~/.config/apprise.yaml
    
    ~/.apprise/apprise.conf
    ~/.apprise/apprise.yaml
    ~/.config/apprise/apprise.conf
    ~/.config/apprise/apprise.yaml
    
    /etc/apprise.conf
    /etc/apprise.yaml
    /etc/apprise/apprise.conf
    /etc/apprise/apprise.yaml
    

    The configuration files specified above can also be identified with a .yml extension or even just entirely removing the .conf extension altogether.

    The environment variable of APPRISE_CONFIG_PATH can be specified to override the list identified above with one of your own. use a semi-colon (;), line-feed (\n), and/or carriage return (\r) to delimit multiple entries.

    If a default configuration file is referenced in any way by the apprise tool, you no longer need to provide it a Service URL. Usage of the apprise tool simplifies to:

    $ apprise -vv -t "my title" -b "my notification body"
    

    If you leveraged tagging, you can define all of Apprise Service URLs in your configuration that you want and only specifically notify a subset of them:

    $ apprise -vv --title "Will Be Late Getting Home" \
        --body "Please go ahead and make dinner without me." \
        --tag=family
    

    ENVIRONMENT VARIABLES

    APPRISE_URLS: Specify the default URLs to notify IF none are otherwise specified on the command line explicitly. If the --config (-c) is specified, then this will override any reference to this variable. Use white space and/or a comma (,) to delimit multiple entries.

    APPRISE_CONFIG_PATH: Explicitly specify the config search path to use (overriding the default). Use a semi-colon (;), line-feed (\n), and/or carriage return (\r) to delimit multiple entries.

    APPRISE_PLUGIN_PATH: Explicitly specify the custom plugin search path to use (overriding the default). Use a semi-colon (;), line-feed (\n), and/or carriage return (\r) to delimit multiple entries.

    APPRISE_STORAGE_PATH: Explicitly specify the persistent storage path to use (overriding the default).

    HTTP_PROXY, HTTPS_PROXY, NO_PROXY: Standard proxy variables honored by the underlying requests library (not Apprise-specific). Set HTTP_PROXY/HTTPS_PROXY to route outbound notifications through a proxy, and NO_PROXY to exempt specific hosts. SOCKS proxies (socks5h://...) additionally require the optional PySocks package (pip install pysocks) to be installed.

    BUGS

    If you find any bugs, please make them known at: https://github.com/caronc/apprise/issues

    DONATIONS

    If you found Apprise useful at all, please consider sponsoring or donating!

    Apprise is Copyright (C) 2026 Chris Caron lead2gold@gmail.com

    1. Chris Caron <lead2gold@gmail.com>
    2. August 2026
    3. apprise(1)
    b") == "ab" assert to_md("ab") == "ab" assert to_md("ab") == "ab" # A stray / close still falls through cleanly when content follows. assert to_md("

    ab

    ") == "ab" # _make_frame() empty-stack guard conv = HTMLMarkdownConverter() # Force the stack empty -- this can only happen via direct attribute # manipulation, never through the public parsing API conv._stack = [] frame = conv._make_frame("div") # The fallback defaults must match the root sentinel values assert frame["tag"] == "div" assert frame["do_store"] is True assert frame["preserve_cr"] is False assert frame["list_type"] is None assert frame["depth"] == 0 assert frame["counter"] is None assert frame["list_do_store"] is True # A stale open-tag count must not corrupt the stack. conv = HTMLMarkdownConverter() conv._tag_open_counts["nonexistent"] = 1 conv._pop_to("nonexistent") # must not raise or corrupt state assert len(conv._stack) == 1 # only the root sentinel remains # ignore content found in head assert ( to_md( "" + "
      " * 50 + "
    • hidden
    • " + "
    " * 50 + "text" ) == "text" ) # deep
      nesting still resolve correctly. assert ( to_md("
        " * 50 + "
      • visible
      • " + "
      " * 50) == "- visible" ) # List indentation is capped at LIST_DEPTH_MAX --- assert LIST_DEPTH_MAX == 4 one_per_level = "
      • x" * 10 + "
      " * 10 out = to_md(one_per_level) lines = out.split("\n") assert len(lines) == 10 # Indent grows for the first LIST_DEPTH_MAX levels... assert lines[0] == "- x" assert lines[1] == " - x" assert lines[2] == " - x" assert lines[3] == " - x" # ...then holds steady for every level beyond the cap for line in lines[4:]: assert line == " - x" # Performance: same shape, large enough that the old O(N^2) n = 20000 deep = "
      • x" * n + "
      " * n start = default_timer() out = to_md(deep) elapsed = default_timer() - start assert len(out) < 20 * n # Generous bound: this must catch a real return to quadratic # behavior, not just run fast on a quiet dev machine -- shared or # emulated build infrastructure (e.g. a busy Koji builder) can be # many times slower. assert elapsed < 15.0 # Performance: many UNCLOSED
    • tags nested directly inside one another. n = 20000 html = "
    • x" * n start = default_timer() out = to_md(html) elapsed = default_timer() - start assert len(out) < 10 * n # output itself is linear, not quadratic # Generous bound -- see the note on the equivalent check above. assert elapsed < 30.0 # Performance: many open
      tags followed by many closing tags of # a *different* kind that's never actually open (""). n = 20000 html = "
      " * n + "" * n start = default_timer() out = to_md(html) elapsed = default_timer() - start assert len(out) < 20 * n # Generous bound: this must catch a real return to quadratic # behavior, not just run fast on a quiet dev machine -- shared or # emulated build infrastructure (e.g. a busy Koji builder) can be # many times slower. assert elapsed < 15.0 # Blockquote depth is capped at BLOCKQUOTE_DEPTH_MAX assert BLOCKQUOTE_DEPTH_MAX == 4 # Each level is its own

      , so every line of real content is followed by a # blank (but still "> "-prefixed) separator line. one_per_level = "

      x

      " * 10 + "
      " * 10 out = to_md(one_per_level) lines = out.split("\n") assert len(lines) == 19 content_lines = lines[0::2] separator_lines = lines[1::2] assert len(content_lines) == 10 assert content_lines[0] == "> x" assert content_lines[1] == "> > x" assert content_lines[2] == "> > > x" assert content_lines[3] == "> > > > x" for line in content_lines[4:]: assert line == "> > > > x" # Each separator matches its preceding content line, minus the "x" for content, separator in zip(content_lines, separator_lines): assert separator == content[: -len("x")].rstrip() n = 20000 deep = "

      x

      " * n + "
      " * n start = default_timer() out = to_md(deep) elapsed = default_timer() - start assert len(out) < 20 * n # Generous bound: this must catch a real return to quadratic # behavior, not just run fast on a quiet dev machine -- shared or # emulated build infrastructure (e.g. a busy Koji builder) can be # many times slower. assert elapsed < 15.0 # When emphasis or anchor tags are nested past MAX_FRAME_DEPTH, _push_frame # returns False and the opening delimiter must NOT be emitted. depth = MAX_FRAME_DEPTH + 1 em_open = "" * depth em_close = "" * depth out_em = to_md(f"{em_open}text{em_close}") # The text must appear; no unmatched bare asterisk may leak through. assert "text" in out_em assert out_em.replace("*", "").strip() == "text" # Same guard for -- no unmatched "[" in the output. a_open = '' * depth a_close = "" * depth out_a = to_md(f"{a_open}text{a_close}") assert "text" in out_a # Count "[" and "](" -- every "[" must have a matching "](". assert out_a.count("[") == out_a.count("](") # At MAX_FRAME_DEPTH the
      frame is discarded and _push_frame # returns False. bq_open = "
      " * depth bq_close = "
      " * depth out_bq = to_md(f"{bq_open}text{bq_close}") assert "text" in out_bq # Every ">" line-prefix must correspond to a real nested blockquote level. first_line = out_bq.splitlines()[0] if out_bq else "" gt_count = len(first_line) - len(first_line.lstrip("> ")) assert gt_count <= BLOCKQUOTE_DEPTH_MAX * 2 # ">" + " " per level # At MAX_FRAME_DEPTH the
    • frame is discarded and _push_frame returns # False. No _ListMarker must be appended without a backing frame. li_open = "
        " + "
      • " * depth li_close = "
      • " * depth + "
      " out_li = to_md(f"{li_open}text{li_close}") assert "text" in out_li def test_conversion_html_to_markdown_blockquotes(): """Test blockquote conversion.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Single paragraph -- marker and content share the first line assert to_md("

      line1

      ") == "> line1" # Multiple paragraphs assert ( to_md("

      line1

      line2

      ") == "> line1\n>\n> line2" ) assert ( to_md("

      a

      b

      c

      ") == "> a\n>\n> b\n>\n> c" ) # A heading doesn't need that same blank-line treatment. assert ( to_md("

      Title

      body

      ") == "> ## Title\n> body" ) # Inline-only content needs no internal prefixing -- it never hits a # boundary inside the quote in the first place assert ( to_md("
      To be or not to be.
      ") == "> To be or not to be." ) # Nested blockquotes accumulate one "> " per level, not per ancestor's full # cumulative prefix, which would double-count outer quote levels. assert ( to_md("
      nested
      ") == "> > nested" ) assert to_md( "

      a

      b

      " ) == ("> > a\n> >\n> > b") # Bare text follows the same one-prefix-per-level rule. assert ( to_md("
      outer
      inner
      ") == "> outer\n>\n> > inner" ) assert to_md( "
      a
      b
      c
      " "
      " ) == ("> a\n>\n> > b\n> >\n> > > c") # The same text-then-nested-blockquote transition, once depth is already # clamped at BLOCKQUOTE_DEPTH_MAX, must not add yet another level. assert BLOCKQUOTE_DEPTH_MAX == 4 capped = ( "
      " * 4 + "x
      y
      " + ("
      " * 4) ) out = to_md(capped) assert "> > > > x" in out assert "> > > > > y" not in out assert "> > > > y" in out # Content after a blockquote needs a full boundary without the quote # prefix. assert ( to_md("

      line1

      after

      ") == "> line1\n\nafter" ) assert ( to_md("

      before

      line1

      ") == "before\n\n> line1" ) # An entirely empty blockquote (no children at all, nested or not) produces # no output. assert to_md("
      ") == "" assert to_md("
      ") == "" # A blockquote entirely inside a suppressed context contributes nothing, # and does not disturb sibling content outside it assert ( to_md( "

      hidden

      " "text" ) == "text" ) # A blockquote following bare text still needs its own boundary. assert to_md("text
      quoted
      ") == ("text\n\n> quoted") assert to_md("text
      ") == "text" assert to_md("

      text

      ") == "text" def test_conversion_html_to_markdown_emphasis(): """Test emphasis edge cases.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Leading/trailing whitespace inside the tag must end up outside the # delimiters. assert to_md(" text ") == "*text*" assert to_md(" bold ") == "**bold**" assert to_md(" multi space ") == "*multi space*" # Leading-only and trailing-only whitespace assert to_md(" text") == "*text*" assert to_md("text ") == "*text*" # Relocating emphasis whitespace must not disturb surrounding text. assert ( to_md("

      Hello bold world

      ") == "Hello **bold** world" ) # Relocate trailing whitespace even when empty tags split the text # fragments. assert to_md("text x") == "**text** x" # Fully empty, or whitespace-only, spans contribute nothing at all -- not # even an unpaired delimiter assert to_md("") == "" assert to_md(" ") == "" assert to_md("") == "" # Adjacent tags, one empty -- the empty one's delimiters must not collide # with the next tag's into an ambiguous run of asterisks assert to_md("bold") == "**bold**" assert to_md("x") == "*x*" # A stray close tag with no matching open is a no-op, the same as other # malformed- HTML cases elsewhere in this parser. assert to_md("
      text") == "text" assert to_md("

      ab

      ") == "ab" # Mismatched open/close tags ("xy"). assert to_md("xy") == "*xy*" # A tag left open with no closing tag at all is auto-closed at end of # document rather than leaving its opening delimiter unpaired assert to_md("text") == "**text**" assert to_md("

      ab") == "*a**b***" # Normal, well-formed cases are unaffected assert to_md("bold") == "**bold**" assert to_md("italic") == "*italic*" assert to_md("AB") == "**A****B**" # Empty block content inside emphasis must not leave an opening delimiter. assert to_md("

      x") == "x" assert to_md("a
      b") == "ab" # Empty trailing blocks must not hide whitespace from emphasis cleanup. assert to_md("text
      ") == ("*text\n\n> *") def test_conversion_html_to_markdown_empty_blocks(): """Test empty block handling.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Plain, marker-less block tags with nothing inside them already collapse # to nothing extra -- no separate blank line is added for each one. assert to_md("

      text

      ") == "text" assert to_md("

      a

      b

      ") == "a\n\nb" assert ( to_md("

      a

      b

      ") == "a\n\nb" ) # Empty blocks do not detach a marker from later content. assert ( to_md("
      • text

      ") == "- text" ) assert to_md("

      text

      ") == "> text" # Drop markers that never receive content. assert to_md("
      ") == "" assert to_md("
      ") == "" # A sibling marker arriving after the first one's empty block tags replaces # it rather than gluing onto it. assert to_md("
      • real
      ") == "- real" # Empty blocks do not duplicate a blockquote prefix. assert ( to_md("

      line1

      line2

      ") == "> line1\n>\n> line2" ) # Real text between two empty blocks still resets the suppression, so the # block tag right after it starts a new line as normal. assert to_md("
      text

      more

      ") == "text\n\nmore" # Plain whitespace between tags never counts as content assert to_md("

      a

      b

      ") == "a\n\nb" # A standalone   is a deliberate space, not incidental formatting # whitespace. assert to_md("

      a

       

      b

      ") == "a\n\n\xa0\n\nb" #   used inline within real text is unaffected, and still collapses to # a regular space like any other inline whitespace assert ( to_md("Let's handle special html encoding") == "Let's handle special html encoding" ) #
      is a void element (its closing tag may never fire), but it still # writes real, non-marker content ("---"). assert to_md("

      ") == "---\n\n---" assert to_md("

      ") == "---\n\n---" assert to_md("


      ") == "---\n\n---\n\n---" assert to_md("

      a



      b

      ") == "a\n\n---\n\n---\n\nb" #
      as the first thing in a blockquote/list item glues onto the marker; # inside an open blockquote, its own opening boundary still applies. assert to_md("

      ") == "- ---" assert to_md("

      a


      ") == "> a\n>\n> ---" # Real page text that happens to *look* like a generated marker (e.g. assert to_md("

      -

      real

      ") == "\\-\n\nreal" assert to_md("

      -

      ") == "\\-" assert ( to_md("

      -

      real div content
      ") == "\\-\n\nreal div content" ) # Real text directly inside a real
    • that itself looks like a marker is # escaped too. assert to_md("
      • -
      ") == "- \\-" def test_conversion_html_to_markdown_list_indentation(): """Test list continuation indentation.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Baseline: a single

      as an

    • 's only child still just glues onto the # marker. assert to_md("
    • text

    • ") == "- text" # Later paragraphs use the current item's continuation indentation. assert to_md("
      • one

        two

      ") == "- one\n\n two" # Ordered lists need the indentation to match their own (wider) marker # width, not the bullet list's fixed 2 spaces assert ( to_md("
      1. one

        two

      ") == "1. one\n\n two" ) # Sibling
    • s must NOT pick up any indentation from each other -- only a # *continuation within the same item* should ever be indented assert ( to_md("
      • alpha

      • beta

      ") == "- alpha\n\n- beta" ) assert ( to_md("
      1. a

      2. b

      3. c

      ") == "1. a\n\n2. b\n\n3. c" ) # A nested sublist's own marker (computed independently for its own depth) # must not be *additionally* indented on top of that. assert to_md("
      • x
        • x
      ") == "- x\n - x" assert ( to_md("
      • L1
        • L2
          • L3
      ") == "- L1\n - L2\n - L3" ) # Nested quotes combine list indentation with quote prefixes. assert to_md( "
      • a

        b

      " ) == ("- > a\n >\n > b") # A first-child list inside a blockquote retains the quote prefix. assert to_md("
      • text
      ") == ( "> - text" ) # Multiple
    • s inside a
      each need "> " restated assert to_md("
      • a
      • b
      ") == ( "> - a\n> - b" ) # Chained blockquotes retain nesting after a paragraph boundary. assert to_md( "

      a

      nested
      " ) == ("> a\n>\n> > nested") #
      / inside an 
    • : every line of the fence. assert to_md("
      • code:
          indented\n  here
      ") == ( "- code:\n ```\n indented\n here\n ```" ) # A deeper list adds another indentation level to the fence. assert to_md( "
      • outer
        • inner:
            x = 1
      " ) == ("- outer\n - inner:\n ```\n x = 1\n ```") def test_conversion_html_to_markdown_br(): """Test CommonMark hard breaks.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Two trailing spaces then a newline -- a bare "\n" is only a soft break # under CommonMark (most renderers collapse it to a single space rather. assert to_md("line1
      line2") == "line1 \nline2" assert to_md("line1
      line2") == "line1 \nline2" assert to_md("line1
      line2") == "line1 \nline2" # Verified against an actual renderer, not just the raw Markdown shape. rendered = convert_between( NotifyFormat.MARKDOWN, NotifyFormat.HTML, to_md("line1
      line2") ) assert " tags each contribute their own hard break assert to_md("a

      b") == "a \n \nb" # A trailing
      with nothing after it doesn't leave a dangling hard break # at the very end of the output assert to_md("

      line1

      ") == "line1" # A hard break inside a blockquote must restate the "> " prefix on its # continuation line. assert to_md("
      line1
      line2
      ") == ( "> line1 \n> line2" ) # Same, but inside a list item -- the continuation line must keep the # marker's indentation so it stays part of the same item. assert to_md("
      • line1
        line2
      ") == ("- line1 \n line2") # Both nested together -- a blockquote containing a list item. assert to_md( "
      • line1
        line2
      " ) == ("> - line1 \n > line2") def test_conversion_html_to_markdown_tables(): """Test GFM table conversion.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Basic table -- first row becomes the header, a "---" separator row is # inserted right after it assert to_md( "" "
      AB
      12
      " ) == ("| A | B |\n| --- | --- |\n| 1 | 2 |") # // are fully transparent -- header-vs-body is decided # purely by row order, the same as -less markup assert to_md( "" "
      NameAge
      Bob42
      " ) == ("| Name | Age |\n| --- | --- |\n| Bob | 42 |") # A single-row table still gets its separator row assert to_md("
      only
      ") == ( "| only |\n| --- |" ) # Inline formatting inside cells is preserved assert to_md( "" '
      boldlink
      ' ) == ("| **bold** | [link]() |\n| --- | --- |") # A literal '|' inside a cell is escaped -- it's the cell delimiter itself, # and would otherwise be misread as starting a new column assert to_md("
      a | b
      ") == ( "| a \\| b |\n| --- |" ) # A newline a cell's own content produced (here, a
      ) is flattened to a # single space. assert to_md("
      a
      b
      ") == ( "| a b |\n| --- |" ) # Surrounding content gets ordinary paragraph separation from the table, # the same as a list or blockquote would assert to_md("

      before

      A
      ") == ( "before\n\n| A |\n| --- |" ) assert to_md("
      A

      after

      ") == ( "| A |\n| --- |\n\nafter" ) # An entirely empty table, or a table whose only row has no cells at all, # produces nothing assert to_md("
      ") == "" assert to_md("
      ") == "" # A table fully inside a suppressed container (e.g. ) contributes # nothing -- not even an empty "| |" row assert ( to_md( "
      x
      text" ) == "text" ) # Suppress text outside table cells. assert to_md("stray
      A
      ") == ( "| A |\n| --- |" ) assert to_md("stray
      A
      ") == ( "| A |\n| --- |" ) assert to_md("\n \n \n \n
      A
      ") == ( "| A |\n| --- |" ) # A stray with no enclosing at all falls back to ordinary # paragraph-like treatment -- the same as before table support existed assert to_md("standalone") == "standalone" # A stray with no enclosing is treated as a one-row table of # its own assert to_md("") == ( "| a | b |\n| --- | --- |" ) def test_conversion_html_to_markdown_tables_indentation(): """Test nested table indentation.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) assert to_md( "
      • before
      ab
      A
    " ) == ("- before\n\n | A |\n | --- |") assert to_md( "
    • before" "
      AB
      12
    " ) == ("- before\n\n | A | B |\n | --- | --- |\n | 1 | 2 |") assert to_md( "
    " "
    AB
    12
    " ) == ("> | A | B |\n> | --- | --- |\n> | 1 | 2 |") def test_conversion_html_to_markdown_tables_hardening(): """Test malformed table input.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Neither nor ever closed -- extremely common in real- # world/legacy table markup assert to_md("
    AB
    12
    ") == ( "| A | B |\n| --- | --- |\n| 1 | 2 |" ) # Same, but the enclosing is missing too -- the last row and cell # are recovered at end of document assert to_md(" or
    AB
    12") == ( "| A | B |\n| --- | --- |\n| 1 | 2 |" ) # A stray
    with nothing matching open at all (no enclosing # structure whatsoever, not even an open cell) is a no-op. assert to_md("text") == "text" assert to_md("text") == "text" assert to_md("

    ab

    ") == "ab" assert to_md("

    ab

    ") == "ab" # A stray with no enclosing renders as a one-row table of its # own and remains separated from following content. assert to_md("after") == ( "| cell |\n| --- |\n\nafter" ) # A
    cell
    nested inside another table's cell has no Markdown # representation. assert to_md( "
    outer
    inner
    " "
    " ) == ("| outer |\n| --- |") # Same, with content both before and after the nested table, and the # nested table itself containing more than one row. assert to_md( "
    a" "
    b
    c
    d
    " ) == ("| a d |\n| --- |") # Three levels deep -- only the outermost table survives. assert to_md( "
    L1
    L2" "
    L3
    " ) == ("| L1 |\n| --- |") # A literal '|' alongside a nested table in the same cell -- the '|' is # still escaped normally; the nested table still contributes nothing. assert to_md( "" "
    a|b
    c
    " ) == ("| a\\|b |\n| --- |") # Only the very first is ever closed assert to_md("
    A
    B
    C
    ") == ( "| A |\n| --- |\n| B |\n| C |" ) # An unclosed inline element () inside a is silently discarded # when a sibling opens; the cell's text is still captured. assert ( to_md( "
    unclosed textsecond cell
    " ) == "| *unclosed text | second cell |\n| --- | --- |" ) # Performance: many rows, each with unclosed / tags (the legacy- # markup shape), large enough to expose quadratic recovery. n = 20000 html = "" + "
    ab" * n + "
    " start = default_timer() out = to_md(html) elapsed = default_timer() - start assert out.count("\n") == n # one line per row, output stays linear # Generous bound -- see the note on the equivalent check above. assert elapsed < 40.0 def test_conversion_html_to_markdown_pre_code_whitespace(): """Test preformatted whitespace.""" def to_md(body): """Wrapper to simplify html-to-markdown conversion tests.""" return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body) # Internal leading whitespace and blank-looking indentation differences # between lines are preserved exactly assert ( to_md("
      leading\n    more leading\nback to zero
    ") == "```\n leading\n more leading\nback to zero\n```" ) # Leading/trailing spaces inside inline survive too assert to_md(" leading spaces ") == "` leading spaces `" # Whitespace is preserved the same way even when nested inside a list item # (which now adds its own 2-space indentation in front. assert to_md("
    •   a\n    b\nc
    ") == ( "- ```\n a\n b\n c\n ```" ) def test_conversion_html_to_markdown_unterminated_pre_code(): """Test
    // left open at end of document."""
    
        def to_md(body):
            """Wrapper to simplify html-to-markdown conversion tests."""
            return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body)
    
        # A 
    // with no closing tag at all is auto-closed and
        # rendered at end of document.
        assert to_md("
    unterminated code") == "```\nunterminated code\n```"
        assert to_md("inline unterminated") == "`inline unterminated`"
        assert to_md("unterminated output") == (
            "```\nunterminated output\n```"
        )
    
        # Still works with real content before the unterminated block.
        assert to_md("text
    code") == "text\n```\ncode\n```"
    
        # A suppressed (non-storing) unterminated frame contributes nothing.
        assert to_md("
    hidden") == ""
    
    
    def test_conversion_html_to_markdown_table_code_pipe():
        """Test '|' inside an inline code span within a table cell."""
    
        def to_md(body):
            """Wrapper to simplify html-to-markdown conversion tests."""
            return convert_between(NotifyFormat.HTML, NotifyFormat.MARKDOWN, body)
    
        # A '|' inside a code span is already literal there.
        assert to_md("
    a|b
    ") == ( "| `a|b` |\n| --- |" ) # A '|' outside any code span in the same cell is still escaped. assert to_md("
    x|a|b|y
    ") == ( "| x\\|`a|b`\\|y |\n| --- |" ) # Literal backtick CHARACTERS typed as ordinary text (not an actual # element) get backslash-escaped by MARKDOWN_ESCAPE like any other. assert to_md("
    `a|b`
    ") == ( "| \\`a\\|b\\` |\n| --- |" ) # A real code span and escaped literal backticks can coexist in the same # cell -- only the real one's '|' stays unescaped. assert to_md( "
    x|y and `lit|eral`
    " ) == ("| `x|y` and \\`lit\\|eral\\` |\n| --- |") # A real code span immediately preceded by an ESCAPED literal backslash (an # even number of '\' characters, i.e. assert to_md( "
    text\\a|b
    " ) == ("| text\\\\`a|b` |\n| --- |") assert to_md( "
    text\\\\a|b
    " ) == ("| text\\\\\\\\`a|b` |\n| --- |") # An unmatched (unbalanced) bare backtick run inside a cell. conv = HTMLMarkdownConverter() assert conv._escape_cell_pipes("a`b|c") == "a`b\\|c" # The shared index skips escape pairs and groups runs by exact width. # A lookup returns None when the requested width does not exist. assert find_unescaped_run(build_backtick_run_index("\\` `"), 0, 1) == 3 assert ( find_unescaped_run(build_backtick_run_index("no backticks here"), 0, 1) is None ) assert ( find_unescaped_run(build_backtick_run_index("``too short"), 0, 3) is None ) # Runs of different widths are indexed separately. assert find_unescaped_run(build_backtick_run_index("`` `"), 0, 1) == 3 # Skip an escaped single backtick before matching a width-two run. assert find_unescaped_run(build_backtick_run_index("\\` ``x"), 0, 2) == 3 # Skip an unescaped run of the wrong width before matching width two. assert find_unescaped_run(build_backtick_run_index("` ``x"), 0, 2) == 2 # A genuine match arbitrarily far from `start` is still found. far = "a" * 200_000 + "`" assert find_unescaped_run(build_backtick_run_index(far), 0, 1) == 200_000 # A cell containing many distinct, non-matching backtick-run lengths must # still resolve in roughly linear time, not quadratic. def adversarial_cell(width_count): body = ["`" * w + "x" for w in range(1, width_count + 1)] body.append("`") return "".join(body) small = adversarial_cell(400) large = adversarial_cell(800) start = default_timer() conv._escape_cell_pipes(small) small_time = default_timer() - start start = default_timer() conv._escape_cell_pipes(large) large_time = default_timer() - start # Double the adversarial width count. assert large_time < small_time * 6 + 0.05 def test_conversion_text_to(): """conversion: Test Text to all types""" response = convert_between( NotifyFormat.TEXT, NotifyFormat.HTML, "Test MessageBody", ) assert ( response == "<title>Test Message</title><body>Body<" "/body>" ) def test_conversion_markdown_to_html(): """conversion: Test markdown to html""" # While this uses the underlining markdown library # what we're testing for are the edge cases we know it doesn't support # hence, `-` (a dash) with the markdown library must be a `*` to work # correctly response = convert_between( NotifyFormat.MARKDOWN, NotifyFormat.HTML, cleandoc(""" ## Some Heading With Data: - Foo - Bar """), ) assert "
  • Foo
  • " in response assert "
  • Bar
  • " in response assert "

    Some Heading

    " in response assert "
    " not in response # if the - follows With Data on the very next line, it's consider to not # requiring indentation response = convert_between( NotifyFormat.MARKDOWN, NotifyFormat.HTML, cleandoc(""" ## Some Heading With Data: - Foo - Bar """), ) # Breaks are added: assert "
    " in response assert "- Foo" in response assert "- Bar" in response # Table formatting response = convert_between( NotifyFormat.MARKDOWN, NotifyFormat.HTML, cleandoc(""" First Header | Second Header -------------- | ------------- Content Cell1 | Content Cell3 Content Cell2 | Content Cell4 """), ) assert "" in response assert "" in response assert "" in response assert "" in response assert "" in response assert "" in response assert "" in response caronc-apprise-182f859/tests/test_decorator_notify.py000066400000000000000000000577721524161175200230630ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from os.path import dirname, join from apprise import ( Apprise, AppriseAsset, AppriseAttachment, AppriseConfig, NotificationManager, common, ) from apprise.decorators import notify from apprise.decorators.base import CustomNotifyPlugin logging.disable(logging.CRITICAL) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() TEST_VAR_DIR = join(dirname(__file__), "var") def test_notify_simple_decoration(): """decorators: Test simple @notify""" # Verify our schema we're about to declare doesn't already exist # in our schema map: assert "utiltest" not in N_MGR verify_obj = {} # Define a function here on the spot @notify(on="utiltest", name="Apprise @notify Decorator Testing") def my_inline_notify_wrapper( body, title, notify_type, attach, *args, **kwargs ): # Test our body (always present) assert isinstance(body, str) # Ensure content is of type utf-8 assert isinstance(body.encode("utf-8"), bytes) if attach: # attachment is always of type AppriseAttach assert isinstance(attach, AppriseAttachment) # Populate our object we can use to validate verify_obj.update( { "body": body, "title": title, "notify_type": notify_type, "attach": attach, "args": args, "kwargs": kwargs, } ) # Now after our hook being inline... it's been loaded assert "utiltest" in N_MGR # Create ourselves an apprise object aobj = Apprise() assert aobj.add("utiltest://") is True assert len(verify_obj) == 0 assert ( aobj.notify( "Hello World", title="My Title", # add some attachments too attach=( join(TEST_VAR_DIR, "apprise-test.gif"), join(TEST_VAR_DIR, "apprise-test.png"), ), ) is True ) # Our content was populated after the notify() call assert len(verify_obj) > 0 assert verify_obj["body"] == "Hello World" assert verify_obj["title"] == "My Title" assert verify_obj["notify_type"] == common.NotifyType.INFO assert isinstance(verify_obj["attach"], AppriseAttachment) assert len(verify_obj["attach"]) == 2 # No format was defined assert "body_format" in verify_obj["kwargs"] assert verify_obj["kwargs"]["body_format"] is None # The meta argument allows us to further parse the URL parameters # specified assert isinstance(verify_obj["kwargs"], dict) assert "meta" in verify_obj["kwargs"] assert isinstance(verify_obj["kwargs"]["meta"], dict) assert len(verify_obj["kwargs"]["meta"]) == 4 assert "tag" in verify_obj["kwargs"]["meta"] assert "asset" in verify_obj["kwargs"]["meta"] assert isinstance(verify_obj["kwargs"]["meta"]["asset"], AppriseAsset) assert verify_obj["kwargs"]["meta"]["schema"] == "utiltest" assert verify_obj["kwargs"]["meta"]["url"] == "utiltest://" # Reset our verify object (so it can be populated again) verify_obj = {} # Send unicode assert aobj.notify("ツ".encode()) is True # Our content was populated after the notify() call assert len(verify_obj) > 0 assert verify_obj["body"] == "ツ" # content comes back as str (utf-8) assert verify_obj["title"] == "" assert verify_obj["notify_type"] == common.NotifyType.INFO assert verify_obj["attach"] is None # No format was defined assert "body_format" in verify_obj["kwargs"] assert verify_obj["kwargs"]["body_format"] is None # The meta argument allows us to further parse the URL parameters # specified assert isinstance(verify_obj["kwargs"], dict) assert "meta" in verify_obj["kwargs"] assert isinstance(verify_obj["kwargs"]["meta"], dict) assert len(verify_obj["kwargs"]["meta"]) == 4 assert "tag" in verify_obj["kwargs"]["meta"] assert "asset" in verify_obj["kwargs"]["meta"] assert isinstance(verify_obj["kwargs"]["meta"]["asset"], AppriseAsset) assert verify_obj["kwargs"]["meta"]["schema"] == "utiltest" assert verify_obj["kwargs"]["meta"]["url"] == "utiltest://" # Reset our verify object (so it can be populated again) verify_obj = {} # Send utf-8 string assert aobj.notify("ツ") is True assert len(verify_obj) > 0 assert verify_obj["body"] == "ツ" # content comes back as str (utf-8) assert verify_obj["title"] == "" assert verify_obj["notify_type"] == common.NotifyType.INFO assert verify_obj["attach"] is None # No format was defined assert "body_format" in verify_obj["kwargs"] assert verify_obj["kwargs"]["body_format"] is None # The meta argument allows us to further parse the URL parameters # specified assert isinstance(verify_obj["kwargs"], dict) assert "meta" in verify_obj["kwargs"] assert isinstance(verify_obj["kwargs"]["meta"], dict) assert len(verify_obj["kwargs"]["meta"]) == 4 assert "tag" in verify_obj["kwargs"]["meta"] assert "asset" in verify_obj["kwargs"]["meta"] assert isinstance(verify_obj["kwargs"]["meta"]["asset"], AppriseAsset) assert verify_obj["kwargs"]["meta"]["schema"] == "utiltest" assert verify_obj["kwargs"]["meta"]["url"] == "utiltest://" # Some cases that will fail internal validation: # - No Body assert aobj.notify("") is False # - Title only assert aobj.notify("", title="hello world!") is False # Reset our verify object (so it can be populated again) verify_obj = {} # No Body but has attachment (valid) assert ( aobj.notify("", attach=(join(TEST_VAR_DIR, "apprise-test.png"),)) is True ) # Our content was populated after the notify() call assert len(verify_obj) > 0 assert verify_obj["body"] == "" assert verify_obj["title"] == "" assert verify_obj["notify_type"] == common.NotifyType.INFO assert isinstance(verify_obj["attach"], AppriseAttachment) assert len(verify_obj["attach"]) == 1 # No format was defined assert "body_format" in verify_obj["kwargs"] assert verify_obj["kwargs"]["body_format"] is None # The meta argument allows us to further parse the URL parameters # specified assert isinstance(verify_obj["kwargs"], dict) assert "meta" in verify_obj["kwargs"] assert isinstance(verify_obj["kwargs"]["meta"], dict) assert len(verify_obj["kwargs"]["meta"]) == 4 assert "tag" in verify_obj["kwargs"]["meta"] assert "asset" in verify_obj["kwargs"]["meta"] assert isinstance(verify_obj["kwargs"]["meta"]["asset"], AppriseAsset) assert verify_obj["kwargs"]["meta"]["schema"] == "utiltest" assert verify_obj["kwargs"]["meta"]["url"] == "utiltest://" # Reset our verify object (so it can be populated again) verify_obj = {} # We'll do another test now assert ( aobj.notify( "Hello Another World", title="My Other Title", body_format=common.NotifyFormat.HTML, notify_type=common.NotifyType.WARNING, ) is True ) # Our content was populated after the notify() call assert len(verify_obj) > 0 assert verify_obj["body"] == "Hello Another World" assert verify_obj["title"] == "My Other Title" assert verify_obj["notify_type"] == common.NotifyType.WARNING # We have no attachments assert verify_obj["attach"] is None # No format was defined assert "body_format" in verify_obj["kwargs"] assert verify_obj["kwargs"]["body_format"] == common.NotifyFormat.HTML # The meta argument allows us to further parse the URL parameters # specified assert "meta" in verify_obj["kwargs"] assert isinstance(verify_obj["kwargs"], dict) assert len(verify_obj["kwargs"]["meta"]) == 4 assert "asset" in verify_obj["kwargs"]["meta"] assert isinstance(verify_obj["kwargs"]["meta"]["asset"], AppriseAsset) assert "tag" in verify_obj["kwargs"]["meta"] assert isinstance(verify_obj["kwargs"]["meta"]["tag"], set) assert verify_obj["kwargs"]["meta"]["schema"] == "utiltest" assert verify_obj["kwargs"]["meta"]["url"] == "utiltest://" assert "notexc" not in N_MGR # Define a function here on the spot @notify(on="notexc", name="Apprise @notify Exception Handling") def my_exception_inline_notify_wrapper( body, title, notify_type, attach, *args, **kwargs ): raise ValueError("An exception was thrown!") assert "notexc" in N_MGR # Create ourselves an apprise object aobj = Apprise() assert aobj.add("notexc://") is True # Isn't handled assert aobj.notify("Exceptions will be thrown!") is False # Tidy N_MGR.remove("utiltest", "notexc") def test_notify_complex_decoration(): """decorators: Test complex @notify""" # Verify our schema we're about to declare doesn't already exist # in our schema map: assert "utiltest" not in N_MGR verify_obj = {} # Define a function here on the spot @notify( on="utiltest://user@myhost:23?key=value&NOT=CaseSensitive", name="Apprise @notify Decorator Testing", ) def my_inline_notify_wrapper( body, title, notify_type, attach, *args, **kwargs ): # Populate our object we can use to validate verify_obj.update( { "body": body, "title": title, "notify_type": notify_type, "attach": attach, "args": args, "kwargs": kwargs, } ) # Now after our hook being inline... it's been loaded assert "utiltest" in N_MGR # Create ourselves an apprise object aobj = Apprise() assert aobj.add("utiltest://") is True assert len(verify_obj) == 0 assert ( aobj.notify( "Hello World", title="My Title", # add some attachments too attach=( join(TEST_VAR_DIR, "apprise-test.gif"), join(TEST_VAR_DIR, "apprise-test.png"), ), ) is True ) # Our content was populated after the notify() call assert len(verify_obj) > 0 assert verify_obj["body"] == "Hello World" assert verify_obj["title"] == "My Title" assert verify_obj["notify_type"] == common.NotifyType.INFO assert isinstance(verify_obj["attach"], AppriseAttachment) assert len(verify_obj["attach"]) == 2 # No format was defined assert "body_format" in verify_obj["kwargs"] assert verify_obj["kwargs"]["body_format"] is None # The meta argument allows us to further parse the URL parameters # specified assert isinstance(verify_obj["kwargs"], dict) assert "meta" in verify_obj["kwargs"] assert isinstance(verify_obj["kwargs"]["meta"], dict) assert "asset" in verify_obj["kwargs"]["meta"] assert isinstance(verify_obj["kwargs"]["meta"]["asset"], AppriseAsset) assert "tag" in verify_obj["kwargs"]["meta"] assert isinstance(verify_obj["kwargs"]["meta"]["tag"], set) assert len(verify_obj["kwargs"]["meta"]) == 8 # We carry all of our default arguments from the @notify's initialization assert verify_obj["kwargs"]["meta"]["schema"] == "utiltest" # Case sensitivity is lost on key assignment and always made lowercase # however value case sensitivity is preseved. # this is the assembled URL based on the combined values of the default # parameters with values provided in the URL (user's configuration) assert verify_obj["kwargs"]["meta"]["url"].startswith( "utiltest://user@myhost:23?" ) # We don't know where they get placed, so just search for their match assert "key=value" in verify_obj["kwargs"]["meta"]["url"] assert "not=CaseSensitive" in verify_obj["kwargs"]["meta"]["url"] # Reset our verify object (so it can be populated again) verify_obj = {} # We'll do another test now aobj = Apprise() assert aobj.add("utiltest://customhost?key=new&key2=another") is True assert len(verify_obj) == 0 # Send our notification assert aobj.notify("Hello World", title="My Title") is True # Our content was populated after the notify() call assert len(verify_obj) > 0 assert verify_obj["body"] == "Hello World" assert verify_obj["title"] == "My Title" assert verify_obj["notify_type"] == common.NotifyType.INFO assert verify_obj["attach"] is None # No format was defined assert "body_format" in verify_obj["kwargs"] assert verify_obj["kwargs"]["body_format"] is None # The meta argument allows us to further parse the URL parameters # specified assert "meta" in verify_obj["kwargs"] assert isinstance(verify_obj["kwargs"], dict) assert len(verify_obj["kwargs"]["meta"]) == 8 # We carry all of our default arguments from the @notify's initialization assert verify_obj["kwargs"]["meta"]["schema"] == "utiltest" # Our host get's correctly over-ridden assert verify_obj["kwargs"]["meta"]["host"] == "customhost" assert verify_obj["kwargs"]["meta"]["user"] == "user" assert verify_obj["kwargs"]["meta"]["port"] == 23 assert isinstance(verify_obj["kwargs"]["meta"]["qsd"], dict) assert len(verify_obj["kwargs"]["meta"]["qsd"]) == 3 # our key is over-ridden assert verify_obj["kwargs"]["meta"]["qsd"]["key"] == "new" # Our other keys are preserved assert verify_obj["kwargs"]["meta"]["qsd"]["not"] == "CaseSensitive" # New keys are added assert verify_obj["kwargs"]["meta"]["qsd"]["key2"] == "another" # Case sensitivity is lost on key assignment and always made lowercase # however value case sensitivity is preseved. # this is the assembled URL based on the combined values of the default # parameters with values provided in the URL (user's configuration) assert verify_obj["kwargs"]["meta"]["url"].startswith( "utiltest://user@customhost:23?" ) # We don't know where they get placed, so just search for their match assert "key=new" in verify_obj["kwargs"]["meta"]["url"] assert "not=CaseSensitive" in verify_obj["kwargs"]["meta"]["url"] assert "key2=another" in verify_obj["kwargs"]["meta"]["url"] # Tidy N_MGR.remove("utiltest") def test_notify_decorator_urls_with_space(): """decorators: URLs containing spaces""" # This is in relation to https://github.com/caronc/apprise/issues/1264 # Verify our schema we're about to declare doesn't already exist # in our schema map: assert "post" not in N_MGR verify_obj = [] @notify(on="posts") def apprise_custom_api_call_wrapper( body, title, notify_type, attach, meta, *args, **kwargs ): # Track what is added verify_obj.append( { "body": body, "title": title, "notify_type": notify_type, "attach": attach, "meta": meta, "args": args, "kwargs": kwargs, } ) assert "posts" in N_MGR # Create ourselves an apprise object aobj = Apprise() # Add our configuration aobj.add("posts://example.com/my endpoint?-token=ab cdefg") # We loaded 1 item assert len(aobj) == 1 # Nothing stored yet in our object assert len(verify_obj) == 0 # Send utf-8 characters assert aobj.notify("ツ".encode(), title="My Title") is True # Service notified assert len(verify_obj) == 1 # Extract our object obj = verify_obj.pop() assert obj.get("body") == "ツ" assert obj.get("title") == "My Title" assert obj.get("notify_type") == "info" assert obj.get("attach") is None assert isinstance(obj.get("args"), tuple) assert len(obj.get("args")) == 0 assert obj.get("kwargs") == {"body_format": None} meta = obj.get("meta") assert isinstance(meta, dict) assert meta.get("schema") == "posts" assert ( meta.get("url") == "posts://example.com/my%20endpoint?-token=ab%20cdefg" ) assert meta.get("qsd") == {"-token": "ab cdefg"} assert meta.get("host") == "example.com" assert meta.get("fullpath") == "/my%20endpoint" assert meta.get("path") == "/" assert meta.get("query") == "my%20endpoint" assert isinstance(meta.get("tag"), set) assert len(meta.get("tag")) == 0 assert isinstance(meta.get("asset"), AppriseAsset) # Tidy N_MGR.remove("posts") def test_notify_multi_instance_decoration(tmpdir): """decorators: Test multi-instance @notify""" # Verify our schema we're about to declare doesn't already exist # in our schema map: assert "multi" not in N_MGR verify_obj = [] # Define a function here on the spot @notify(on="multi", name="Apprise @notify Decorator Testing") def my_inline_notify_wrapper( body, title, notify_type, attach, meta, *args, **kwargs ): assert isinstance(body, str) # Track what is added verify_obj.append( { "body": body, "title": title, "notify_type": notify_type, "attach": attach, "meta": meta, "args": args, "kwargs": kwargs, } ) # Now after our hook being inline... it's been loaded assert "multi" in N_MGR # Prepare our config t = tmpdir.mkdir("multi-test").join("apprise.yml") t.write("""urls: - multi://user1:pass@hostname - multi://user2:pass2@hostname?verify=no """) # Create ourselves a config object ac = AppriseConfig(paths=str(t)) # Create ourselves an apprise object aobj = Apprise() # Add our configuration aobj.add(ac) # The number of configuration files that exist assert len(ac) == 1 # 2 notification endpoints are loaded assert len(ac.servers()) == 2 # Nothing stored yet in our object assert len(verify_obj) == 0 # Send utf-8 characters assert aobj.notify("ツ".encode(), title="My Title") is True assert len(verify_obj) == 2 # Python 3.6 does not nessisarily return list in order # So let's be sure it's sorted by the user id field to make the remaining # checks on this test easy verify_obj = sorted(verify_obj, key=lambda x: x["meta"]["user"]) # Our content was populated after the notify() call obj = verify_obj[0] assert obj["body"] == "ツ" assert obj["title"] == "My Title" assert obj["notify_type"] == common.NotifyType.INFO meta = obj["meta"] assert isinstance(meta, dict) # No format was defined assert "body_format" in obj["kwargs"] assert obj["kwargs"]["body_format"] is None # The meta argument allows us to further parse the URL parameters # specified assert isinstance(obj["kwargs"], dict) assert "asset" in meta assert isinstance(meta["asset"], AppriseAsset) assert "tag" in meta assert isinstance(meta["tag"], set) assert len(meta) == 8 # We carry all of our default arguments from the @notify's initialization assert meta["schema"] == "multi" assert meta["host"] == "hostname" assert meta["user"] == "user1" assert meta["verify"] is True assert "redirect" not in meta # absent -- URLBase inherits asset default assert meta["password"] == "pass" # Verify our URL is correct assert meta["url"] == "multi://user1:pass@hostname" # # Now verify our second URL saved correct # # Our content was populated after the notify() call obj = verify_obj[1] assert obj["body"] == "ツ" assert obj["title"] == "My Title" assert obj["notify_type"] == common.NotifyType.INFO meta = obj["meta"] assert isinstance(meta, dict) # No format was defined assert "body_format" in obj["kwargs"] assert obj["kwargs"]["body_format"] is None # The meta argument allows us to further parse the URL parameters # specified assert isinstance(obj["kwargs"], dict) assert "asset" in meta assert isinstance(meta["asset"], AppriseAsset) assert "tag" in meta assert isinstance(meta["tag"], set) assert len(meta) == 9 # We carry all of our default arguments from the @notify's initialization assert meta["schema"] == "multi" assert meta["host"] == "hostname" assert meta["user"] == "user2" assert meta["password"] == "pass2" assert meta["verify"] is False assert "redirect" not in meta # absent -- URLBase inherits asset default assert meta["qsd"]["verify"] == "no" # Verify our URL is correct assert meta["url"] == "multi://user2:pass2@hostname?verify=no" # Tidy N_MGR.remove("multi") def test_notify_yaml_priority_over_qsd(): """ decorators: YAML tokens take priority over qsd for @notify plugins. YAML > qsd > URL path must hold for ALL plugin types, including @notify decorator plugins. Previously, @notify plugins kept qsd intact for the second post_process_parse_url_results() call, which allowed qsd to silently overwrite YAML-token values. The fix re-applies YAML tokens after post_process to restore priority. Also verifies qsd is still preserved in the meta dict (for raw inspection) and that qsd still wins over URL-path when no YAML token is present for a field. """ from apprise.config import ConfigBase assert "notifyverify" not in N_MGR @notify(on="notifyverify", name="Test YAML priority @notify") def _handler(body, title, notify_type, attach, meta, *args, **kwargs): pass assert "notifyverify" in N_MGR # URL has ?verify=no&user=qsduser; YAML sibling says verify: yes # and user: yamluser. YAML must win for both. result, _ = ConfigBase.config_parse_yaml(""" urls: - notifyverify://urluser:urlpass@hostname?verify=no&user=qsduser: verify: yes user: yamluser """) assert len(result) == 1 meta = result[0]._default_args # qsd must be preserved intact for downstream meta inspection assert isinstance(meta.get("qsd"), dict) assert meta["qsd"].get("verify") == "no" assert meta["qsd"].get("user") == "qsduser" # YAML tokens must win over qsd assert meta["verify"] is True assert meta["user"] == "yamluser" # When no YAML token is present for a field, qsd still wins over # the URL-path value (qsd > URL path). result2, _ = ConfigBase.config_parse_yaml(""" urls: - notifyverify://urluser:urlpass@hostname?verify=no: """) assert len(result2) == 1 meta2 = result2[0]._default_args assert meta2["verify"] is False # qsd wins over URLBase default assert meta2["user"] == "urluser" # URL-path value (no qsd for user) # Tidy N_MGR.remove("notifyverify") def test_custom_notify_plugin_decoration(): """decorators: CustomNotifyPlugin testing""" CustomNotifyPlugin() caronc-apprise-182f859/tests/test_escapes.py000066400000000000000000000174211524161175200211170ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads from unittest import mock import requests import apprise @mock.patch("requests.request") def test_apprise_interpret_escapes(mock_request): """ API: Apprise() interpret-escape tests """ # Prepare Mock mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok # Default Escapes interpretation Mode is set to disable asset = apprise.AppriseAsset() assert asset.interpret_escapes is False # Load our asset a = apprise.Apprise(asset=asset) # add a test server assert a.add("json://localhost") is True # Our servers should carry this flag assert a[0].asset.interpret_escapes is False # Send notification assert a.notify("ab\\ncd") is True # Test our call count assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" # content is not escaped assert loads(details[1]["data"]).get("message", "") == "ab\\ncd" # Reset mock_request.reset_mock() # Send notification and provide override: assert a.notify("ab\\ncd", interpret_escapes=True) is True # Test our call count assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" # content IS escaped assert loads(details[1]["data"]).get("message", "") == "ab\ncd" # Reset mock_request.reset_mock() # # Now we test the reverse setup where we set the AppriseAsset # object to True but force it off through the notify() calls # # Default Escapes interpretation Mode is set to disable asset = apprise.AppriseAsset(interpret_escapes=True) assert asset.interpret_escapes is True # Load our asset a = apprise.Apprise(asset=asset) # add a test server assert a.add("json://localhost") is True # Our servers should carry this flag assert a[0].asset.interpret_escapes is True # Send notification assert a.notify("ab\\ncd") is True # Test our call count assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" # content IS escaped assert loads(details[1]["data"]).get("message", "") == "ab\ncd" # Reset mock_request.reset_mock() # Send notification and provide override: assert a.notify("ab\\ncd", interpret_escapes=False) is True # Test our call count assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" # content is NOT escaped assert loads(details[1]["data"]).get("message", "") == "ab\\ncd" @mock.patch("requests.request") def test_apprise_escaping(mock_request): """ API: Apprise() escaping tests """ a = apprise.Apprise() response = mock.Mock() response.content = "" response.status_code = requests.codes.ok mock_request.return_value = response # Create ourselves a test object to work with a.add("json://localhost") # Escape our content assert a.notify( title="\\r\\ntitle\\r\\n", body="\\r\\nbody\\r\\n", interpret_escapes=True, ) # Verify our content was escaped correctly assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" result = loads(details[1]["data"]) assert result["title"] == "title" assert result["message"] == "\r\nbody" # Reset our mock object mock_request.reset_mock() # # Support Specially encoded content: # # Escape our content assert a.notify( # Google Translated to Arabic: "Let's make the world a better place." title="Ψ―ΨΉΩˆΩ†Ψ§ Ω†Ψ¬ΨΉΩ„ Ψ§Ω„ΨΉΨ§Ω„Ω… Ω…ΩƒΨ§Ω†Ψ§ أفآل.\\r\\t\\t\\n\\r\\n", # Google Translated to Hungarian: "One line of code at a time.' body="Egy sor kΓ³dot egyszerre.\\r\\n\\r\\r\\n", # Our Escape Flag interpret_escapes=True, ) # Verify our content was escaped correctly assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" result = loads(details[1]["data"]) assert result["title"] == "Ψ―ΨΉΩˆΩ†Ψ§ Ω†Ψ¬ΨΉΩ„ Ψ§Ω„ΨΉΨ§Ω„Ω… Ω…ΩƒΨ§Ω†Ψ§ أفآل." assert result["message"] == "Egy sor kΓ³dot egyszerre." # Error handling # # We can't escape the content below assert a.notify(title=None, body=4, interpret_escapes=True) is False assert a.notify(title=4, body=None, interpret_escapes=True) is False assert ( a.notify(title=object(), body=False, interpret_escapes=True) is False ) assert ( a.notify(title=False, body=object(), interpret_escapes=True) is False ) # We support bytes assert ( a.notify( title=b"byte title", body=b"byte body", interpret_escapes=True ) is True ) # However they're escaped as 'utf-8' by default unless we tell Apprise # otherwise # Now test hebrew types (outside of default utf-8) # Χ›Χ•ΧͺΧ¨Χͺ Χ Χ€ΧœΧΧ” translates to 'A wonderful title' # Χ–Χ• Χ”Χ•Χ“Χ’Χ” translates to 'This is a notification' title = "Χ›Χ•ΧͺΧ¨Χͺ Χ Χ€ΧœΧΧ”".encode("ISO-8859-8") body = "[_[Χ–Χ• Χ”Χ•Χ“Χ’Χ”](http://localhost)_".encode("ISO-8859-8") assert a.notify(title=title, body=body, interpret_escapes=True) is False # However if we let Apprise know in advance the encoding, it will handle # it for us asset = apprise.AppriseAsset(encoding="ISO-8859-8") a = apprise.Apprise(asset=asset) # Create ourselves a test object to work with a.add("json://localhost") assert a.notify(title=title, body=body, interpret_escapes=True) is True # We'll restore our configuration back to how it was now a = apprise.Apprise() a.add("json://localhost") # The body is proessed first, so the errors thrown above get tested on # the body only. Now we run similar tests but only make the title # bad and always mark the body good assert a.notify(title=None, body="valid", interpret_escapes=True) is True assert a.notify(title=4, body="valid", interpret_escapes=True) is False assert ( a.notify(title=object(), body="valid", interpret_escapes=True) is False ) assert a.notify(title=False, body="valid", interpret_escapes=True) is True # Bytes are supported assert ( a.notify(title=b"byte title", body="valid", interpret_escapes=True) is True ) caronc-apprise-182f859/tests/test_logger.py000066400000000000000000000336671524161175200207650ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import re import sys from unittest import mock import pytest import requests from apprise import Apprise, AppriseAsset, URLBase # Disable logging for a cleaner testing output from apprise.logger import LogCapture, logger, logging def test_apprise_logger(): """ API: Apprise() Logger """ # Ensure we're not running in a disabled state logging.disable(logging.NOTSET) # Set our log level URLBase.logger.setLevel(logging.DEPRECATE + 1) # Deprication will definitely not trigger URLBase.logger.deprecate("test") # Verbose Debugging is not on at this point URLBase.logger.trace("test") # Set both logging entries on URLBase.logger.setLevel(logging.TRACE) # Deprication will definitely trigger URLBase.logger.deprecate("test") # Verbose Debugging will activate URLBase.logger.trace("test") # Disable Logging logging.disable(logging.CRITICAL) def test_apprise_log_memory_captures(): """ API: Apprise() Log Memory Captures """ # Ensure we're not running in a disabled state logging.disable(logging.NOTSET) logger.setLevel(logging.CRITICAL) with LogCapture(level=logging.TRACE) as stream: logger.trace("trace") logger.debug("debug") logger.info("info") logger.warning("warning") logger.error("error") logger.deprecate("deprecate") logs = re.split(r"\r*\n", stream.getvalue().rstrip()) # We have a log entry for each of the 6 logs we generated above assert "trace" in stream.getvalue() assert "debug" in stream.getvalue() assert "info" in stream.getvalue() assert "warning" in stream.getvalue() assert "error" in stream.getvalue() assert "deprecate" in stream.getvalue() assert len(logs) == 6 # Verify that we did not lose our effective log level even though # the above steps the level up for the duration of the capture assert logger.getEffectiveLevel() == logging.CRITICAL logger.setLevel(logging.TRACE) with LogCapture(level=logging.DEBUG) as stream: logger.trace("trace") logger.debug("debug") logger.info("info") logger.warning("warning") logger.error("error") logger.deprecate("deprecate") # We have a log entry for 5 of the log entries we generated above # There will be no 'trace' entry assert "trace" not in stream.getvalue() assert "debug" in stream.getvalue() assert "info" in stream.getvalue() assert "warning" in stream.getvalue() assert "error" in stream.getvalue() assert "deprecate" in stream.getvalue() logs = re.split(r"\r*\n", stream.getvalue().rstrip()) assert len(logs) == 5 # Verify that we did not lose our effective log level even though # the above steps the level up for the duration of the capture assert logger.getEffectiveLevel() == logging.TRACE logger.setLevel(logging.ERROR) with LogCapture(level=logging.WARNING) as stream: logger.trace("trace") logger.debug("debug") logger.info("info") logger.warning("warning") logger.error("error") logger.deprecate("deprecate") # We have a log entry for 3 of the log entries we generated above # There will be no 'trace', 'debug', or 'info' entry assert "trace" not in stream.getvalue() assert "debug" not in stream.getvalue() assert "info" not in stream.getvalue() assert "warning" in stream.getvalue() assert "error" in stream.getvalue() assert "deprecate" in stream.getvalue() logs = re.split(r"\r*\n", stream.getvalue().rstrip()) assert len(logs) == 3 # Set a global level of ERROR logger.setLevel(logging.ERROR) # Use the default level of None (by not specifying one); we then # use whatever has been defined globally with LogCapture() as stream: logger.trace("trace") logger.debug("debug") logger.info("info") logger.warning("warning") logger.error("error") logger.deprecate("deprecate") assert "trace" not in stream.getvalue() assert "debug" not in stream.getvalue() assert "info" not in stream.getvalue() assert "warning" not in stream.getvalue() assert "error" in stream.getvalue() assert "deprecate" in stream.getvalue() logs = re.split(r"\r*\n", stream.getvalue().rstrip()) assert len(logs) == 2 # Verify that we did not lose our effective log level assert logger.getEffectiveLevel() == logging.ERROR with LogCapture(level=logging.TRACE) as stream: logger.trace("trace") logger.debug("debug") logger.info("info") logger.warning("warning") logger.error("error") logger.deprecate("deprecate") # We have a log entry for each of the 6 logs we generated above assert "trace" in stream.getvalue() assert "debug" in stream.getvalue() assert "info" in stream.getvalue() assert "warning" in stream.getvalue() assert "error" in stream.getvalue() assert "deprecate" in stream.getvalue() logs = re.split(r"\r*\n", stream.getvalue().rstrip()) assert len(logs) == 6 # Verify that we did not lose our effective log level even though # the above steps the level up for the duration of the capture assert logger.getEffectiveLevel() == logging.ERROR # Test capture where our notification throws an unhandled exception obj = Apprise.instantiate("json://user:password@example.com") with ( mock.patch("requests.request", side_effect=NotImplementedError()), pytest.raises(NotImplementedError), # Our exception gets caught in side our with() block # and although raised, all graceful handling of the log # is reverted as it was LogCapture(level=logging.TRACE) as stream, ): obj.send("hello world") # Disable Logging logging.disable(logging.CRITICAL) def test_apprise_log_file_captures(tmpdir): """ API: Apprise() Log File Captures """ # Ensure we're not running in a disabled state logging.disable(logging.NOTSET) log_file = tmpdir.join("capture.log") assert not os.path.isfile(str(log_file)) logger.setLevel(logging.CRITICAL) with LogCapture(path=str(log_file), level=logging.TRACE) as fp: # The file will exit now assert os.path.isfile(str(log_file)) logger.trace("trace") logger.debug("debug") logger.info("info") logger.warning("warning") logger.error("error") logger.deprecate("deprecate") content = fp.read().rstrip() logs = re.split(r"\r*\n", content) # We have a log entry for each of the 6 logs we generated above assert "trace" in content assert "debug" in content assert "info" in content assert "warning" in content assert "error" in content assert "deprecate" in content assert len(logs) == 6 # The file is automatically cleaned up afterwards assert not os.path.isfile(str(log_file)) # Verify that we did not lose our effective log level even though # the above steps the level up for the duration of the capture assert logger.getEffectiveLevel() == logging.CRITICAL logger.setLevel(logging.TRACE) with LogCapture(path=str(log_file), level=logging.DEBUG) as fp: # The file will exit now assert os.path.isfile(str(log_file)) logger.trace("trace") logger.debug("debug") logger.info("info") logger.warning("warning") logger.error("error") logger.deprecate("deprecate") content = fp.read().rstrip() logs = re.split(r"\r*\n", content) # We have a log entry for 5 of the log entries we generated above # There will be no 'trace' entry assert "trace" not in content assert "debug" in content assert "info" in content assert "warning" in content assert "error" in content assert "deprecate" in content assert len(logs) == 5 # Concurrent file access is not possible on Windows. # PermissionError: [WinError 32] The process cannot access the file # because it is being used by another process. if sys.platform != "win32": # Remove our file before we exit the with clause # this causes our delete() call to throw gracefully inside os.unlink(str(log_file)) # Verify file is gone assert not os.path.isfile(str(log_file)) # Verify that we did not lose our effective log level even though # the above steps the level up for the duration of the capture assert logger.getEffectiveLevel() == logging.TRACE logger.setLevel(logging.ERROR) with LogCapture( path=str(log_file), delete=False, level=logging.WARNING ) as fp: # Verify exists assert os.path.isfile(str(log_file)) logger.trace("trace") logger.debug("debug") logger.info("info") logger.warning("warning") logger.error("error") logger.deprecate("deprecate") content = fp.read().rstrip() logs = re.split(r"\r*\n", content) # We have a log entry for 3 of the log entries we generated above # There will be no 'trace', 'debug', or 'info' entry assert "trace" not in content assert "debug" not in content assert "info" not in content assert "warning" in content assert "error" in content assert "deprecate" in content assert len(logs) == 3 # Verify the file still exists (because delete was set to False) assert os.path.isfile(str(log_file)) # remove it now os.unlink(str(log_file)) # Enure it's been removed assert not os.path.isfile(str(log_file)) # Set a global level of ERROR logger.setLevel(logging.ERROR) # Test case where we can't open the file with ( mock.patch("builtins.open", side_effect=OSError()), # Use the default level of None (by not specifying one); we then # use whatever has been defined globally pytest.raises(OSError), LogCapture(path=str(log_file)) as fp, ): # we'll never get here because we'll fail to open the file pass # Disable Logging logging.disable(logging.CRITICAL) @mock.patch("requests.request") def test_apprise_secure_logging(mock_request): """ API: Apprise() secure logging tests """ # Ensure we're not running in a disabled state logging.disable(logging.NOTSET) logger.setLevel(logging.CRITICAL) # Prepare Mock mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok # Default Secure Logging is set to enabled asset = AppriseAsset() assert asset.secure_logging is True # Load our asset a = Apprise(asset=asset) with LogCapture(level=logging.DEBUG) as stream: # add a test server assert a.add("json://user:pass1$-3!@localhost") is True # Our servers should carry this flag assert a[0].asset.secure_logging is True logs = re.split(r"\r*\n", stream.getvalue().rstrip()) assert len(logs) == 1 entry = re.split(r"\s-\s", logs[0]) assert len(entry) == 3 assert entry[1] == "DEBUG" assert entry[2].startswith( "Loaded JSON URL: json://user:****@localhost/" ) # Send notification assert a.notify("test") is True # Test our call count assert mock_request.call_count == 1 # Reset mock_request.reset_mock() # Now we test the reverse configuration and turn off # secure logging. # Default Secure Logging is set to disable asset = AppriseAsset(secure_logging=False) assert asset.secure_logging is False # Load our asset a = Apprise(asset=asset) with LogCapture(level=logging.DEBUG) as stream: # add a test server assert a.add("json://user:pass1$-3!@localhost") is True # Our servers should carry this flag assert a[0].asset.secure_logging is False logs = re.split(r"\r*\n", stream.getvalue().rstrip()) assert len(logs) == 1 entry = re.split(r"\s-\s", logs[0]) assert len(entry) == 3 assert entry[1] == "DEBUG" # Note that our password is no longer escaped (it is however # url encoded) assert entry[2].startswith( "Loaded JSON URL: json://user:pass1%24-3%21@localhost/" ) # Disable Logging logging.disable(logging.CRITICAL) caronc-apprise-182f859/tests/test_notification_manager.py000066400000000000000000001024401524161175200236500ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc # Disable logging for a cleaner testing output import logging import re import sys import threading import types import pytest from apprise import Apprise, NotificationManager from apprise.logger import LogCapture from apprise.plugins import NotifyBase logging.disable(logging.CRITICAL) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() def test_notification_manager_general(): """ N_MGR: Notification Manager General testing """ # Clear our set so we can test init calls N_MGR.unload_modules() assert isinstance(N_MGR.schemas(), list) assert len(N_MGR.schemas()) > 0 N_MGR.unload_modules(disable_native=True) assert isinstance(N_MGR.schemas(), list) assert len(N_MGR.schemas()) == 0 N_MGR.unload_modules() assert len(N_MGR) > 0 N_MGR.unload_modules() iter(N_MGR) iter(N_MGR) N_MGR.unload_modules() assert bool(N_MGR) is False assert len(list(iter(N_MGR))) > 0 assert bool(N_MGR) N_MGR.unload_modules() assert isinstance(N_MGR.plugins(), types.GeneratorType) assert len(list(N_MGR.plugins())) > 0 N_MGR.unload_modules(disable_native=True) assert isinstance(N_MGR.plugins(), types.GeneratorType) assert len(list(N_MGR.plugins())) == 0 N_MGR.unload_modules() assert isinstance(N_MGR["json"](host="localhost"), NotifyBase) N_MGR.unload_modules() assert "json" in N_MGR # Define our good:// url class DisabledNotification(NotifyBase): # Always disabled enabled = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True def url(self, **kwargs): # Support url() function return "" # Define our good:// url class GoodNotification(NotifyBase): secure_protocol = ("good", "goods") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True def url(self, **kwargs): # Support url() function return "" N_MGR.unload_modules() assert N_MGR.add(GoodNotification) assert "good" in N_MGR assert "goods" in N_MGR assert "abcd" not in N_MGR assert "xyz" not in N_MGR N_MGR.unload_modules() assert N_MGR.add(GoodNotification, "abcd") assert "good" in N_MGR assert "goods" in N_MGR assert "abcd" in N_MGR assert "xyz" not in N_MGR N_MGR.unload_modules() assert N_MGR.add(GoodNotification, ["abcd", "xYz"]) assert "good" in N_MGR assert "goods" in N_MGR assert "abcd" in N_MGR # Lower case assert "xyz" in N_MGR N_MGR.unload_modules() # Not going to work; schemas must be a list of string assert N_MGR.add(GoodNotification, object) is False N_MGR.unload_modules() with pytest.raises(KeyError): del N_MGR["good"] N_MGR["good"] = GoodNotification del N_MGR["good"] N_MGR.unload_modules() N_MGR["good"] = GoodNotification assert N_MGR["good"].enabled is True N_MGR.enable_only("json", "xml") assert N_MGR["good"].enabled is False assert N_MGR["json"].enabled is True assert N_MGR["jsons"].enabled is True assert N_MGR["xml"].enabled is True assert N_MGR["xmls"].enabled is True # Only two plugins are enabled assert len(list(N_MGR.plugins(include_disabled=False))) == 2 N_MGR.enable_only("good") assert N_MGR["good"].enabled is True assert N_MGR["json"].enabled is False assert N_MGR["jsons"].enabled is False assert N_MGR["xml"].enabled is False assert N_MGR["xmls"].enabled is False assert len(list(N_MGR.plugins(include_disabled=False))) == 1 N_MGR.unload_modules() N_MGR["disabled"] = DisabledNotification assert N_MGR["disabled"].enabled is False N_MGR.enable_only("disabled") # Can't enable items that aren't supposed to be: assert N_MGR["disabled"].enabled is False N_MGR["good"] = GoodNotification assert N_MGR["good"].enabled is True # You can't disable someething already disabled N_MGR.disable("disabled") assert N_MGR["disabled"].enabled is False N_MGR.unload_modules() N_MGR.enable_only("form", "xml") for schema in N_MGR.schemas(include_disabled=False): assert re.match(r"^(form|xml)s?$", schema, re.IGNORECASE) is not None N_MGR.unload_modules() assert N_MGR["form"].enabled is True assert N_MGR["xml"].enabled is True assert N_MGR["json"].enabled is True N_MGR.enable_only("form", "xml") assert N_MGR["form"].enabled is True assert N_MGR["xml"].enabled is True assert N_MGR["json"].enabled is False N_MGR.disable("invalid", "xml") assert N_MGR["form"].enabled is True assert N_MGR["xml"].enabled is False assert N_MGR["json"].enabled is False # Detect that our json object is enabled with pytest.raises(KeyError): # The below can not be indexed N_MGR["invalid"] N_MGR.unload_modules() N_MGR.disable("invalid", "xml") N_MGR.unload_modules() assert N_MGR["json"].enabled is True # Work with an empty module tree N_MGR.unload_modules(disable_native=True) with pytest.raises(KeyError): # The below can not be indexed N_MGR["good"] N_MGR.unload_modules() assert "hello" not in N_MGR assert "good" not in N_MGR assert "goods" not in N_MGR N_MGR["hello"] = GoodNotification assert "hello" in N_MGR assert "good" in N_MGR assert "goods" in N_MGR N_MGR.unload_modules() N_MGR["good"] = GoodNotification with pytest.raises(KeyError): # Can not assign the value again without getting a Conflict N_MGR["good"] = GoodNotification N_MGR.unload_modules() N_MGR.remove("good", "invalid") assert "good" not in N_MGR assert "goods" not in N_MGR def test_notification_manager_add_force_overrides_schema_without_unload(): """Verify add(force=True) overrides existing schema without unloading.""" import sys from apprise.plugins import N_MGR class NotifyDiscordCustom: """A minimal custom discord override used for testing.""" protocol = "discord" secure_protocol = None service_name = "Discord (Custom)" def __init__(self, *args, **kwargs): pass def send(self, *args, **kwargs): return True def url(self, **kwargs): return "discord://" # Ensure native modules are loaded N_MGR.unload_modules() N_MGR.load_modules() # Confirm 'discord' is available and capture its module name assert "discord" in N_MGR native_plugin = N_MGR["discord"] native_module = native_plugin.__module__ assert native_module in sys.modules # A normal add should fail due to the conflict assert N_MGR.add(NotifyDiscordCustom, schemas="discord") is False # A forced add should succeed and must not unload the native module assert ( N_MGR.add(NotifyDiscordCustom, schemas="discord", force=True) is True ) assert N_MGR["discord"] is NotifyDiscordCustom assert native_module in sys.modules def test_notification_manager_module_loading(tmpdir): """ N_MGR: Notification Manager Module Loading """ # Handle loading modules twice (they gracefully handle not loading more in # memory then needed) N_MGR.load_modules() N_MGR.load_modules() # # Thread Testing # # This tests against a racing condition when the modules have not been # loaded. When multiple instances of Apprise are all instantiated, # the loading of the modules will occur for each instance if detected # having not been previously done, this tests that we can dynamically # support the loading of modules once whe multiple instances to apprise # are instantiated. thread_count = 10 def thread_test(result, no): """Load our apprise object with valid URLs and store our result.""" apobj = Apprise() result[no] = ( apobj.add("json://localhost") and apobj.add("form://localhost") and apobj.add("xml://localhost") ) # Unload our modules N_MGR.unload_modules() # Prepare threads to load results = [None] * thread_count threads = [ threading.Thread(target=thread_test, args=(results, no)) for no in range(thread_count) ] # Verify we can safely load our modules in a thread safe environment for t in threads: t.start() for t in threads: t.join() # Verify we loaded our urls in all threads successfully for result in results: assert result is True def test_notification_manager_decorators(tmpdir): """ N_MGR: Notification Manager Decorator testing """ # Prepare ourselves a file to work with notify_hook = tmpdir.mkdir("goodmodule").join("__init__.py") notify_hook.write( cleandoc(""" from apprise.decorators import notify # We want to trigger on anyone who configures a call to clihook:// @notify(on="clihooka") def mywrapper(body, title, notify_type, *args, **kwargs): # A simple test - print to screen print("A {}: {} - {}".format(notify_type, title, body)) # No return (so a return of None) get's translated to True # Define another in the same file; uppercase goes to lower @notify(on="CLIhookb") def mywrapper(body, title, notify_type, *args, **kwargs): # A simple test - print to screen print("B {}: {} - {}".format(notify_type, title, body)) # No return (so a return of None) get's translated to True """) ) N_MGR.module_detection(str(notify_hook)) assert "clihooka" in N_MGR assert "clihookb" in N_MGR N_MGR.unload_modules() assert "clihooka" not in N_MGR assert "clihookb" not in N_MGR N_MGR.module_detection(str(notify_hook)) assert "clihooka" in N_MGR assert "clihookb" in N_MGR del N_MGR["clihookb"] assert "clihooka" in N_MGR assert "clihookb" not in N_MGR del N_MGR["clihooka"] assert "clihooka" not in N_MGR assert "clihookb" not in N_MGR # Prepare ourselves a file to work with notify_base = tmpdir.mkdir("plugins") notify_test = notify_base.join("NotifyTest.py") notify_test.write( cleandoc(""" # # Bare Minimum Valid Object # from apprise.plugins import NotifyBase from apprise.common import NotifyType class NotifyTest(NotifyBase): service_name = 'Test' # The services URL service_url = 'https://github.com/caronc/apprise/' # Define our protocol secure_protocol = 'myservice' # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://appriseit.com/services/myservice/' # Define object templates templates = ( '{schema}://', ) def __init__(self, **kwargs): super().__init__(**kwargs) def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): return True def url(self): return 'myservice://' """) ) assert "myservice" not in N_MGR N_MGR.load_modules(path=str(notify_base)) assert "myservice" in N_MGR del N_MGR["myservice"] assert "myservice" not in N_MGR assert "myservice" not in N_MGR N_MGR.load_modules(path=str(notify_base)) # It's still not loaded because the path has already been scanned assert "myservice" not in N_MGR N_MGR.load_modules(path=str(notify_base), force=True) assert "myservice" in N_MGR # Double load will test section of code that prevents a notification # From reloading if previously already loaded N_MGR.load_modules(path=str(notify_base)) # Our item is still loaded as expected assert "myservice" in N_MGR # Simple test to make sure we can handle duplicate entries loaded N_MGR.load_modules(path=str(notify_base), force=True) N_MGR.load_modules(path=str(notify_base), force=True) def test_notification_manager_add_force_returns_false_if_conflict_persists( monkeypatch: pytest.MonkeyPatch, ) -> None: """ N_MGR.add(force=True) must still fail safely if conflicts persist after the attempted unmap. This explicitly targets the defensive re-check branch in add(). """ # Ensure native modules are loaded and we have a known schema to collide on N_MGR.unload_modules() N_MGR.load_modules() assert "discord" in N_MGR class NotifyDiscordCustom: protocol = "discord" secure_protocol = None service_name = "Discord (Custom)" def __init__(self, *args, **kwargs) -> None: pass def send(self, *args, **kwargs) -> bool: return True def url(self, **kwargs) -> str: return "discord://" # Simulate an unmap failure by making remove() a no-op. # This ensures the conflict remains on the re-check and triggers the # warning + return False branch. def _noop_remove(*args, **kwargs) -> None: return None monkeypatch.setattr(N_MGR, "remove", _noop_remove) assert ( N_MGR.add(NotifyDiscordCustom, schemas="discord", force=True) is False ) # --------------------------------------------------------------------------- # Helpers shared by the runtime_deps / eviction tests # --------------------------------------------------------------------------- def _make_dep_plugin(schema, *deps): """Return a minimal NotifyBase subclass that declares *deps in runtime_deps() and is always enabled.""" class _Plugin(NotifyBase): protocol = schema enabled = True def notify(self, *args, **kwargs): return True def url(self, **kwargs): return f"{schema}://" @staticmethod def runtime_deps(): return deps _Plugin.__name__ = f"Notify_{schema}" return _Plugin # --------------------------------------------------------------------------- def test_manager_dep_counter_built_on_load(): """_dep_counter is populated after native modules are loaded.""" N_MGR.unload_modules() # Trigger native load assert len(N_MGR.schemas()) > 0 # The counter must exist and be a dict assert isinstance(N_MGR._dep_counter, dict) # Every library declared by an enabled plugin must appear in the counter for plugin in N_MGR.plugins(include_disabled=False): for lib in plugin.runtime_deps(): assert lib in N_MGR._dep_counter assert N_MGR._dep_counter[lib] >= 1 def test_manager_dep_counter_custom_plugins(): """_dep_counter reflects only enabled plugins with runtime_deps.""" N_MGR.unload_modules() PlugA = _make_dep_plugin("plugA", "fakelib1") PlugB = _make_dep_plugin("plugB", "fakelib1", "fakelib2") PlugC = _make_dep_plugin("plugC") # no deps N_MGR["plugA"] = PlugA N_MGR["plugB"] = PlugB N_MGR["plugC"] = PlugC # Rebuild counter to include newly registered plugins N_MGR._build_dep_counter() assert N_MGR._dep_counter.get("fakelib1", 0) >= 2 assert N_MGR._dep_counter.get("fakelib2", 0) >= 1 # PlugC has no deps so it contributes nothing assert "fakelib_none" not in N_MGR._dep_counter def test_manager_disable_decrements_counter(): """disable() decrements the dep counter for the disabled plugin's libs.""" N_MGR.unload_modules() PlugA = _make_dep_plugin("solodep", "uniquelib99") N_MGR["solodep"] = PlugA N_MGR._build_dep_counter() assert N_MGR._dep_counter.get("uniquelib99", 0) >= 1 before = N_MGR._dep_counter["uniquelib99"] N_MGR.disable("solodep") assert N_MGR._dep_counter.get("uniquelib99", 0) == before - 1 def test_manager_shared_dep_not_evicted_until_all_disabled(): """A shared library is not evicted until every plugin using it is off.""" import sys N_MGR.unload_modules() N_MGR.evict_on_disable = True # Two plugins share the same fake library PlugX = _make_dep_plugin("sharedX", "sharedfakelib") PlugY = _make_dep_plugin("sharedY", "sharedfakelib") # Inject a fake module so eviction has something to remove fake_mod = types.ModuleType("sharedfakelib") sys.modules["sharedfakelib"] = fake_mod N_MGR["sharedX"] = PlugX N_MGR["sharedY"] = PlugY N_MGR._build_dep_counter() assert N_MGR._dep_counter.get("sharedfakelib", 0) >= 2 # Disable one β€” library should still be present (counter > 0) N_MGR.disable("sharedX") assert "sharedfakelib" in sys.modules # Disable the other β€” counter should hit 0 and library gets evicted N_MGR.disable("sharedY") assert "sharedfakelib" not in sys.modules N_MGR.evict_on_disable = False def test_manager_evict_on_disable_false_by_default(): """evict_on_disable defaults to False; no eviction even at counter 0.""" import sys N_MGR.unload_modules() assert N_MGR.evict_on_disable is False PlugZ = _make_dep_plugin("nodiscard", "nodiscardlib") fake_mod = types.ModuleType("nodiscardlib") sys.modules["nodiscardlib"] = fake_mod N_MGR["nodiscard"] = PlugZ N_MGR._build_dep_counter() N_MGR.disable("nodiscard") # Library is still present because evict_on_disable is False assert "nodiscardlib" in sys.modules # Cleanup del sys.modules["nodiscardlib"] def test_manager_evict_library_graceful_keyerror(): """_evict_library handles a missing sys.modules key gracefully.""" import sys N_MGR.unload_modules() # Put a module in, then remove it manually before eviction to simulate # a race or double-eviction scenario fake_mod = types.ModuleType("ghostlib") sys.modules["ghostlib"] = fake_mod del sys.modules["ghostlib"] # Should not raise even though the key is gone N_MGR._evict_library("ghostlib") def test_manager_evict_library_removes_submodules(): """_evict_library removes the top-level package and all its submodules.""" import sys N_MGR.unload_modules() root = types.ModuleType("mypkg") sub = types.ModuleType("mypkg.sub") deep = types.ModuleType("mypkg.sub.deep") sys.modules["mypkg"] = root sys.modules["mypkg.sub"] = sub sys.modules["mypkg.sub.deep"] = deep N_MGR._evict_library("mypkg") assert "mypkg" not in sys.modules assert "mypkg.sub" not in sys.modules assert "mypkg.sub.deep" not in sys.modules def test_manager_enable_only_updates_dep_counter(): """enable_only() decrements counters for disabled plugins. We isolate this test to fake libraries only to avoid evicting real native extensions (e.g. cryptography/PyO3) that cannot be safely re-imported in the same interpreter session. """ import sys N_MGR.unload_modules() N_MGR.evict_on_disable = True PlugKeep = _make_dep_plugin("keepme2", "keeplib2") PlugDrop = _make_dep_plugin("dropme2", "droplib2") fake_keep = types.ModuleType("keeplib2") fake_drop = types.ModuleType("droplib2") sys.modules["keeplib2"] = fake_keep sys.modules["droplib2"] = fake_drop N_MGR["keepme2"] = PlugKeep N_MGR["dropme2"] = PlugDrop # Seed the dep counter for only our two fake plugins so that disabling # dropme2 drives the droplib2 counter to zero β€” without touching real # native-library plugins loaded alongside them. N_MGR._dep_counter["keeplib2"] = 1 N_MGR._dep_counter["droplib2"] = 1 # Disable dropme2 directly; keepme2 stays enabled. N_MGR.disable("dropme2") assert N_MGR["keepme2"].enabled is True assert N_MGR["dropme2"].enabled is False assert "keeplib2" in sys.modules # counter still 1 β€” not evicted assert "droplib2" not in sys.modules # counter hit 0 β€” evicted # Cleanup del sys.modules["keeplib2"] N_MGR.evict_on_disable = False def test_manager_unload_resets_dep_counter(): """unload_modules() resets _dep_counter to an empty dict. Seeds the counter via a fake plugin rather than relying on native plugins whose optional libraries may not be installed (the minimal tox env disables all such plugins, leaving _dep_counter empty after a native load). """ N_MGR.unload_modules() # Seed counter directly so the post-unload assertion is meaningful PlugR = _make_dep_plugin("resettest_xyz", "resetlib_xyz") N_MGR["resettest_xyz"] = PlugR N_MGR._build_dep_counter() assert N_MGR._dep_counter.get("resetlib_xyz", 0) >= 1 N_MGR.unload_modules() assert N_MGR._dep_counter == {} def test_manager_known_plugin_runtime_deps(): """Plugins known to carry optional deps must declare runtime_deps().""" N_MGR.unload_modules() from apprise.plugins.blink1 import NotifyBlink1 from apprise.plugins.fcm import NotifyFCM from apprise.plugins.growl import NotifyGrowl from apprise.plugins.mqtt import NotifyMQTT from apprise.plugins.simplepush import NotifySimplePush from apprise.plugins.smpp import NotifySMPP from apprise.plugins.vapid import NotifyVapid from apprise.plugins.xmpp.base import NotifyXMPP assert "hid" in NotifyBlink1.runtime_deps() assert "paho" in NotifyMQTT.runtime_deps() assert "gntp" in NotifyGrowl.runtime_deps() assert "smpplib" in NotifySMPP.runtime_deps() assert "slixmpp" in NotifyXMPP.runtime_deps() assert "cryptography" in NotifySimplePush.runtime_deps() assert "cryptography" in NotifyFCM.runtime_deps() assert "cryptography" in NotifyVapid.runtime_deps() # Base class has no deps assert NotifyBase.runtime_deps() == () # =========================================================================== # Coverage: additional tests for previously uncovered branches # =========================================================================== def test_manager_len_already_loaded(): """Verify __len__ works correctly when modules are already loaded.""" N_MGR.unload_modules() # First call: triggers lazy load _ = len(N_MGR) # Second call: modules already loaded, returns length directly assert len(N_MGR) > 0 def test_manager_unload_orphan_custom_module(): """Verify unload_modules() skips custom module map entries whose name is not present in the native module map.""" N_MGR.unload_modules() N_MGR.load_modules() # Inject a synthetic entry whose 'name' value is not a key in # _module_map so the early-continue branch is exercised. N_MGR._custom_module_map["orphan_key_xyz"] = { "name": "orphan_module_xyz_not_native", "path": "/fake/path.py", "notify": {}, } N_MGR.unload_modules() assert "orphan_key_xyz" not in N_MGR._custom_module_map def test_module_detection_invalid_paths(): """Verify module_detection returns early for invalid path inputs.""" N_MGR.unload_modules() N_MGR.module_detection(None) N_MGR.module_detection([]) N_MGR.module_detection(123) def test_module_detection_nonexistent_path(): """Verify a path that does not exist is silently skipped.""" N_MGR.unload_modules() N_MGR.module_detection(["/tmp/__no_such_path_xyz_abc_123__"]) def test_module_detection_directory_scan(tmpdir): """Scans a plain directory (no __init__.py at root) containing several fixture files that each exercise a different branch.""" N_MGR.unload_modules() # File whose name starts with '.' -> module_re fails -> ignored tmpdir.join(".hidden_xyz").write("") # File without .py extension -> valid_python_file_re fails inside # _import_module -> skipped tmpdir.join("noext_xyz").write("") # Valid .py but raises ImportError at module level -> # import_module returns None -> skipped tmpdir.join("badimport_xyz.py").write( "import this_library_does_not_exist_xyz_abc\n" ) # Valid .py with no @notify -> module_pyname absent from # _custom_module_map after import -> skipped tmpdir.join("nohooks_xyz.py").write("x = 1\n") # Subdir without __init__.py -> no __init__.py -> skipped tmpdir.mkdir("subpkg_no_init_xyz") # Subdir WITH __init__.py, pre-populated in cache so the cache-hit # branch is exercised (file exists -> don't skip, but cache -> skip # _import_module call) cached_sub = tmpdir.mkdir("subpkg_cached_xyz") cached_init = cached_sub.join("__init__.py") cached_init.write("# cached\n") N_MGR._paths_previously_scanned.add(str(cached_init)) N_MGR.module_detection(str(tmpdir)) N_MGR.unload_modules() def test_module_detection_package_dir(tmpdir): """Directory with __init__.py: already-scanned path is skipped; fresh path is imported but yields no hooks.""" N_MGR.unload_modules() # A package dir (has __init__.py) with no @notify hooks pkgdir = tmpdir.mkdir("mypkg_xyz") pkg_path = str(pkgdir) init_path = str(pkgdir.join("__init__.py")) pkgdir.join("__init__.py").write("# no hooks\n") # Pre-populate the cache with the __init__.py path so the # already-scanned-inner-path branch is exercised on first call N_MGR._paths_previously_scanned.add(init_path) N_MGR.module_detection(pkg_path) # Discard both paths # so the add-to-cache branch is exercised on the second call N_MGR._paths_previously_scanned.discard(init_path) N_MGR._paths_previously_scanned.discard(pkg_path) N_MGR.module_detection(pkg_path) N_MGR.unload_modules() def test_module_detection_non_matching_file(tmpdir): """File path whose basename fails module_re is silently ignored.""" N_MGR.unload_modules() # A filename starting with '.' does not match module_re -> ignored hidden = tmpdir.join(".hidden_direct_xyz") hidden.write("") N_MGR.module_detection(str(hidden)) N_MGR.unload_modules() def test_module_detection_reload_existing(tmpdir): """_import_module clears and re-registers a custom module that was previously loaded into _custom_module_map.""" N_MGR.unload_modules() hook = tmpdir.mkdir("reloadpkg_xyz").join("__init__.py") hook.write( cleandoc(""" from apprise.decorators import notify @notify(on="reloadhookxyz") def handler(body, title, notify_type, *args, **kwargs): pass """) ) # First load β€” populates _custom_module_map N_MGR.module_detection(str(hook)) assert "reloadhookxyz" in N_MGR # Remove path from cache so the same file is processed a second time N_MGR._paths_previously_scanned.discard(str(hook)) # Second load -> _import_module finds module_pyname already in # _custom_module_map -> clear + re-register N_MGR.module_detection(str(hook)) assert "reloadhookxyz" in N_MGR N_MGR.unload_modules() def test_build_dep_counter_empty_map(): """_build_dep_counter returns early when _module_map is empty.""" # disable_native=True sets _module_map = {} (falsy) -> returns early N_MGR.unload_modules(disable_native=True) N_MGR._build_dep_counter() assert N_MGR._dep_counter == {} def test_build_dep_counter_no_runtime_deps_attr(): """Plugin without a callable runtime_deps is skipped by _build_dep_counter.""" N_MGR.unload_modules() N_MGR.load_modules() class MinimalPlugin: """Minimal plugin: enabled but has no runtime_deps attribute.""" enabled = True N_MGR._module_map["minimal_test_xyz_657"] = { "plugin": {MinimalPlugin}, "module": None, "path": "apprise.adhoc.minimal_test_xyz_657", "native": False, } # getattr returns None -> not callable(None) -> continue N_MGR._build_dep_counter() N_MGR.unload_modules() def test_update_dep_counter_no_runtime_deps(): """_update_dep_counter returns early when the plugin has no callable runtime_deps.""" N_MGR.unload_modules() class NoDepPlugin: """Plugin with no runtime_deps attribute at all.""" # getattr returns None -> not callable(None) -> return early N_MGR._update_dep_counter(NoDepPlugin, -1) def test_evict_library_keyerror_race(monkeypatch): """KeyError during sys.modules eviction is handled gracefully. sys.modules is replaced with a subclass whose __delitem__ always raises KeyError. The key is found but the delete fails, exercising the except-KeyError branch. With evicted == 0 and to_remove non-empty the elif warning branch is also reached. """ N_MGR.unload_modules() class BrokenModulesDict(dict): def __delitem__(self, key): raise KeyError(key) broken = BrokenModulesDict(sys.modules) broken["evict_race_lib_xyz"] = types.ModuleType("evict_race_lib_xyz") monkeypatch.setattr(sys, "modules", broken) # to_remove = ["evict_race_lib_xyz"]; del raises -> KeyError caught # evicted stays 0, to_remove non-empty -> warning branch reached N_MGR._evict_library("evict_race_lib_xyz") def test_load_modules_import_fallback_fails(tmpdir): """__import__ raises ImportError and the path-based fallback also returns None because the file itself fails to import.""" N_MGR.unload_modules() # A .py file whose top-level import always fails tmpdir.join("alwaysfail_xyz.py").write( "import this_library_does_not_exist_xyz_abc\n" ) # load_modules uses the default prefix ("apprise.plugins") so # __import__("apprise.plugins.alwaysfail_xyz") raises ImportError, # then import_module(path, ...) also returns None -> skipped N_MGR.load_modules(path=str(tmpdir)) N_MGR.unload_modules() def test_load_modules_no_valid_class(tmpdir): """Module loads successfully but contains no qualifying class, so module_class stays None and the module is skipped.""" N_MGR.unload_modules() # A module with only a docstring: dir() yields nothing that matches # module_filter_re (all dunders start with '_') -> empty list -> # module_class stays None -> skipped tmpdir.join("notifyempty_xyz.py").write('"""Empty module."""\n') N_MGR.load_modules(path=str(tmpdir)) N_MGR.unload_modules() def test_load_modules_schema_conflict(tmpdir): """A schema already registered is logged as a conflict and skipped.""" N_MGR.unload_modules() N_MGR.load_modules() # native load -> "json" in _schema_map tmpdir.join("notifyconflict_xyz.py").write( cleandoc(""" from apprise.plugins.base import NotifyBase class NotifyConflict(NotifyBase): service_name = "Conflict" protocol = "json" secure_protocol = None service_url = "https://example.com" setup_url = "https://example.com" notify_url = "" templates = ('{schema}://',) def send(self, body, title="", **kwargs): return True def url(self, **kwargs): return "json://" """) ) # __import__("apprise.plugins.notifyconflict_xyz") -> ImportError; # fallback loads the file; NotifyConflict.schemas() = {"json"}; # "json" already in _schema_map -> conflict logged, skipped N_MGR.load_modules(path=str(tmpdir)) N_MGR.unload_modules() def test_notification_manager_no_import_failures(): """Verify all native plugins load without TRACE import errors.""" # Remove the global test-suite log suppression so TRACE records # are visible during the scan below logging.disable(logging.NOTSET) # Force a clean reload of the native plugin directory N_MGR.unload_modules() with LogCapture(level=logging.TRACE) as stream: # Trigger load_modules() via the lazy-load path len(N_MGR) # Grab the captured log content before the handler is removed content = stream.getvalue() # Restore log suppression for the remainder of the test suite logging.disable(logging.CRITICAL) # Any "import failed" line means a plugin directory was found but # contained no loadable Notify* class (e.g. a stale __pycache__ # directory left behind after a rename). failures = [ line for line in content.splitlines() if "import failed" in line ] assert not failures, "Plugin import failures detected:\n" + "\n".join( failures ) # Leave the manager in a clean state for subsequent tests N_MGR.unload_modules() caronc-apprise-182f859/tests/test_notify_base.py000066400000000000000000000365001524161175200217750ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timedelta # Disable logging for a cleaner testing output import logging from timeit import default_timer import pytest from apprise import AppriseAsset, NotifyImageSize, NotifyType from apprise.plugins import NotifyBase logging.disable(logging.CRITICAL) def test_notify_base(): """ API: NotifyBase() object """ # invalid types throw exceptions with pytest.raises(TypeError): NotifyBase(**{"format": "invalid"}) # invalid types throw exceptions with pytest.raises(TypeError): NotifyBase(**{"overflow": "invalid"}) # Bad port information nb = NotifyBase(port="invalid") assert nb.port is None nb = NotifyBase(port=10) assert nb.port == 10 assert isinstance(nb.url(), str) assert str(nb) == nb.url() with pytest.raises(NotImplementedError): # Each sub-module is that inherits this as a parent is required to # over-ride this function. So direct calls to this throws a not # implemented error intentionally nb.send("test message") # Throttle overrides.. nb = NotifyBase() nb.request_rate_per_sec = 0.0 start_time = default_timer() nb.throttle() elapsed = default_timer() - start_time # Should be a very fast response time since we set it to zero but we'll # check for less then 500 to be fair as some testing systems may be slower # then other assert elapsed < 0.5 # Concurrent calls should achieve the same response start_time = default_timer() nb.throttle() elapsed = default_timer() - start_time assert elapsed < 0.5 nb = NotifyBase() nb.request_rate_per_sec = 1.0 # Set our time to now start_time = default_timer() nb.throttle() elapsed = default_timer() - start_time # A first call to throttle (Without telling it a time previously ran) does # not block for any length of time; it just merely sets us up for # concurrent calls to block assert elapsed < 0.5 # Concurrent calls could take up to the rate_per_sec though... start_time = default_timer() nb.throttle(last_io=datetime.now()) elapsed = default_timer() - start_time assert elapsed > 0.48 and elapsed < 1.5 nb = NotifyBase() nb.request_rate_per_sec = 1.0 # Set our time to now start_time = default_timer() nb.throttle(last_io=datetime.now()) elapsed = default_timer() - start_time # because we told it that we had already done a previous action (now) # the throttle holds out until the right time has passed assert elapsed > 0.48 and elapsed < 1.5 # Concurrent calls could take up to the rate_per_sec though... start_time = default_timer() nb.throttle(last_io=datetime.now()) elapsed = default_timer() - start_time assert elapsed > 0.48 and elapsed < 1.5 nb = NotifyBase() start_time = default_timer() nb.request_rate_per_sec = 1.0 # Force a time in the past nb.throttle(last_io=(datetime.now() - timedelta(seconds=20))) elapsed = default_timer() - start_time # Should be a very fast response time since we set it to zero but we'll # check for less then 500 to be fair as some testing systems may be slower # then other assert elapsed < 0.5 # Force a throttle time start_time = default_timer() nb.throttle(wait=0.5) elapsed = default_timer() - start_time assert elapsed > 0.48 and elapsed < 1.5 # our NotifyBase wasn't initialized with an ImageSize so this will fail assert nb.image_url(notify_type=NotifyType.INFO) is None assert nb.image_path(notify_type=NotifyType.INFO) is None assert nb.image_raw(notify_type=NotifyType.INFO) is None # Color handling assert nb.color(notify_type="invalid") == AppriseAsset.default_html_color assert isinstance( nb.color(notify_type=NotifyType.INFO, color_type=None), str ) assert isinstance( nb.color(notify_type=NotifyType.INFO, color_type=int), int ) assert isinstance( nb.color(notify_type=NotifyType.INFO, color_type=tuple), tuple ) # Ascii Handling assert nb.ascii(notify_type="invalid") == AppriseAsset.default_ascii_chars assert nb.ascii(NotifyType.INFO) == "[i]" assert nb.ascii(NotifyType.SUCCESS) == "[+]" assert nb.ascii(NotifyType.WARNING) == "[~]" assert nb.ascii(NotifyType.FAILURE) == "[!]" # Create an object nb = NotifyBase() # Force an image size since the default doesn't have one nb.image_size = NotifyImageSize.XY_256 # We'll get an object this time around assert nb.image_url(notify_type=NotifyType.INFO) is not None assert nb.image_path(notify_type=NotifyType.INFO) is not None assert nb.image_raw(notify_type=NotifyType.INFO) is not None # Static function testing assert ( NotifyBase.escape_html("'\t \n") == "<content>'  \n</content>" ) assert ( NotifyBase.escape_html( "'\t \n", convert_new_lines=True ) == "<content>'  
    </content>" ) # Test invalid data assert NotifyBase.split_path(None) == [] assert NotifyBase.split_path(object()) == [] assert NotifyBase.split_path(42) == [] assert NotifyBase.split_path( "/path/?name=Dr%20Disrespect", unquote=False ) == [ "path", "?name=Dr%20Disrespect", ] assert NotifyBase.split_path( "/path/?name=Dr%20Disrespect", unquote=True ) == [ "path", "?name=Dr Disrespect", ] # a slash found inside the path, if escaped properly will not be broken # by split_path while additional concatinated slashes are ignored # FYI: %2F = / assert NotifyBase.split_path( "/%2F///%2F%2F////%2F%2F%2F////", unquote=True ) == [ "/", "//", "///", ] # Test invalid data assert NotifyBase.parse_list(None) == [] assert NotifyBase.parse_list(object()) == [] assert NotifyBase.parse_list(42) == [] result = NotifyBase.parse_list( ",path,?name=Dr%20Disrespect", unquote=False ) assert isinstance(result, list) assert len(result) == 2 assert "path" in result assert "?name=Dr%20Disrespect" in result result = NotifyBase.parse_list(",path,?name=Dr%20Disrespect", unquote=True) assert isinstance(result, list) assert len(result) == 2 assert "path" in result assert "?name=Dr Disrespect" in result # by parse_list while additional concatinated slashes are ignored # FYI: %2F = / # In this lit there are actually 4 entries, however parse_list # eliminates duplicates in addition to unquoting content by default result = NotifyBase.parse_list( ",%2F,%2F%2F, , , ,%2F%2F%2F, %2F", unquote=True ) assert isinstance(result, list) assert len(result) == 3 assert "/" in result assert "//" in result assert "///" in result # Phone number parsing assert NotifyBase.parse_phone_no(None) == [] assert NotifyBase.parse_phone_no(object()) == [] assert NotifyBase.parse_phone_no(42) == [] result = NotifyBase.parse_phone_no( "+1-800-123-1234,(800) 123-4567", unquote=False ) assert isinstance(result, list) assert len(result) == 2 assert "+1-800-123-1234" in result assert "(800) 123-4567" in result # %2B == + result = NotifyBase.parse_phone_no( "%2B1-800-123-1234,%2B1%20800%20123%204567", unquote=True ) assert isinstance(result, list) assert len(result) == 2 assert "+1-800-123-1234" in result assert "+1 800 123 4567" in result # Give nothing, get nothing assert NotifyBase.escape_html("") == "" assert NotifyBase.escape_html(None) == "" assert NotifyBase.escape_html(object()) == "" # Test quote assert NotifyBase.unquote("%20") == " " assert NotifyBase.quote(" ") == "%20" assert NotifyBase.unquote(None) == "" assert NotifyBase.quote(None) == "" def test_notify_base_urls(): """ API: NotifyBase() URLs """ # Test verify switch whih is used as part of the SSL Verification # by default all SSL sites are verified unless this flag is set to # something like 'No', 'False', 'Disabled', etc. Boolean values are # pretty forgiving. results = NotifyBase.parse_url("https://localhost:8080/?verify=No") assert "verify" in results assert results["verify"] is False results = NotifyBase.parse_url("https://localhost:8080/?verify=Yes") assert "verify" in results assert results["verify"] is True # The default is to verify results = NotifyBase.parse_url("https://localhost:8080") assert "verify" in results assert results["verify"] is True # Password Handling # pass keyword over-rides default password results = NotifyBase.parse_url("https://user:pass@localhost") assert "password" in results assert results["password"] == "pass" # pass keyword over-rides default password results = NotifyBase.parse_url( "https://user:pass@localhost?pass=newpassword" ) assert "password" in results assert results["password"] == "newpassword" # password keyword can also optionally be used results = NotifyBase.parse_url( "https://user:pass@localhost?password=passwd" ) assert "password" in results assert results["password"] == "passwd" # pass= override password= # password keyword can also optionally be used results = NotifyBase.parse_url( "https://user:pass@localhost?pass=pw1&password=pw2" ) assert "password" in results assert results["password"] == "pw1" # Options results = NotifyBase.parse_url("https://localhost?format=invalid") assert "format" not in results results = NotifyBase.parse_url("https://localhost?format=text") assert "format" in results assert results["format"] == "text" results = NotifyBase.parse_url("https://localhost?format=markdown") assert "format" in results assert results["format"] == "markdown" results = NotifyBase.parse_url("https://localhost?format=html") assert "format" in results assert results["format"] == "html" results = NotifyBase.parse_url("https://localhost?overflow=invalid") assert "overflow" not in results results = NotifyBase.parse_url("https://localhost?overflow=upstream") assert "overflow" in results assert results["overflow"] == "upstream" results = NotifyBase.parse_url("https://localhost?overflow=split") assert "overflow" in results assert results["overflow"] == "split" results = NotifyBase.parse_url("https://localhost?overflow=truncate") assert "overflow" in results assert results["overflow"] == "truncate" # User Handling # user keyword over-rides default password results = NotifyBase.parse_url("https://user:pass@localhost") assert "user" in results assert results["user"] == "user" # user keyword over-rides default password results = NotifyBase.parse_url("https://user:pass@localhost?user=newuser") assert "user" in results assert results["user"] == "newuser" # Test invalid urls assert NotifyBase.parse_url("https://:@/") is None assert NotifyBase.parse_url("http://:@") is None assert NotifyBase.parse_url("http://@") is None assert NotifyBase.parse_url("http:///") is None assert NotifyBase.parse_url("http://:test/") is None assert NotifyBase.parse_url("http://pass:test/") is None def test_notify_base_runtime_deps(): """NotifyBase.runtime_deps() returns an empty tuple by default.""" assert NotifyBase.runtime_deps() == () assert isinstance(NotifyBase.runtime_deps(), tuple) def test_notify_base_enable_disable(): """NotifyBase.enable() and disable() toggle the enabled flag.""" class ToggleNotification(NotifyBase): protocol = "toggle" def notify(self, *args, **kwargs): return True def url(self, **kwargs): return "toggle://" # Starts enabled assert ToggleNotification.enabled is True # disable() sets it False ToggleNotification.disable() assert ToggleNotification.enabled is False # enable() restores it ToggleNotification.enable() assert ToggleNotification.enabled is True # Calling disable() twice is idempotent ToggleNotification.disable() ToggleNotification.disable() assert ToggleNotification.enabled is False # Calling enable() twice is idempotent ToggleNotification.enable() ToggleNotification.enable() assert ToggleNotification.enabled is True # Changes are on the class, not on instances instance = ToggleNotification(host="localhost") ToggleNotification.disable() assert instance.enabled is False ToggleNotification.enable() assert instance.enabled is True def test_notify_base_runtime_deps_override(): """Subclasses can override runtime_deps() to declare their dependencies.""" class DepFreePlugin(NotifyBase): protocol = "depfree" def notify(self, *args, **kwargs): return True def url(self, **kwargs): return "depfree://" class SingleDepPlugin(NotifyBase): protocol = "singledep" def notify(self, *args, **kwargs): return True def url(self, **kwargs): return "singledep://" @staticmethod def runtime_deps(): return ("fakelibA",) class MultiDepPlugin(NotifyBase): protocol = "multidep" def notify(self, *args, **kwargs): return True def url(self, **kwargs): return "multidep://" @staticmethod def runtime_deps(): return ("fakelibA", "fakelibB") # No override -> inherits the empty tuple from NotifyBase assert DepFreePlugin.runtime_deps() == () # Single-dep override assert SingleDepPlugin.runtime_deps() == ("fakelibA",) # Multi-dep override preserves order deps = MultiDepPlugin.runtime_deps() assert deps == ("fakelibA", "fakelibB") assert deps[0] == "fakelibA" assert deps[1] == "fakelibB" caronc-apprise-182f859/tests/test_persistent_store.py000066400000000000000000001442231524161175200231110ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timedelta, timezone import gzip import json # Disable logging for a cleaner testing output import logging import os import shutil import sys import tempfile import time from unittest import mock import zlib import pytest from apprise import exception from apprise.asset import AppriseAsset from apprise.persistent_store import ( CacheJSONEncoder, CacheObject, PersistentStore, PersistentStoreMode, ) logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Keep the real close method so simulated failures do not leak file handles. _real_ntf_close = tempfile._TemporaryFileWrapper.close def _flaky_close(always=False): """Mock close errors while still releasing the real file handle. Raise on every call when ``always`` is true; otherwise, raise only once. """ state = {"calls": 0} def _close(self, *args, **kwargs): # Perform the real close regardless of the simulated outcome _real_ntf_close(self) state["calls"] += 1 if always or state["calls"] == 1: raise OSError() return _close def test_persistent_storage_asset(tmpdir): """Tests the Apprise Asset Object when setting the Persistent Store.""" asset = AppriseAsset(storage_path=str(tmpdir)) assert asset.storage_path == str(tmpdir) assert asset.storage_mode is PersistentStoreMode.AUTO # If there is no storage path, we're always set to memory asset = AppriseAsset( storage_path=None, storage_mode=PersistentStoreMode.MEMORY ) assert asset.storage_path is None assert asset.storage_mode is PersistentStoreMode.MEMORY def test_persistent_storage_bad_mode(tmpdir): """Persistent Storage Bad Mode Testing.""" # Create ourselves an attachment object set in Memory Mode only with pytest.raises(AttributeError): PersistentStore(namespace="abc", path=str(tmpdir), mode="invalid") with pytest.raises(AttributeError): AppriseAsset(storage_mode="invalid") def test_disabled_persistent_storage(tmpdir): """Persistent Storage General Testing.""" # Create ourselves an attachment object set in Memory Mode only pc = PersistentStore( namespace="abc", path=str(tmpdir), mode=PersistentStoreMode.MEMORY ) assert pc.read() is None assert pc.read("mykey") is None with pytest.raises(AttributeError): # Invalid key specified pc.read("!invalid") assert pc.write("data") is False assert pc.get("key") is None assert pc.set("key", "value") assert pc.get("key") == "value" assert pc.set("key2", "value") pc.clear("key", "key-not-previously-set") assert pc.get("key2") == "value" assert pc.get("key") is None # Set it again assert pc.set("key", "another-value") # Clears all pc.clear() assert pc.get("key2") is None assert pc.get("key") is None # A second call to clear on an already empty cache set pc.clear() # No dirty flag is set as ther is nothing to write to disk pc.set("not-persistent", "value", persistent=False) del pc["not-persistent"] with pytest.raises(KeyError): # Can't delete it twice del pc["not-persistent"] # A Persistent key pc.set("persistent", "value") # Removes it and sets/clears the dirty flag del pc["persistent"] # After all of the above, nothing was done to the directory assert len(os.listdir(str(tmpdir))) == 0 with pytest.raises(AttributeError): # invalid persistent store specified PersistentStore(namespace="abc", path=str(tmpdir), mode="garbage") def test_persistent_storage_init(tmpdir): """Test storage initialization.""" with pytest.raises(AttributeError): PersistentStore(namespace="", path=str(tmpdir)) with pytest.raises(AttributeError): PersistentStore(namespace=None, path=str(tmpdir)) with pytest.raises(AttributeError): PersistentStore(namespace="_", path=str(tmpdir)) with pytest.raises(AttributeError): PersistentStore(namespace=".", path=str(tmpdir)) with pytest.raises(AttributeError): PersistentStore(namespace="-", path=str(tmpdir)) with pytest.raises(AttributeError): PersistentStore(namespace="_abc", path=str(tmpdir)) with pytest.raises(AttributeError): PersistentStore(namespace=".abc", path=str(tmpdir)) with pytest.raises(AttributeError): PersistentStore(namespace="-abc", path=str(tmpdir)) with pytest.raises(AttributeError): PersistentStore(namespace="%", path=str(tmpdir)) def test_persistent_storage_general(tmpdir): """Persistent Storage General Testing.""" namespace = "abc" # Create ourselves an attachment object pc = PersistentStore() # Default mode when a path is not provided assert pc.mode == PersistentStoreMode.MEMORY assert pc.size() == 0 assert pc.files() == [] assert pc.files(exclude=True, lazy=False) == [] assert pc.files(exclude=False, lazy=False) == [] pc.set("key", "value") # There is no disk size utilized assert pc.size() == 0 assert pc.files(exclude=True, lazy=False) == [] assert pc.files(exclude=False, lazy=False) == [] # Create ourselves an attachment object pc = PersistentStore(namespace=namespace, path=str(tmpdir)) # Default mode when a path is provided assert pc.mode == PersistentStoreMode.AUTO # Get our path associated with our Persistent Store assert pc.path == os.path.join(str(tmpdir), "abc") # Expiry testing assert pc.set("key", "value", datetime.now() + timedelta(hours=1)) # i min in the future assert pc.set("key", "value", 60) with pytest.raises(AttributeError): assert pc.set("key", "value", "invalid") pc = PersistentStore(namespace=namespace, path=str(tmpdir)) # Our key is still valid and we load it from disk assert pc.get("key") == "value" assert pc["key"] == "value" pc = PersistentStore(namespace=namespace, path=str(tmpdir)) assert pc.keys() # Second call after already initialized skips over initialization assert pc.keys() pc = PersistentStore(namespace=namespace, path=str(tmpdir)) with pytest.raises(KeyError): # The below pc["unassigned_key"] def test_persistent_storage_auto_mode(tmpdir): """Persistent Storage Auto Write Testing.""" namespace = "abc" # Create ourselves an attachment object pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.AUTO ) pc.write(b"test") with mock.patch("os.unlink", side_effect=FileNotFoundError()): assert pc.delete(all=True) is True # Create a temporary file we can delete with open(os.path.join(pc.path, pc.temp_dir, "test.file"), "wb") as fd: fd.write(b"data") # Delete just the temporary files assert pc.delete(temp=True) is True # Delete just the temporary files # Create a cache entry and delete it assert pc.set("key", "value") is True pc.write(b"test") assert pc.delete(cache=True) is True # Verify our data entry wasn't removed assert pc.read() == b"test" # But our cache was assert pc.get("key") is None # A reverse of the above... create a cache an data variable and # Clear the data; make sure our cache is still there assert pc.set("key", "value") is True assert pc.write(b"test", key="iokey") is True assert pc.delete("iokey") is True assert pc.get("key") == "value" assert pc.read("iokey") is None def test_persistent_storage_flush_mode(tmpdir): """Persistent Storage Forced Write Testing.""" namespace = "abc" # Create ourselves an attachment object pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # Reference path path = os.path.join(str(tmpdir), namespace) assert pc.size() == 0 assert list(pc.files()) == [] # Key is not set yet assert pc.get("key") is None assert len(pc.keys()) == 0 assert "key" not in pc # Verify our data is set assert pc.set("key", "value") assert len(pc.keys()) == 1 assert "key" in list(pc.keys()) assert pc.size() > 0 assert len(pc.files()) == 1 # Second call uses Lazy cache # Just our cache file assert len(pc.files()) == 1 # Setting the same value again uses a lazy mode and # bypasses all of the write overhead assert pc.set("key", "value") path_content = os.listdir(path) # var, cache.psdata, and tmp assert len(path_content) == 3 # Assignments (causes another disk write) pc["key"] = "value2" # Setting the same value and explictly marking the field as not being # perisistent pc.set("key-xx", "abc123", persistent=False) # Changing it's value doesn't alter the persistent flag pc["key-xx"] = "def678" # Setting it twice pc["key-xx"] = "def678" # Our retrievals assert pc["key-xx"] == "def678" assert pc.get("key-xx") == "def678" # But on the destruction of our object, it is not available again del pc # Create ourselves an attachment object pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) assert pc.get("key-xx") is None with pytest.raises(KeyError): pc["key-xx"] # Now our key is set assert "key" in pc assert pc.get("key") == "value2" # A directory was created identified by the namespace assert len(os.listdir(str(tmpdir))) == 1 assert namespace in os.listdir(str(tmpdir)) path_content = os.listdir(path) assert len(path_content) == 4 # Another write doesn't change the file count pc["key"] = "value3" path_content = os.listdir(path) assert len(path_content) == 4 # Our temporary directory used for all file handling in this namespace assert pc.temp_dir in path_content # Our cache file assert os.path.basename(pc.cache_file) in path_content path = os.path.join(pc.path, pc.temp_dir) path_content = os.listdir(path) # We always do our best to clean any temporary files up assert len(path_content) == 0 # Destroy our object del pc # Re-initialize it pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # Our key is persistent and available right away assert pc.get("key") == "value3" assert "key" in pc # Remove our item del pc["key"] assert pc.size() == 0 assert "key" not in pc assert pc.write("data") is True assert pc.read() == b"data" assert pc.write(b"data") is True assert pc.read() == b"data" assert pc.read("default") == b"data" assert pc.write("data2", key="mykey") is True assert pc.read("mykey") == b"data2" # We can selectively delete our key assert pc.delete("mykey") assert pc.read("mykey") is None # Other keys are not touched assert pc.read("default") == b"data" assert pc.read() == b"data" # Full purge assert pc.delete() assert pc.read("mykey") is None assert pc.read() is None # Practice with files with open(os.path.join(TEST_VAR_DIR, "apprise-test.gif"), "rb") as fd: assert pc.write(fd, key="mykey", compress=False) is True # Read our content back fd.seek(0) assert pc.read("mykey", compress=False) == fd.read() with open(os.path.join(TEST_VAR_DIR, "apprise-test.gif"), "rb") as fd: assert pc.write(fd, key="mykey", compress=True) is True # Read our content back; content will be compressed fd.seek(0) assert pc.read("mykey", compress=True) == fd.read() class Foobar: def read(*args, **kwargs): return 42 foobar = Foobar() # read() returns a non string/bin with pytest.raises(exception.AppriseDiskIOError): pc.write(foobar, key="foobar", compress=True) assert pc.read("foobar") is None class Foobar: def read(*args, **kwargs): return "good" foobar = Foobar() # read() returns a string so the below write works assert pc.write(foobar, key="foobar", compress=True) assert pc.read("foobar") == b"good" pc.delete() class Foobar: def read(*args, **kwargs): # Throw an exception raise TypeError() foobar = Foobar() # read() returns a non string/bin with pytest.raises(exception.AppriseDiskIOError): pc.write(foobar, key="foobar", compress=True) assert pc.read("foobar") is None # Set our max_file_size prev_max_file_size = pc.max_file_size pc.max_file_size = 1 assert pc.delete() assert pc.write("data") is False assert pc.read() is None # Restore setting pc.max_file_size = prev_max_file_size # Reset pc.delete() assert pc.write("data") # Corrupt our data data = pc.read(compress=False)[:20] + pc.read(compress=False)[:10] pc.write(data, compress=False) # Now we'll get an exception reading back the corrupted data assert pc.read() is None # Keep in mind though the data is still there; operator should write # and read the way they expect to and things will work out fine # This test just proves that Apprise Peresistent storage still # gracefully handles bad data assert pc.read(compress=False) == data # No key exists also returns None assert pc.read("no-key-exists") is None pc.write(b"test") pc["key"] = "value" with mock.patch("os.unlink", side_effect=FileNotFoundError()): assert pc.delete(all=True) is True with mock.patch("os.unlink", side_effect=OSError()): assert pc.delete(all=True) is False # Create a temporary file we can delete tmp_file = os.path.join(pc.path, pc.temp_dir, "test.file") with open(tmp_file, "wb") as fd: fd.write(b"data") assert pc.set("key", "value") is True assert pc.write(b"test", key="iokey") is True # Delete just the temporary files assert pc.delete(temp=True) is True assert os.path.exists(tmp_file) is False # our other entries are untouched assert pc.get("key") == "value" assert pc.read("iokey") == b"test" # Delete just the temporary files # Create a cache entry and delete it assert pc.set("key", "value") is True assert pc.write(b"test") is True assert pc.delete(cache=True) is True # Verify our data entry wasn't removed assert pc.read() == b"test" # But our cache was assert pc.get("key") is None # A reverse of the above... create a cache an data variable and # Clear the data; make sure our cache is still there assert pc.set("key", "value") is True assert pc.write(b"test", key="iokey") is True assert pc.delete("iokey") is True assert pc.get("key") == "value" assert pc.read("iokey") is None # Create some custom files cust1_file = os.path.join(pc.path, "test.file") cust2_file = os.path.join(pc.path, pc.data_dir, "test.file") with open(cust1_file, "wb") as fd: fd.write(b"data") with open(cust2_file, "wb") as fd: fd.write(b"data") # Even after a full flush our files will exist assert pc.delete() assert os.path.exists(cust1_file) is True assert os.path.exists(cust2_file) is True # However, if we turn off validate, we do a full sweep because these # unknown files are lingering in our directory space assert pc.delete(validate=False) assert os.path.exists(cust1_file) is False assert os.path.exists(cust2_file) is False pc["key"] = "value" pc["key2"] = "value2" assert "key" in pc assert "key2" in pc pc.clear("key") assert "key" not in pc assert "key2" in pc # Set expired content pc.set( "expired", "expired-content", expires=datetime.now() - timedelta(days=1), ) # It's actually there... but it's expired so our persistent # storage is behaving as it should assert "expired" not in pc assert pc.get("expired") is None # Prune our content pc.prune() def test_persistent_storage_corruption_handling(tmpdir): """Test corrupting handling of storage.""" # Namespace namespace = "def456" # Initialize it pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) cache_file = pc.cache_file assert not os.path.isfile(cache_file) # Store our key pc["mykey"] = 42 assert os.path.isfile(cache_file) with gzip.open(cache_file, "rb") as f: # Read our content from disk json.loads(f.read().decode("utf-8")) # Remove object del pc # Corrupt the file with open(cache_file, "wb") as f: f.write(b"{") pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # File is corrupted assert "mykey" not in pc pc["mykey"] = 42 del pc # File is corrected now pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) assert "mykey" in pc # Corrupt the file again with gzip.open(cache_file, "wb") as f: # Bad JSON File f.write(b"{") pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # File is corrupted assert "mykey" not in pc pc["mykey"] = 42 del pc pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # Test our force flush assert pc.flush(force=True) is True # double call assert pc.flush(force=True) is True # Zlib error handling as well during open with ( mock.patch("gzip.open", side_effect=OSError()), pytest.raises(KeyError), ): pc["mykey"] = 43 pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # Zlib error handling as well during open with mock.patch("gzip.open", side_effect=OSError()): # No keys can be returned assert not pc.keys() pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) with ( mock.patch("json.loads", side_effect=TypeError()), mock.patch("os.unlink", side_effect=FileNotFoundError()), pytest.raises(KeyError), ): pc["mykey"] = 44 pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) with ( mock.patch("json.loads", side_effect=TypeError()), mock.patch("os.unlink", side_effect=OSError()), pytest.raises(KeyError), ): pc["mykey"] = 45 pc["my-new-key"] = 43 with mock.patch("gzip.open", side_effect=OSError()): # We will fail to flush our content to disk assert pc.flush(force=True) is False with mock.patch("json.dumps", side_effect=TypeError()): # We will fail to flush our content to disk assert pc.flush(force=True) is False with mock.patch("os.makedirs", side_effect=OSError()): pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH, ) # Directory initialization failed so we fall back to memory mode assert pc.mode == PersistentStoreMode.MEMORY # Handle file updates pc = PersistentStore( namespace="file-time-refresh", path=str(tmpdir), mode=PersistentStoreMode.AUTO, ) pc["test"] = "abcd" assert pc.write(b"data", key="abcd") is True assert pc.read("abcd", expires=True) == b"data" assert pc.write(b"data2", key="defg") is True assert pc.read("defg", expires=False) == b"data2" assert pc.write(b"data3", key="hijk") is True assert pc.read("hijk", expires=False) == b"data3" assert pc["test"] == "abcd" with mock.patch("os.utime", side_effect=(OSError(), FileNotFoundError())): pc.flush() # directory initialization okay pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) assert "mykey" not in pc pc["mykey"] = 42 del pc pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) assert "mykey" in pc # Remove the last entry del pc["mykey"] with ( mock.patch("os.rename", side_effect=OSError()), mock.patch("os.unlink", side_effect=OSError()), ): assert not pc.flush(force=True) # Create another entry pc["mykey"] = 42 with mock.patch("tempfile.NamedTemporaryFile", side_effect=OSError()): assert not pc.flush(force=True) # Temporary file cleanup failure with mock.patch( "tempfile._TemporaryFileWrapper.close", side_effect=OSError() ): assert not pc.flush(force=True) # Create another entry pc["mykey"] = 43 mock_ntf = mock.MagicMock() mock_ntf.name = os.path.join(tmpdir, "file") # # Recursion loop checking # with mock.patch( "tempfile.NamedTemporaryFile", side_effect=[FileNotFoundError(), FileNotFoundError(), mock_ntf], ): # No way to have recursion loop assert not pc.flush(force=True, _recovery=True) with mock.patch( "tempfile.NamedTemporaryFile", side_effect=[FileNotFoundError(), FileNotFoundError(), mock_ntf], ): # No way to have recursion loop assert not pc.flush(force=False, _recovery=True) with mock.patch( "tempfile.NamedTemporaryFile", side_effect=[FileNotFoundError(), FileNotFoundError(), mock_ntf], ): # No way to have recursion loop assert not pc.flush(force=False, _recovery=False) with mock.patch( "tempfile.NamedTemporaryFile", side_effect=[FileNotFoundError(), FileNotFoundError(), mock_ntf], ): # No way to have recursion loop assert not pc.flush(force=True, _recovery=False) with ( mock.patch( "tempfile._TemporaryFileWrapper.close", autospec=True, side_effect=_flaky_close(), ), mock.patch("os.unlink", side_effect=(OSError())), ): assert not pc.flush(force=True) with mock.patch( "tempfile._TemporaryFileWrapper.close", autospec=True, side_effect=_flaky_close(always=True), ): assert not pc.flush(force=True) with ( mock.patch( "tempfile._TemporaryFileWrapper.close", autospec=True, side_effect=_flaky_close(), ), mock.patch("os.unlink", side_effect=OSError()), ): assert not pc.flush(force=True) with ( mock.patch( "tempfile._TemporaryFileWrapper.close", autospec=True, side_effect=_flaky_close(), ), mock.patch("os.unlink", side_effect=FileNotFoundError()), ): assert not pc.flush(force=True) del pc # directory initialization okay pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # Allows us to play with encoding errors pc.encoding = "ascii" # Handle write() calls with mock.patch("os.stat", side_effect=OSError()): # We fail to fetch the filesize of our old file causing us to fail assert pc.write("abcd") is False # γƒœγƒΌγƒ«γƒˆ translates to vault (no bad word here) :) data = "γƒœγƒΌγƒ«γƒˆ" # We'll have encoding issues assert pc.write(data) is False with mock.patch("gzip.open", side_effect=FileNotFoundError()): pc = PersistentStore(namespace=namespace, path=str(tmpdir)) # recovery mode will kick in and even it will fail assert pc.write(b"key") is False with mock.patch("gzip.open", side_effect=OSError()): pc = PersistentStore(namespace=namespace, path=str(tmpdir)) # Falls to default assert pc.get("key") is None pc = PersistentStore(namespace=namespace, path=str(tmpdir)) with pytest.raises(KeyError): pc["key"] = "value" pc = PersistentStore(namespace=namespace, path=str(tmpdir)) with pytest.raises(KeyError): pc["key"] pc = PersistentStore(namespace=namespace, path=str(tmpdir)) with pytest.raises(KeyError): del pc["key"] pc = PersistentStore(namespace=namespace, path=str(tmpdir)) # Fails to set key assert pc.set("key", "value") is False pc = PersistentStore(namespace=namespace, path=str(tmpdir)) # Fails to clear assert pc.clear() is False pc = PersistentStore(namespace=namespace, path=str(tmpdir)) # Fails to prune assert pc.prune() is False # Set some expired content pc.set( "key", "value", persistent=False, expires=datetime.now() - timedelta(days=1), ) pc.set( "key2", "value2", persistent=True, expires=datetime.now() - timedelta(days=1), ) # Set some un-expired content pc.set("key3", "value3", persistent=True) pc.set("key4", "value4", persistent=False) assert pc.prune() is True # Second call has no change made assert pc.prune() is False # Reset pc.delete() # directory initialization okay pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # Write some content that expires almost immediately pc.set( "key1", "value", persistent=True, expires=datetime.now() + timedelta(seconds=1), ) pc.set( "key2", "value", persistent=True, expires=datetime.now() + timedelta(seconds=1), ) pc.set( "key3", "value", persistent=True, expires=datetime.now() + timedelta(seconds=1), ) pc.flush() # Wait out our expiry time.sleep(1.3) # now initialize our storage again pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # This triggers our __load_cache() which reads in a value # determined to have already been expired assert "key1" not in pc assert "key2" not in pc assert "key3" not in pc # Sweep pc.delete() pc.set("key", "value") pc.set("key2", "value2") pc.write("more-content") # Flush our content to disk pc.flush() # Ideally we'd use os.stat below, but it is called inside a list # comprehension block and mock doesn't appear to throw the exception # there. So this is a bit of a cheat, but it works with mock.patch("builtins.sum", side_effect=OSError()): assert pc.size(exclude=True, lazy=False) == 0 assert pc.size(exclude=False, lazy=False) == 0 pc = PersistentStore(namespace=namespace, path=str(tmpdir)) with mock.patch("glob.glob", side_effect=OSError()): assert pc.files(exclude=True, lazy=False) == [] assert pc.files(exclude=False, lazy=False) == [] pc = PersistentStore( namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH ) # Causes an initialization pc["abc"] = 1 with mock.patch("os.unlink", side_effect=OSError()): # Now we can't set data with pytest.raises(KeyError): pc["new-key"] = "value" # However keys that alrady exist don't get caught in check # and therefore won't throw pc["abc"] = "value" # # Handles flush() when the queue is empty # pc.clear() with mock.patch("os.unlink", side_effect=OSError()): # We can't remove backup cache file assert pc.flush(force=True) is False with mock.patch("os.unlink", side_effect=FileNotFoundError()): # FileNotFound is not an issue assert pc.flush(force=True) is True with mock.patch("os.rename", side_effect=OSError()): # We can't create a backup assert pc.flush(force=True) is False with mock.patch("os.rename", side_effect=FileNotFoundError()): # FileNotFound is not an issue assert pc.flush(force=True) is True # Flush any previous cache and data pc.delete() # # Handles flush() cases where is data to write # # Create a key pc.set("abc", "a-test-value") with mock.patch("os.unlink", side_effect=(OSError(), None)): # We failed to move our content in place assert pc.flush(force=True) is False with mock.patch("os.unlink", side_effect=(OSError(), FileNotFoundError())): # We failed to move our content in place assert pc.flush(force=True) is False with mock.patch("os.unlink", side_effect=(OSError(), OSError())): # We failed to move our content in place assert pc.flush(force=True) is False def test_persistent_custom_io(tmpdir): """Test reading and writing custom files.""" # Initialize it for memory only pc = PersistentStore(path=str(tmpdir)) with pytest.raises(AttributeError): pc.open("!invalid#-Key") # We can't open the file as it does not exist with pytest.raises(FileNotFoundError): pc.open("valid-key") with pytest.raises(AttributeError): # Bad data pc.open(1234) with pytest.raises(FileNotFoundError), pc.open("key") as fd: pass # Also can be caught using Apprise Exception Handling with pytest.raises(exception.AppriseFileNotFound), pc.open("key") as fd: pass # Write some valid data with pc.open("new-key", "wb") as fd: fd.write(b"data") with mock.patch( "builtins.open", new_callable=mock.mock_open, read_data="mocked file content", ) as mock_file: mock_file.side_effect = OSError with ( pytest.raises(exception.AppriseDiskIOError), pc.open("new-key", compress=True) as fd, ): pass # Again but with compression this time with mock.patch( "gzip.open", new_callable=mock.mock_open, read_data="mocked file content", ) as mock_file: mock_file.side_effect = OSError with ( pytest.raises(exception.AppriseDiskIOError), pc.open("new-key", compress=True) as fd, ): pass # Zlib error handling as well during open with mock.patch( "gzip.open", new_callable=mock.mock_open, read_data="mocked file content", ) as mock_file: mock_file.side_effect = zlib.error with ( pytest.raises(exception.AppriseDiskIOError), pc.open("new-key", compress=True) as fd, ): pass # Writing with pytest.raises(AttributeError): pc.write(1234) with pytest.raises(AttributeError): pc.write(None) with pytest.raises(AttributeError): pc.write(True) pc = PersistentStore(str(tmpdir)) with pc.open("key", "wb") as fd: fd.write(b"test") fd.close() # Handle error capuring when failing to write to disk with mock.patch( "gzip.open", new_callable=mock.mock_open, read_data="mocked file content", ) as mock_file: mock_file.side_effect = zlib.error # We fail to write to disk assert pc.write(b"test") is False # We support other errors too mock_file.side_effect = OSError assert pc.write(b"test") is False with pytest.raises(AttributeError): pc.write(b"data", key="!invalid#-Key") pc.delete() with mock.patch("os.unlink", side_effect=OSError()): # Write our data and the __move() will fail under the hood assert pc.write(b"test") is False pc.delete() with mock.patch("os.rename", side_effect=OSError()): # Write our data and the __move() will fail under the hood assert pc.write(b"test") is False pc.delete() with mock.patch("os.unlink", side_effect=(OSError(), FileNotFoundError())): # Write our data and the __move() will fail under the hood assert pc.write(b"test") is False pc.delete() with mock.patch("os.unlink", side_effect=(OSError(), None)): # Write our data and the __move() will fail under the hood assert pc.write(b"test") is False pc.delete() with mock.patch("os.unlink", side_effect=(OSError(), OSError())): # Write our data and the __move() will fail under the hood assert pc.write(b"test") is False pc.delete() with mock.patch("os.rename", side_effect=(None, OSError(), None)): assert pc.write(b"test") is False with mock.patch("os.rename", side_effect=(None, OSError(), OSError())): assert pc.write(b"test") is False with mock.patch( "os.rename", side_effect=(None, OSError(), FileNotFoundError()) ): assert pc.write(b"test") is False pc.delete() with mock.patch("os.rename", side_effect=(None, None, None, OSError())): # not enough reason to fail assert pc.write(b"test") is True with ( mock.patch("os.stat", side_effect=OSError()), mock.patch("os.close", side_effect=(None, OSError())), ): assert pc.write(b"test") is False pc.delete() with mock.patch( "tempfile._TemporaryFileWrapper.close", autospec=True, side_effect=_flaky_close(always=True), ): assert pc.write(b"test") is False pc.delete() with ( mock.patch( "tempfile._TemporaryFileWrapper.close", autospec=True, side_effect=_flaky_close(), ), mock.patch("os.unlink", side_effect=OSError()), ): assert pc.write(b"test") is False pc.delete() with ( mock.patch( "tempfile._TemporaryFileWrapper.close", autospec=True, side_effect=_flaky_close(), ), mock.patch("os.unlink", side_effect=FileNotFoundError()), ): assert pc.write(b"test") is False def test_persistent_storage_cache_object(tmpdir): """General testing of a CacheObject.""" # A cache object c = CacheObject(123) ref = datetime.now(tz=timezone.utc) expires = ref + timedelta(days=1) # Create a cache object that expires tomorrow c = CacheObject("abcd", expires=expires) assert c.expires == expires assert c.expires_sec > 86390.0 and c.expires_sec <= 86400.0 assert bool(c) is True assert "never" not in str(c) assert "str:+:abcd" in str(c) # # Testing CacheObject.set() # c.set(123) assert "never" not in str(c) assert "int:+:123" in str(c) hash_value = c.hash() assert isinstance(hash_value, str) c.set(124) assert "never" not in str(c) assert "int:+:124" in str(c) assert c.hash() != hash_value c.set(123) # sha is the same again if we set the value back assert c.hash() == hash_value c.set(124) assert isinstance(c.hash(), str) assert c.value == 124 assert bool(c) is True c.set(124, expires=False, persistent=False) assert bool(c) is True assert c.expires is None assert c.expires_sec is None c.set(124, expires=True) # we're expired now assert bool(c) is False # # Testing CacheObject equality (==) # a = CacheObject("abc") b = CacheObject("abc") assert a == b assert a == "abc" assert b == "abc" # Equality is no longer a thing b = CacheObject("abc", 30) assert a != b # however we can look at the value inside assert a == b.value b = CacheObject("abc", persistent=False) a = CacheObject("abc", persistent=True) # Persistent flag matters assert a != b # however we can look at the value inside assert a == b.value b = CacheObject("abc", persistent=True) assert a == b # Epoch EPOCH = datetime(1970, 1, 1) # test all of our supported types (also test time naive and aware times) for entry in ( "string", 123, 1.2222, datetime.now(), datetime.now(tz=timezone.utc), None, False, True, b"\0", ): # Create a cache object that expires tomorrow c = CacheObject(entry, datetime.now() + timedelta(days=1)) # Verify our content hasn't expired assert c # Verify we can dump our object result = json.loads( json.dumps(c, separators=(",", ":"), cls=CacheJSONEncoder) ) # Instantiate our object cc = CacheObject.instantiate(result) assert cc.json() == c.json() # Test our JSON Encoder against items we don't support with pytest.raises(TypeError): json.loads( json.dumps(object(), separators=(",", ":"), cls=CacheJSONEncoder) ) assert CacheObject.instantiate(None) is None assert CacheObject.instantiate({}) is None # Bad data assert ( CacheObject.instantiate({"v": 123, "x": datetime.now(), "c": "int"}) is None ) # object type is not supported assert ( CacheObject.instantiate( { "v": 123, "x": (datetime.now() - EPOCH).total_seconds(), "c": object, } ) is None ) obj = CacheObject.instantiate( {"v": 123, "x": (datetime.now() - EPOCH).total_seconds(), "c": "int"}, verify=False, ) assert isinstance(obj, CacheObject) assert obj.value == 123 # no HASH and verify is set to true; our checksum will fail assert ( CacheObject.instantiate( { "v": 123, "x": (datetime.now() - EPOCH).total_seconds(), "c": "int", }, verify=True, ) is None ) # We can't instantiate our object if the expiry value is bad assert ( CacheObject.instantiate( {"v": 123, "x": "garbage", "c": "int"}, verify=False ) is None ) # We need a valid hash sum too assert ( CacheObject.instantiate( { "v": 123, "x": (datetime.now() - EPOCH).total_seconds(), "c": "int", # Expecting a valid sha string "!": 1.0, }, verify=False, ) is None ) # Our Bytes Object with corruption assert ( CacheObject.instantiate( { "v": "garbage", "x": (datetime.now() - EPOCH).total_seconds(), "c": "bytes", }, verify=False, ) is None ) obj = CacheObject.instantiate( { "v": "AA==", "x": (datetime.now() - EPOCH).total_seconds(), "c": "bytes", }, verify=False, ) assert isinstance(obj, CacheObject) assert obj.value == b"\0" # Test our datetime objects obj = CacheObject.instantiate( { "v": "2024-06-08T01:50:01.587267", "x": (datetime.now() - EPOCH).total_seconds(), "c": "datetime", }, verify=False, ) assert isinstance(obj, CacheObject) assert obj.value == datetime(2024, 6, 8, 1, 50, 1, 587267) # A corrupt datetime object assert ( CacheObject.instantiate( { "v": "garbage", "x": (datetime.now() - EPOCH).total_seconds(), "c": "datetime", }, verify=False, ) is None ) @pytest.mark.skipif( sys.platform == "win32", reason="Unreliable results to be determined" ) def test_persistent_storage_disk_prune(tmpdir): """General testing of a Persistent Store prune calls.""" # Persistent Storage Initialization pc = PersistentStore( path=str(tmpdir), namespace="t01", mode=PersistentStoreMode.FLUSH ) # Store some data assert pc.write(b"data-t01") is True assert pc.set("key-t01", "value") pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Store some data assert pc.write(b"data-t02") is True assert pc.set("key-t02", "value") # purne anything older then 30s results = PersistentStore.disk_prune(path=str(tmpdir), expires=30) # Nothing is older then 30s right now assert isinstance(results, dict) assert "t01" in results assert "t02" in results assert len(results["t01"]) == 0 assert len(results["t02"]) == 0 pc = PersistentStore( path=str(tmpdir), namespace="t01", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t01") == "value" assert pc.read() == b"data-t01" # An expiry of zero gets everything # Note: This test randomly fails in Microsoft Windows for unknown reasons # When this is determined, this test can be opened back up results = PersistentStore.disk_prune(path=str(tmpdir), expires=0) # We match everything now assert isinstance(results, dict) assert "t01" in results assert "t02" in results assert len(results["t01"]) == 2 assert len(results["t02"]) == 2 # Content is still not removed however because no action was put in place pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t02") == "value" assert pc.read() == b"data-t02" pc = PersistentStore( path=str(tmpdir), namespace="t01", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t01") == "value" assert pc.read() == b"data-t01" with mock.patch("os.listdir", side_effect=OSError()): results = PersistentStore.disk_scan( namespace="t01", path=str(tmpdir), closest=True ) assert isinstance(results, list) assert len(results) == 0 with mock.patch("os.listdir", side_effect=FileNotFoundError()): results = PersistentStore.disk_scan( namespace="t01", path=str(tmpdir), closest=True ) assert isinstance(results, list) assert len(results) == 0 # Without closest flag results = PersistentStore.disk_scan( namespace="t01", path=str(tmpdir), closest=False ) assert isinstance(results, list) assert len(results) == 0 # Now we'll filter on specific namespaces results = PersistentStore.disk_prune( namespace="notfound", path=str(tmpdir), expires=0, action=True ) # nothing matched, nothing found assert isinstance(results, dict) assert len(results) == 0 results = PersistentStore.disk_prune( namespace=("t01", "invalid", "-garbag!"), path=str(tmpdir), expires=0, action=True, ) # only t01 would be cleaned now assert isinstance(results, dict) assert len(results) == 1 assert len(results["t01"]) == 2 # A second call will yield no results because the content has # already been cleaned up results = PersistentStore.disk_prune( namespace="t01", path=str(tmpdir), expires=0, action=True ) assert isinstance(results, dict) assert len(results) == 0 # t02 is still untouched pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t02") == "value" assert pc.read() == b"data-t02" # t01 of course... it's gone pc = PersistentStore( path=str(tmpdir), namespace="t01", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t01") is None assert pc.read() is None with pytest.raises(AttributeError): # provide garbage in namespace field and we're going to have a problem PersistentStore.disk_prune( namespace=object, path=str(tmpdir), expires=0, action=True ) # Error Handling with mock.patch("os.path.getmtime", side_effect=FileNotFoundError()): results = PersistentStore.disk_prune( namespace="t02", path=str(tmpdir), expires=0, action=True ) assert isinstance(results, dict) assert len(results) == 1 assert len(results["t02"]) == 0 # no files were removed, so our data is still accessible pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t02") == "value" assert pc.read() == b"data-t02" with mock.patch("os.path.getmtime", side_effect=OSError()): results = PersistentStore.disk_prune( namespace="t02", path=str(tmpdir), expires=0, action=True ) assert isinstance(results, dict) assert len(results) == 1 assert len(results["t02"]) == 0 # no files were removed, so our data is still accessible pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t02") == "value" assert pc.read() == b"data-t02" with mock.patch("os.unlink", side_effect=FileNotFoundError()): results = PersistentStore.disk_prune( namespace="t02", path=str(tmpdir), expires=0, action=True ) assert isinstance(results, dict) assert len(results) == 1 assert len(results["t02"]) == 2 # no files were removed, so our data is still accessible pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t02") == "value" assert pc.read() == b"data-t02" with mock.patch("os.unlink", side_effect=OSError()): results = PersistentStore.disk_prune( namespace="t02", path=str(tmpdir), expires=0, action=True ) assert isinstance(results, dict) assert len(results) == 1 assert len(results["t02"]) == 2 # no files were removed, so our data is still accessible pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t02") == "value" assert pc.read() == b"data-t02" with mock.patch("os.rmdir", side_effect=OSError()): results = PersistentStore.disk_prune( namespace="t02", path=str(tmpdir), expires=0, action=True ) assert isinstance(results, dict) assert len(results) == 1 assert len(results["t02"]) == 2 # no files were removed, so our data is still accessible pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Nothing is pruned assert pc.get("key-t02") is None assert pc.read() is None def test_persistent_storage_disk_changes(tmpdir): """General testing of a Persistent Store with underlining disk changes.""" # Create a garbage file in place of where the namespace should be tmpdir.join("t01").write("0" * 1024) # Persistent Storage Initialization where namespace directory now is # already occupied by a filename pc = PersistentStore( path=str(tmpdir), namespace="t01", mode=PersistentStoreMode.FLUSH ) # Store some data and note that it isn't possible assert pc.write(b"data-t01") is False # We actually fell back to memory mode: assert pc.mode == PersistentStoreMode.MEMORY # Set's work assert pc.set("key-t01", "value") # But upon reinitializtion (enforcing memory mode check) we will not have # the data available to us pc = PersistentStore( path=str(tmpdir), namespace="t01", mode=PersistentStoreMode.FLUSH ) assert pc.get("key-t01") is None # # Test situation where the file structure changed after initialization # pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Our mode stuck as t02 initialized correctly assert pc.mode == PersistentStoreMode.FLUSH assert os.path.isdir(pc.path) shutil.rmtree(pc.path) assert not os.path.isdir(pc.path) assert pc.set("key-t02", "value") # The directory got re-created assert os.path.isdir(pc.path) # Same test but flag set to AUTO pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.AUTO ) # Our mode stuck as t02 initialized correctly assert pc.mode == PersistentStoreMode.AUTO assert os.path.isdir(pc.path) shutil.rmtree(pc.path) assert not os.path.isdir(pc.path) assert pc.set("key-t02", "value") # The directory is not recreated because of auto; it will occur on save assert not os.path.isdir(pc.path) path = pc.path del pc # It exists now assert os.path.isdir(path) pc = PersistentStore( path=str(tmpdir), namespace="t02", mode=PersistentStoreMode.FLUSH ) # Content was not lost assert pc.get("key-t02") == "value" # We'll remove a sub directory of it this time shutil.rmtree(os.path.join(pc.path, pc.temp_dir)) # We will still successfully write our data assert pc.write(b"data-t02") is True assert os.path.isdir(pc.path) shutil.rmtree(pc.path) assert not os.path.isdir(pc.path) assert pc.set("key-t01", "value") caronc-apprise-182f859/tests/test_plugin_africas_talking.py000066400000000000000000000163701524161175200241750ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyType from apprise.plugins.africas_talking import NotifyAfricasTalking logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "atalk://", { # Instantiated but no auth, so no notification can happen "instance": TypeError, }, ), ( "atalk://:@/", { # invalid auth "instance": TypeError }, ), ( "atalk://user@^/", { # invalid apikey "instance": TypeError }, ), ( "atalk://user@apikey/{}".format("3" * 5), { # invalid nubmer provided "instance": NotifyAfricasTalking, # Expected notify() response because we have no one to notify "notify_response": False, }, ), ( "atalk://user@apikey/123/{}/abcd/+{}".format("3" * 11, "4" * 11), { # includes a few invalid bits of info "instance": NotifyAfricasTalking, "privacy_url": "atalk://user@a...y/33333333333/+44444444444", }, ), ( "atalk://user@apikey/+{}?batch=y".format("4" * 11), { "instance": NotifyAfricasTalking, # Our expected url(privacy=True) startswith() response: "privacy_url": "atalk://user@a...y/+44444444444", }, ), ( "atalk://user@apikey/+{}?mode=invalid".format("4" * 11), {"instance": TypeError}, ), ( "atalk://user@apikey/+{}?mode=s".format("4" * 11), { # S will match the sandbox "instance": NotifyAfricasTalking, }, ), ( "atalk://user@apikey/+{}?mode=PREM".format("4" * 11), { # PREM will match premium (not case sensitive) "instance": NotifyAfricasTalking, }, ), ( "atalk://{}?apikey=key&user=user&from=FROMUSER".format("1" * 11), { # use get args to acomplish the same thing "instance": NotifyAfricasTalking, }, ), ( "atalk://_?user=user&to={},{}&key={}&from={}".format( "1" * 11, "2" * 11, "b" * 10, "5" * 13 ), { # use get args to acomplish the same thing "instance": NotifyAfricasTalking, }, ), ( "atalk://user@apikey/{}/".format("1" * 11), { "instance": NotifyAfricasTalking, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "atalk://user@apikey/{}/".format("1" * 11), { "instance": NotifyAfricasTalking, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_atalk_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_atalk_edge_cases(mock_post): """NotifyAfricasTalking() Edge Cases.""" # Initialize some generic (but valid) tokens apikey = "my-api-key" appuser = "my-app-user" targets = [ "+1(555) 123-1234", "1555 5555555", # A garbage entry "12", ] # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Instantiate our object obj = Apprise.instantiate( "atalk://{}@{}/{}?batch=n".format(appuser, apikey, "/".join(targets)) ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # We know there are 2 (valid) targets assert len(obj) == 2 # Test our call count assert mock_post.call_count == 2 # Test details = mock_post.call_args_list[0] headers = details[1]["headers"] assert headers["apiKey"] == apikey payload = details[1]["data"] assert payload["username"] == appuser assert payload["from"] == "AFRICASTKNG" assert payload["to"] == "+15551231234" assert payload["message"] == "title\r\nbody" details = mock_post.call_args_list[1] headers = details[1]["headers"] assert headers["apiKey"] == apikey payload = details[1]["data"] assert payload["username"] == appuser assert payload["from"] == "AFRICASTKNG" assert payload["to"] == "15555555555" assert payload["message"] == "title\r\nbody" # Verify our URL looks good assert obj.url().startswith( "atalk://{}@{}/{}".format( appuser, apikey, "/".join(["+15551231234", "15555555555"]) ) ) assert "batch=no" in obj.url() # Reset our mock object mock_post.reset_mock() # With our batch in place, our calculations are different # Testing URL restructuring here as well where phone # is found # in host obj = Apprise.instantiate( "atalk://{}?user={}&apikey={}&batch=y&from=TEST".format( "/".join(targets), appuser, apikey ) ) # 2 phones were loaded but counted as 1 due to batch flag assert len(obj) == 1 assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Test our call count (batched into 1) assert mock_post.call_count == 1 details = mock_post.call_args_list[0] headers = details[1]["headers"] assert headers["apiKey"] == apikey payload = details[1]["data"] assert payload["username"] == appuser assert payload["from"] == "TEST" assert payload["to"] == "+15551231234,15555555555" assert payload["message"] == "title\r\nbody" caronc-apprise-182f859/tests/test_plugin_apprise_api.py000066400000000000000000000333201524161175200233420ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging import os from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.apprise_api import NotifyAppriseAPI logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "apprise://", { # invalid url (not complete) "instance": None, }, ), # A a bad url ( "apprise://:@/", { "instance": None, }, ), # No token specified ( "apprise://localhost", { "instance": TypeError, }, ), # invalid token ( "apprise://localhost/!", { "instance": TypeError, }, ), # No token specified (whitespace is trimmed) ( "apprise://localhost/%%20", { "instance": TypeError, }, ), # A valid URL with Token ( "apprise://localhost/%s" % ("a" * 32), { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprise://localhost/a...a/", "force_debug": True, }, ), # A valid URL with long Token ( "apprise://localhost/%s" % ("a" * 128), { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprise://localhost/a...a/", }, ), # A valid URL with Token (using port) ( "apprise://localhost:8080/%s" % ("b" * 32), { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprise://localhost:8080/b...b/", }, ), # A secure (https://) reference ( "apprises://localhost/%s" % ("c" * 32), { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprises://localhost/c...c/", }, ), # Native URL suport (https) ( "https://example.com/path/notify/%s" % ("d" * 32), { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprises://example.com/path/d...d/", }, ), # Native URL suport (http) ( "http://example.com/notify/%s" % ("d" * 32), { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprise://example.com/d...d/", }, ), # support to= keyword ( "apprises://localhost/?to=%s" % ("e" * 32), { "instance": NotifyAppriseAPI, "privacy_url": "apprises://localhost/e...e/", }, ), # support token= keyword (even when passed with to=, token over-rides) ( "apprise://localhost/?token={}&to={}".format("f" * 32, "abcd"), { "instance": NotifyAppriseAPI, "privacy_url": "apprise://localhost/f...f/", }, ), # Test tags ( "apprise://localhost/?token={}&tags=admin,team".format("abcd"), { "instance": NotifyAppriseAPI, "privacy_url": "apprise://localhost/a...d/", }, ), # Test Format string ( "apprise://user@localhost/mytoken0/?format=markdown", { "instance": NotifyAppriseAPI, "privacy_url": "apprise://user@localhost/m...0/", }, ), ( "apprise://user@localhost/mytoken1/", { "instance": NotifyAppriseAPI, "privacy_url": "apprise://user@localhost/m...1/", }, ), ( "apprise://localhost:8080/mytoken/", { "instance": NotifyAppriseAPI, }, ), ( "apprise://user:pass@localhost:8080/mytoken2/", { "instance": NotifyAppriseAPI, "privacy_url": "apprise://user:****@localhost:8080/m...2/", }, ), ( "apprises://localhost/mytoken/", { "instance": NotifyAppriseAPI, }, ), ( "apprises://user:pass@localhost/mytoken3/", { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprises://user:****@localhost/m...3/", }, ), ( "apprises://localhost:8080/mytoken4/", { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprises://localhost:8080/m...4/", }, ), ( "apprises://localhost:8080/abc123/?method=json", { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprises://localhost:8080/a...3/", }, ), ( "apprises://localhost:8080/abc123/?method=form", { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprises://localhost:8080/a...3/", }, ), # Invalid method specified ( "apprises://localhost:8080/abc123/?method=invalid", { "instance": TypeError, }, ), ( "apprises://user:password@localhost:8080/mytoken5/", { "instance": NotifyAppriseAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "apprises://user:****@localhost:8080/m...5/", }, ), ( "apprises://localhost:8080/path?+HeaderKey=HeaderValue", { "instance": NotifyAppriseAPI, }, ), ( "apprise://localhost/%s" % ("a" * 32), { "instance": NotifyAppriseAPI, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "apprise://localhost/%s" % ("a" * 32), { "instance": NotifyAppriseAPI, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "apprise://localhost/%s" % ("a" * 32), { "instance": NotifyAppriseAPI, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_apprise_api_urls(): """NotifyAppriseAPI() General Checks.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_notify_apprise_api_payload_check(mock_post): """NotifyAppriseAPI() payload checks""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" # Assign our mock object our return value mock_post.return_value = okay_response obj = Apprise.instantiate("apprise://user@localhost/mytoken1/?method=form") assert isinstance(obj, NotifyAppriseAPI) # Test URL with Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) details = mock_post.call_args_list[0] assert details[0][0] == "http://localhost/notify/mytoken1" assert details[1]["data"] == { "title": "title", "body": "body", "type": "info", "format": "text", } assert "X-Apprise-ID" in details[1]["headers"] assert details[1]["headers"].get("User-Agent") == "Apprise" assert details[1]["headers"].get("Accept") == "application/json" assert details[1]["headers"].get("X-Apprise-Recursion-Count") == "1" mock_post.reset_mock() obj = Apprise.instantiate("apprise://user@localhost/mytoken1/?method=json") assert isinstance(obj, NotifyAppriseAPI) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) details = mock_post.call_args_list[0] assert details[0][0] == "http://localhost/notify/mytoken1" data = loads(details[1]["data"]) assert "attachments" in data assert isinstance(data["attachments"], list) assert len(data["attachments"]) == 1 # Remove our attachment to make the next assert easier del data["attachments"] assert data == { "title": "title", "body": "body", "type": "info", "format": "text", } assert "X-Apprise-ID" in details[1]["headers"] assert details[1]["headers"].get("User-Agent") == "Apprise" assert details[1]["headers"].get("Accept") == "application/json" assert details[1]["headers"].get("X-Apprise-Recursion-Count") == "1" @mock.patch("requests.post") def test_notify_apprise_api_attachments(mock_post): """NotifyAppriseAPI() Attachments.""" okay_response = requests.Request() for method in ("json", "form"): okay_response.status_code = requests.codes.ok okay_response.content = "" # Assign our mock object our return value mock_post.return_value = okay_response obj = Apprise.instantiate( f"apprise://user@localhost/mytoken1/?method={method}" ) assert isinstance(obj, NotifyAppriseAPI) # Test Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test invalid attachment path = os.path.join( TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg" ) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # Test Valid Attachment (load 3) path = ( os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ) attach = AppriseAttachment(path) # Return our good configuration mock_post.side_effect = None mock_post.return_value = okay_response with mock.patch("builtins.open", side_effect=OSError()): # We can't send the message we can't open the attachment for # reading assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) with mock.patch("requests.post", side_effect=OSError()): # Attachment issue assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # test the handling of our batch modes obj = Apprise.instantiate("apprise://user@localhost/mytoken1/") assert isinstance(obj, NotifyAppriseAPI) # Now send an attachment normally without issues mock_post.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert details[0][0] == "http://localhost/notify/mytoken1" assert obj.url(privacy=False).startswith( "apprise://user@localhost/mytoken1/" ) mock_post.reset_mock() caronc-apprise-182f859/tests/test_plugin_aprs.py000066400000000000000000000316471524161175200220250ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import socket from unittest import mock import apprise from apprise.plugins.aprs import NotifyAprs logging.disable(logging.CRITICAL) @mock.patch("socket.create_connection") def test_plugin_aprs_urls(mock_create_connection): """NotifyAprs() Apprise URLs.""" # A socket object sobj = mock.Mock() sobj.return_value = 1 sobj.getpeername.return_value = ("localhost", 1234) sobj.socket_close.return_value = None sobj.setblocking.return_value = True sobj.recv.return_value = "ping\npong pong DF1JSL-15 verified pong".encode( "latin-1" ) sobj.sendall.return_value = True sobj.settimeout.return_value = True # Prepare Mock mock_create_connection.return_value = sobj # Test invalid URLs assert apprise.Apprise.instantiate("aprs://") is None assert apprise.Apprise.instantiate("aprs://:@/") is None # No call-sign specified assert apprise.Apprise.instantiate("aprs://DF1JSL-15:12345") is None # Garbage assert NotifyAprs.parse_url(None) is None # Valid call-sign but no password assert apprise.Apprise.instantiate("aprs://DF1JSL-15:@DF1ABC") is None assert apprise.Apprise.instantiate("aprs://DF1JSL-15@DF1ABC") is None # Password of -1 not supported assert apprise.Apprise.instantiate("aprs://DF1JSL-15:-1@DF1ABC") is None # Alpha Password not supported assert apprise.Apprise.instantiate("aprs://DF1JSL-15:abcd@DF1ABC") is None # Valid instances instance = apprise.Apprise.instantiate("aprs://DF1JSL-15:12345@DF1ABC") assert isinstance(instance, NotifyAprs) assert instance.url(privacy=True).startswith( "aprs://DF1JSL-15:****@D...C?" ) assert instance.notify("test") is True # 1N3 callsigns instance = apprise.Apprise.instantiate("aprs://D1JSL-15:12345@D1ABC") assert isinstance(instance, NotifyAprs) instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC?delay=3.0" ) assert isinstance(instance, NotifyAprs) instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC?delay=2" ) assert isinstance(instance, NotifyAprs) instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC?delay=-3.0" ) assert instance is None instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC?delay=40.0" ) assert instance is None instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC?delay=invalid" ) assert instance is None instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC/DF1DEF" ) assert isinstance(instance, NotifyAprs) assert instance.url(privacy=True).startswith( "aprs://DF1JSL-15:****@D...C/D...F?" ) assert instance.notify("test") is True instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC-1/DF1ABC/DF1ABC-15" ) assert isinstance(instance, NotifyAprs) assert instance.url(privacy=True).startswith( "aprs://DF1JSL-15:****@D...1/D...C/D...5?" ) assert instance.notify("test") is True instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@?to=DF1ABC,DF1DEF" ) assert isinstance(instance, NotifyAprs) assert instance.url(privacy=True).startswith( "aprs://DF1JSL-15:****@D...C/D...F?" ) assert instance.notify("test") is True # Test Locale settings instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC?locale=EURO" ) assert isinstance(instance, NotifyAprs) assert instance.url(privacy=True).startswith( "aprs://DF1JSL-15:****@D...C?" ) # we used the default locale, so no setting assert "locale=" not in instance.url(privacy=True) assert instance.notify("test") is True instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC?locale=NOAM" ) assert isinstance(instance, NotifyAprs) assert instance.url(privacy=True).startswith( "aprs://DF1JSL-15:****@D...C?" ) # locale is set in URL assert "locale=NOAM" in instance.url(privacy=True) assert instance.notify("test") is True # Invalid locale assert ( apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC?locale=invalid" ) is None ) # Invalid call signs instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@abcdefghi/a" ) # We still instantiate assert isinstance(instance, NotifyAprs) # We still load our bad entries assert instance.url(privacy=True).startswith( "aprs://DF1JSL-15:****@A...I/A...A?" ) # But with only bad entries, we have nothing to notify assert instance.notify("test") is False # Enforces a close del instance @mock.patch("socket.create_connection") def test_plugin_aprs_edge_cases(mock_create_connection): """NotifyAprs() Edge Cases.""" # A socket object sobj = mock.Mock() sobj.return_value = 1 sobj.getpeername.return_value = ("localhost", 1234) sobj.socket_close.return_value = None sobj.setblocking.return_value = True sobj.recv.return_value = "ping\npong pong DF1JSL-15 verified pong".encode( "latin-1" ) sobj.sendall.return_value = True sobj.settimeout.return_value = True # Prepare Mock mock_create_connection.return_value = sobj # Valid instances instance = apprise.Apprise.instantiate( "aprs://DF1JSL-15:12345@DF1ABC/DF1DEF" ) assert isinstance(instance, NotifyAprs) # our URL Identifier assert isinstance(instance.url_id(), str) # Objects read assert len(instance) == 2 # Bad data sobj.recv.return_value = "one line".encode("latin-1") assert instance.notify(body="body", title="title") is False sobj.recv.return_value = "\n\n\n".encode("latin-1") assert instance.notify(body="body", title="title") is False sobj.recv.return_value = "".encode("latin-1") assert instance.notify(body="body", title="title") is False sobj.recv.return_value = "\ndata".encode("latin-1") assert instance.notify(body="body", title="title") is False # Different Call-Sign then what we logged in as sobj.recv.return_value = "ping\npong pong DF1JSL-14 verified, pong".encode( "latin-1" ) assert instance.notify(body="body", title="title") is False # Unverified sobj.recv.return_value = ( "ping\npong pong DF1JSL-15 unverified, pong".encode("latin-1") ) assert instance.notify(body="body", title="title") is False # # Test Login edge cases # sobj.return_value = False assert instance.aprsis_login() is False sobj.return_value = 1 sobj.recv.return_value = "".encode("latin-1") assert instance.aprsis_login() is False sobj.recv.return_value = "ping\npong pong DF1JSL-15 verified pong".encode( "latin-1" ) # # Test Socket Send Exceptions # sobj.sendall.return_value = None sobj.sendall.side_effect = socket.gaierror("gaierror") # No connection assert instance.socket_send("data") is False # Ensure we have a connection before calling socket_send() assert instance.socket_open() is True assert instance.socket_send("data") is False sobj.sendall.side_effect = socket.timeout("timeout") assert instance.socket_open() is True assert instance.socket_send("data") is False assert instance.socket_open() is True sobj.sendall.side_effect = OSError("error") assert instance.socket_send("data") is False # Login is impacted by socket_send sobj.return_value = 1 assert instance.socket_open() is True assert instance.aprsis_login() is False # Return some of our sobj.sendall.side_effect = None sobj.sendall.return_value = True assert instance.socket_open() is True sobj.close.return_value = None sobj.close.side_effect = socket.gaierror("gaierror") instance.socket_close() sobj.close.side_effect = socket.timeout("timeout") instance.socket_close() sobj.close.side_effect = OSError("error") instance.socket_close() sobj.return_value = None instance.socket_close() # Socket isn't open; so we can't get content assert instance.socket_receive(100) is False sobj.close.side_effect = None sobj.close.return_value = None # Double close test instance.socket_close() sobj.return_value = 1 mock_create_connection.return_value = None mock_create_connection.side_effect = socket.gaierror("gaierror") assert instance.socket_open() is False assert instance.notify("test") is False mock_create_connection.side_effect = socket.timeout("timeout") assert instance.socket_open() is False assert instance.notify("test") is False mock_create_connection.side_effect = OSError("error") assert instance.socket_open() is False assert instance.notify("test") is False mock_create_connection.side_effect = ConnectionError("ConnectionError") assert instance.socket_open() is False assert instance.notify("test") is False # Restore our good connection mock_create_connection.return_value = sobj mock_create_connection.side_effect = None # Functionality has been restored assert instance.socket_open() is True # Now play with getpeername sobj.getpeername.return_value = None sobj.getpeername.side_effect = ValueError("getpeername ValueError") assert instance.socket_open() is True sobj.getpeername.return_value = ("localhost", 1234) assert instance.socket_open() is True # Test different receive settings assert instance.socket_receive(0) assert instance.socket_receive(-1) assert instance.socket_receive(100) sobj.recv.side_effect = socket.gaierror("gaierror") assert instance.socket_open() is True assert instance.socket_receive(100) is False sobj.recv.side_effect = socket.timeout("timeout") assert instance.socket_open() is True assert instance.socket_receive(100) is False sobj.recv.side_effect = OSError("error") assert instance.socket_open() is True assert instance.socket_receive(100) is False # Restore sobj.recv.side_effect = None sobj.recv.return_value = "ping\npong pong DF1JSL-15 verified pong".encode( "latin-1" ) # Simulate a successful connection, but a failed notification # To do this we need to have a login succeed, but the second call to send # to fail sobj.sendall.return_value = True assert instance.notify("test") is True sobj.sendall.return_value = None sobj.sendall.side_effect = (True, socket.gaierror("gaierror")) assert instance.notify("test") is False sobj.sendall.return_value = True sobj.sendall.side_effect = None del sobj def test_plugin_aprs_config_files(): """NotifyAprs() Config File Cases.""" content = """ urls: - aprs://DF1JSL-15:12345@DF1ABC": - locale: NOAM - aprs://DF1JSL-15:12345@DF1ABC: - locale: SOAM - aprs://DF1JSL-15:12345@DF1ABC: - locale: EURO - aprs://DF1JSL-15:12345@DF1ABC: - locale: ASIA - aprs://DF1JSL-15:12345@DF1ABC: - locale: AUNZ - aprs://DF1JSL-15:12345@DF1ABC: - locale: ROTA # This will fail to load because the locale is bad - aprs://DF1JSL-15:12345@DF1ABC: - locale: aprs_invalid """ # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) assert len(ac.servers()) == 6 assert len(aobj) == 6 caronc-apprise-182f859/tests/test_plugin_bark.py000066400000000000000000000536231524161175200217750ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import base64 import json import logging import re from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyFormat import apprise.plugins.bark as bark_module from apprise.plugins.bark import NotifyBark logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "bark://", { # No no host "instance": None, }, ), ( "bark://:@/", { # just invalid all around "instance": None, }, ), ( "bark://localhost", { # No Device Key specified "instance": NotifyBark, # Expected notify() response False (because we won't be able # to actually notify anything if no device_key was specified "notify_response": False, }, ), ( "bark://192.168.0.6:8081/device_key", { # Everything is okay "instance": NotifyBark, # Our expected url(privacy=True) startswith() response: "privacy_url": "bark://192.168.0.6:8081/", }, ), ( "bark://user@192.168.0.6:8081/device_key", { # Everything is okay (test with user) "instance": NotifyBark, # Our expected url(privacy=True) startswith() response: "privacy_url": "bark://user@192.168.0.6:8081/", }, ), ( "bark://192.168.0.6:8081/device_key/?sound=invalid", { # bad sound, but we go ahead anyway "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?sound=alarm", { # alarm.caf sound loaded "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?sound=NOiR.cAf", { # noir.caf sound loaded "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?badge=100", { # set badge "instance": NotifyBark, }, ), ( "barks://192.168.0.6:8081/device_key/?badge=invalid", { # set invalid badge "instance": NotifyBark, }, ), ( "barks://192.168.0.6:8081/device_key/?badge=-12", { # set invalid badge "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?category=apprise", { # set category "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?image=no", { # do not display image "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?group=apprise", { # set group "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?level=invalid", { # bad level, but we go ahead anyway "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/?to=device_key", { # test use of to= argument "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?click=http://localhost", { # Our click link "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?level=active", { # active level "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?level=critical", { # critical level "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?level=critical&volume=10", { # critical level with volume 10 "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?level=critical&volume=invalid", { # critical level with invalid volume "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?level=critical&volume=11", { # volume > 10 "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?level=critical&volume=-1", { # volume < 0 "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?level=critical&volume=", { # volume None "instance": NotifyBark, }, ), ( "bark://user:pass@192.168.0.5:8086/device_key/device_key2/", { # Everything is okay "instance": NotifyBark, # Our expected url(privacy=True) startswith() response: "privacy_url": "bark://user:****@192.168.0.5:8086/", }, ), ( "barks://192.168.0.7/device_key/", { "instance": NotifyBark, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, # Our expected url(privacy=True) startswith() response: "privacy_url": "barks://192.168.0.7/****", }, ), ( "bark://192.168.0.7/device_key", { "instance": NotifyBark, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "bark://192.168.0.6:8081/device_key/?icon=https://example.com/icon.png", { # set custom icon "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?icon=https://example.com/icon.png&image=no", { # set custom icon and disable default image "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?call=1", { # set call parameter to repeat ringtone "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?call=1&sound=alarm&level=critical", { # set call parameter with other parameters "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?format=markdown", { # enable markdown mode via global format parameter "instance": NotifyBark, }, ), ( "bark://192.168.0.6:8081/device_key/?format=text", { # explicitly set text format (default behavior) "instance": NotifyBark, }, ), ) def test_plugin_bark_urls(): """NotifyBark() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_bark_html_to_markdown_format(mock_post): """NotifyBark(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Load via Apprise URL with markdown format enabled aobj = Apprise() assert aobj.add("bark://192.168.0.6:8081/device_key/?format=markdown") # Notify with an HTML body; the framework converts it to Markdown # before dispatching to the Bark plugin assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown, not stripped plain text payload = json.loads(mock_post.call_args_list[0][1]["data"]) assert payload["markdown"] == "**hello** *world*" @pytest.mark.parametrize( ("key", "expected_ciphertext"), ( ( "k" * 16, "M31imDGJqdEEszZWcWfgiMTooJniyhmvnRIIrUSrWDVi3FxcD9cl1Dyre2wQ8a5I2bhFyBrGPeMEwp1+Uw==", ), ( "k" * 24, "QyuvztP2Yktq2khUWXw6hYrjLnhuc+kC44lyGOaVYBRJC4vsYaB6RewayvX+4ZKjUUIifRWDtIiVFq/KgA==", ), ( "k" * 32, "a9CkBbe9d0qkskyAzKyb4pV/WjH6D1J/JLm0EOUTMcoTpJEs10gqANkkLKaOPqVXL0yo0AaxElYjmLmynw==", ), ), ) def test_plugin_bark_aesgcm_wire_format( key, expected_ciphertext, monkeypatch, ): """NotifyBark() matches Bark's AES-GCM combined wire format.""" pytest.importorskip("cryptography") monkeypatch.setattr( bark_module.secrets, "token_urlsafe", lambda length: "fixed-iv-123", ) instance = NotifyBark( host="localhost", targets=("device-key",), secure=True, encryption_key=key, ) ciphertext, iv = instance._encrypt_payload( {"body": "Encrypted weather", "level": "active"} ) assert iv == "fixed-iv-123" assert ciphertext == expected_ciphertext @pytest.mark.parametrize( "key", ( pytest.param("short", id="too-short"), pytest.param("k" * 17, id="unsupported-length"), pytest.param("πŸ”’" * 16, id="non-ascii-unicode-probe"), pytest.param(b"k" * 16, id="non-string"), ), ) def test_plugin_bark_rejects_invalid_encryption_keys(key): """NotifyBark() rejects keys Bark cannot use as raw AES keys.""" with pytest.raises(TypeError, match="Bark encryption key"): NotifyBark( host="localhost", targets=("device-key",), secure=True, encryption_key=key, ) def test_plugin_bark_encryption_fails_closed_without_cryptography(monkeypatch): """NotifyBark() never downgrades requested encryption to plaintext.""" monkeypatch.setattr(bark_module, "BARK_AESGCM_SUPPORT", False) with pytest.raises(TypeError, match="requires the 'cryptography' package"): NotifyBark( host="localhost", targets=("device-key",), secure=True, encryption_key="k" * 32, ) instance = NotifyBark( host="localhost", targets=("device-key",), secure=True, ) assert instance.encryption_key is None def test_plugin_bark_encryption_url_roundtrip_and_privacy(): """NotifyBark() preserves encryption config without exposing secrets.""" pytest.importorskip("cryptography") device_key = "private-device-key" encryption_key = "k" * 32 instance = NotifyBark( host="localhost", targets=(device_key,), secure=True, encryption_key=encryption_key, ) generated_url = instance.url(privacy=False) assert device_key in generated_url assert encryption_key in generated_url private_url = instance.url(privacy=True) assert device_key not in private_url assert encryption_key not in private_url assert private_url.startswith("barks://localhost/****?") assert "key=" in private_url parsed = NotifyBark.parse_url(generated_url) rebuilt = NotifyBark(**parsed) assert rebuilt.encryption_key == encryption_key assert rebuilt.targets == [device_key] assert rebuilt.url_identifier == instance.url_identifier different_key = NotifyBark( host="localhost", targets=(device_key,), secure=True, encryption_key="x" * 32, ) assert different_key.url_identifier != instance.url_identifier @mock.patch("requests.post") def test_plugin_bark_encrypts_complete_payload_with_fresh_iv_per_target( mock_post, monkeypatch, ): """NotifyBark() encrypts all parameters and rotates IVs per request.""" aesgcm = pytest.importorskip( "cryptography.hazmat.primitives.ciphers.aead" ).AESGCM generated_ivs = iter(("abcdefghijkl", "mnopqrstuvwx")) token_urlsafe = mock.Mock(side_effect=lambda length: next(generated_ivs)) monkeypatch.setattr( bark_module.secrets, "token_urlsafe", token_urlsafe, ) response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response key = "k" * 32 private_body = "Private encrypted body" private_title = "Private encrypted title" targets = ("private-device-one", "private-device-two") instance = NotifyBark( host="localhost", targets=targets, secure=True, encryption_key=key, include_image=False, sound="alarm", category="private-category", group="private-group", level="active", click="https://private.example.invalid", badge=3, volume=4, icon="https://private.example.invalid/icon.png", call=True, ) instance.logger = mock.Mock() assert instance.send(body=private_body, title=private_title) is True assert mock_post.call_count == 2 request_payloads = [ json.loads(call.kwargs["data"]) for call in mock_post.call_args_list ] assert {payload["device_key"] for payload in request_payloads} == set( targets ) assert [payload["iv"] for payload in request_payloads] == [ "abcdefghijkl", "mnopqrstuvwx", ] assert token_urlsafe.call_args_list == [ mock.call(bark_module.BARK_GCM_IV_RANDOM_BYTES), mock.call(bark_module.BARK_GCM_IV_RANDOM_BYTES), ] assert all( re.fullmatch(r"[A-Za-z0-9_-]{12}", payload["iv"]) for payload in request_payloads ) assert all( set(payload) == {"device_key", "ciphertext", "iv"} for payload in request_payloads ) expected_parameters = { "title": private_title, "body": private_body, "sound": "alarm.caf", "url": "https://private.example.invalid", "badge": 3, "level": "active", "category": "private-category", "group": "private-group", "volume": 4, "icon": "https://private.example.invalid/icon.png", "call": 1, } for request_payload in request_payloads: plaintext = aesgcm(key.encode("ascii")).decrypt( request_payload["iv"].encode("ascii"), base64.b64decode(request_payload["ciphertext"]), None, ) assert json.loads(plaintext) == expected_parameters logged = repr(instance.logger.method_calls) for private_value in ( key, private_body, private_title, *targets, "private-category", "private-group", "private.example.invalid", ): assert private_value not in logged @mock.patch("requests.post") def test_plugin_bark_encrypts_markdown_payload(mock_post, monkeypatch): """NotifyBark() encrypts Markdown and does not add a plaintext body.""" aesgcm = pytest.importorskip( "cryptography.hazmat.primitives.ciphers.aead" ).AESGCM monkeypatch.setattr( bark_module.secrets, "token_urlsafe", lambda length: "fixed-iv-123", ) response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response key = "k" * 32 instance = NotifyBark( host="localhost", targets=("device-key",), secure=True, encryption_key=key, include_image=False, format=NotifyFormat.MARKDOWN, ) assert instance.send(body="**encrypted**", title="Title") is True request_payload = json.loads(mock_post.call_args.kwargs["data"]) plaintext = aesgcm(key.encode("ascii")).decrypt( request_payload["iv"].encode("ascii"), base64.b64decode(request_payload["ciphertext"]), None, ) assert json.loads(plaintext) == { "title": "Title", "markdown": "**encrypted**", } @mock.patch("requests.post") def test_plugin_bark_rotates_iv_across_repeated_sends( mock_post, monkeypatch, ): """NotifyBark() generates a fresh IV for every repeated request.""" pytest.importorskip("cryptography") generated_ivs = iter(("abcdefghijkl", "mnopqrstuvwx")) monkeypatch.setattr( bark_module.secrets, "token_urlsafe", lambda length: next(generated_ivs), ) response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response instance = NotifyBark( host="localhost", targets=("device-key",), secure=True, encryption_key="k" * 32, include_image=False, ) assert instance.send(body="same body", title="same title") is True assert instance.send(body="same body", title="same title") is True payloads = [ json.loads(call.kwargs["data"]) for call in mock_post.call_args_list ] assert [payload["iv"] for payload in payloads] == [ "abcdefghijkl", "mnopqrstuvwx", ] assert payloads[0]["ciphertext"] != payloads[1]["ciphertext"] def test_plugin_bark_rejects_incompatible_generated_iv(monkeypatch): """NotifyBark() rejects RNG output Bark cannot decrypt.""" pytest.importorskip("cryptography") monkeypatch.setattr( bark_module.secrets, "token_urlsafe", lambda length: "too-short", ) instance = NotifyBark( host="localhost", targets=("device-key",), secure=True, encryption_key="k" * 32, ) with pytest.raises(ValueError, match="incompatible AES-GCM IV"): instance._encrypt_payload({"body": "private body"}) @mock.patch("requests.post") def test_plugin_bark_encryption_failure_prevents_network_io(mock_post): """NotifyBark() fails closed when payload encryption fails.""" pytest.importorskip("cryptography") instance = NotifyBark( host="localhost", targets=("private-device",), secure=True, encryption_key="k" * 32, include_image=False, ) instance.logger = mock.Mock() instance._encrypt_payload = mock.Mock( side_effect=ValueError("private encryption detail") ) assert instance.send(body="private body", title="private title") is False mock_post.assert_not_called() logged = repr(instance.logger.method_calls) assert "private encryption detail" not in logged assert "private body" not in logged assert "private title" not in logged assert "private-device" not in logged @mock.patch("requests.post") def test_plugin_bark_encrypted_send_http_error(mock_post, monkeypatch): """NotifyBark() suppresses response details when encrypted.""" pytest.importorskip("cryptography") monkeypatch.setattr( bark_module.secrets, "token_urlsafe", lambda length: "fixed-iv-123", ) response = mock.Mock() response.status_code = requests.codes.internal_server_error response.content = b"private response body" mock_post.return_value = response instance = NotifyBark( host="localhost", targets=("private-device",), secure=True, encryption_key="k" * 32, include_image=False, ) instance.logger = mock.Mock() assert instance.send(body="private body", title="private title") is False # The encrypted request still went out; it's the server's response # that failed, not the encryption step assert mock_post.call_count == 1 # A failure warning must still surface, just without the response # body attached to it instance.logger.warning.assert_called_once() logged = repr(instance.logger.method_calls) assert "private response body" not in logged assert "private body" not in logged assert "private title" not in logged assert "private-device" not in logged @mock.patch("requests.post") def test_plugin_bark_encrypted_send_connection_error(mock_post, monkeypatch): """NotifyBark() suppresses socket exception details when encrypted.""" pytest.importorskip("cryptography") monkeypatch.setattr( bark_module.secrets, "token_urlsafe", lambda length: "fixed-iv-123", ) mock_post.side_effect = requests.RequestException("private socket detail") instance = NotifyBark( host="localhost", targets=("private-device",), secure=True, encryption_key="k" * 32, include_image=False, ) instance.logger = mock.Mock() assert instance.send(body="private body", title="private title") is False assert mock_post.call_count == 1 # A failure warning must still surface, just without the raw # exception text attached to it instance.logger.warning.assert_called_once() logged = repr(instance.logger.method_calls) assert "private socket detail" not in logged assert "private body" not in logged assert "private title" not in logged assert "private-device" not in logged def test_plugin_bark_runtime_dependencies(): """NotifyBark() declares its optional cryptography dependency.""" assert NotifyBark.runtime_deps() == ("cryptography",) caronc-apprise-182f859/tests/test_plugin_base_formatting.py000066400000000000000000003661471524161175200242320ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging from random import choice from string import ascii_uppercase as str_alpha, digits as str_num from unittest import mock import pytest import requests from apprise import ( Apprise, AppriseAsset, NotifyBase, NotifyFormat, OverflowMode, ) logging.disable(logging.CRITICAL) def assert_body( source: str, offset: int, chunk_body: str, ) -> int: """ Assert that `chunk_body` comes from `source` starting at `offset`, accounting for leading/trailing vertical whitespace that _apply_overflow trims away. Returns the updated offset. """ segment = source[offset : offset + len(chunk_body)] ws_diff = len(segment) - len(segment.lstrip("\r\n\x0b\x0c").rstrip()) assert ( source[offset : offset + len(chunk_body) + ws_diff] .lstrip("\r\n\x0b\x0c") .rstrip() == chunk_body ) return offset + len(chunk_body) + ws_diff def test_notify_overflow_truncate_with_amalgamation(): """ API: Overflow With Amalgamation Truncate Functionality Testing """ # # A little preparation # # Number of characters per line row = 24 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num + " ") for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content # back to being 1024 characters. body = body[0:1024] # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) # # First Test: Truncated Title # class TestNotification(NotifyBase): # Test title max length title_maxlen = 10 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # We should throw an exception because our specified overflow is wrong. with pytest.raises(TypeError): # Load our object obj = TestNotification(overflow="invalid") # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title, overflow=None) chunks = obj._apply_overflow(body="", title="", overflow=None) chunks = obj._apply_overflow(body=body, title="", overflow=None) chunks = obj._apply_overflow(body=body, title=title, overflow=None) chunks = obj._apply_overflow( body=body, title=title, overflow=OverflowMode.SPLIT ) assert len(chunks) == 1 assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Line Count Control # class TestNotification(NotifyBase): # Test title max length title_maxlen = 5 # Maximum number of lines body_max_line_count = 5 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert len(chunks[0].get("body").split("\n")) == obj.body_max_line_count assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Truncated body # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length of just 10 body_maxlen = 10 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 # Body is lost as the title prevails over everything assert chunks[0].get("body") == "" # Title is not longer then the maximum size the body can be due to # amalgamationflag: assert title[: obj.body_maxlen] == chunks[0].get("title") class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length of just 10 body_maxlen = 10 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert chunks[0].get("body") == "" # body_maxlen prevails due to it being smaller and amalgamation flag set assert title[0 : obj.body_maxlen] == chunks[0].get("title") # # Next Test: Append title to body + Truncated body # class TestNotification(NotifyBase): # Enforce no title title_maxlen = 0 # Enforce a body length of just 100 body_maxlen = 100 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched obj.notify_format = NotifyFormat.HTML chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 obj.notify_format = NotifyFormat.MARKDOWN chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 obj.notify_format = NotifyFormat.TEXT chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 # The below line should be read carefully... We're actually testing to see # that our title is matched against our body. Behind the scenes, the title # was appended to the body. The body was then truncated to the maxlen. # The thing is, since the title is so large, all of the body was lost # and a good chunk of the title was too. The message sent will just be a # small portion of the title assert len(chunks[0].get("body")) == obj.body_maxlen assert title[0 : obj.body_maxlen] == chunks[0].get("body") def test_notify_overflow_truncate_no_amalgamation(): """ API: Overflow No Amalgamation Truncate Functionality Testing """ # # A little preparation # # Number of characters per line row = 24 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num + " ") for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content # back to being 1024 characters. body = body[0:1024] # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) # # First Test: Truncated Title # class TestNotification(NotifyBase): # Test title max length title_maxlen = 10 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # We should throw an exception because our specified overflow is wrong. with pytest.raises(TypeError): # Load our object obj = TestNotification(overflow="invalid") # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title, overflow=None) chunks = obj._apply_overflow(body="", title="", overflow=None) chunks = obj._apply_overflow(body=body, title="", overflow=None) chunks = obj._apply_overflow(body=body, title=title, overflow=None) chunks = obj._apply_overflow( body=body, title=title, overflow=OverflowMode.SPLIT ) assert len(chunks) == 1 assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Line Count Control # class TestNotification(NotifyBase): # Test title max length title_maxlen = 5 # Maximum number of lines body_max_line_count = 5 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert len(chunks[0].get("body").split("\n")) == obj.body_max_line_count assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Truncated body # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length of just 10 body_maxlen = 10 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert body[0 : obj.body_maxlen].lstrip("\r\n\x0b\x0c").rstrip() == chunks[ 0 ].get("body") assert title == chunks[0].get("title") class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length of just 10 body_maxlen = 10 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 # No amalgamation set so our body aligns in size (no -2 like previous # test) assert body[0 : obj.body_maxlen].lstrip("\r\n\x0b\x0c").rstrip() == chunks[ 0 ].get("body") assert title == chunks[0].get("title") # # Next Test: Append title to body + Truncated body # class TestNotification(NotifyBase): # Enforce no title title_maxlen = 0 # Enforce a body length of just 100 body_maxlen = 100 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.TRUNCATE) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched obj.notify_format = NotifyFormat.HTML chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 obj.notify_format = NotifyFormat.MARKDOWN chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 obj.notify_format = NotifyFormat.TEXT chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 # The below line should be read carefully... We're actually testing to see # that our title is matched against our body. Behind the scenes, the title # was appended to the body. The body was then truncated to the maxlen. # The thing is, since the title is so large, all of the body was lost # and a good chunk of the title was too. The message sent will just be a # small portion of the title assert len(chunks[0].get("body")) == obj.body_maxlen assert title[0 : obj.body_maxlen] == chunks[0].get("body") def test_notify_overflow_split_with_amalgamation(): """ API: Overflow With Amalgamation Splits Functionality Testing """ # # A little preparation # # Number of characters per line row = 24 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num) for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content # back to being 1024 characters. body = body[0:1024] # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) # # First Test: Truncated Title # class TestNotification(NotifyBase): # Test title max length title_maxlen = 10 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Line Count Control # class TestNotification(NotifyBase): # Test title max length title_maxlen = 5 # Maximum number of lines body_max_line_count = 5 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert len(chunks[0].get("body").split("\n")) == obj.body_max_line_count assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Split body # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # No counter is displayed because our title is so enormous # We switch to a display title on first message only if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The length of the body prevails our title due to it being # so much smaller then our title length assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Another edge case where the title just isn't that long leaving # a lot of space for the [xx/xx] entries (no truncation needed) chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title[:20]) c_len = len(" [X/X]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert title[:20] == chunk.get("title")[:-c_len] assert chunk.get("title")[-c_len:] == f" [{idx:01}/{len(chunks):01}]" # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Test forcing overflow_display_title_once # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Only display title once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The body length prevails due to our amalgamation flag assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 400 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) c_len = len(" [XXXX/XXXX]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert ( title[: obj.title_maxlen][:-c_len] == chunk.get("title")[:-c_len] ) assert chunk.get("title")[-c_len:] == f" [{idx:04}/{len(chunks):04}]" # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off new_body = body * 2500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 150 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 5 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # # Next Test: Append title to body + split body # class TestNotification(NotifyBase): # Enforce no title title_maxlen = 0 # Enforce a body length based on the title. Make sure it's an int. body_maxlen = int(title_len / 4) # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) # Our final product is that our title has been appended to our body to # create one great big body. As a result we'll get quite a few lines back # now. offset = 0 # Our body will look like this in small chunks at the end of the day bulk = title + "\r\n" + body # Due to the new line added to the end assert len(chunks) == ( # wrap division in int() so Python 3 doesn't convert it to a float on # us int(len(bulk) / obj.body_maxlen) + (1 if len(bulk) % obj.body_maxlen else 0) ) for chunk in chunks: # Verification assert len(chunk.get("title")) == 0 assert len(chunk.get("body")) <= obj.body_maxlen # Our title is empty every time assert chunk.get("title") == "" # Our body is only broken up; not lost offset = assert_body( bulk, offset, chunk.get("body"), ) # # Test case where our title_len is shorter then the value # that would otherwise trigger the [XX/XX] elements # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title is truncated and no counter added assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Scenario where the title length is larger than the body # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = 50 # With amalgamation overflow_amalgamate_title = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it due to it's length if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to 50 due # to amalamation. The lowest value always prevails assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) def test_notify_overflow_split_with_amalgamation_force_title_always(): """ API: Overflow With Amalgamation (title alaways Split Functionality Testing """ # # A little preparation # # Number of characters per line row = 24 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num) for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content # back to being 1024 characters. body = body[0:1024] # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) # # First Test: Truncated Title # class TestNotification(NotifyBase): # Test title max length title_maxlen = 10 # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Line Count Control # class TestNotification(NotifyBase): # Test title max length title_maxlen = 5 # Maximum number of lines body_max_line_count = 5 # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert len(chunks[0].get("body").split("\n")) == obj.body_max_line_count assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Split body # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # No counter is displayed because our title is so enormous # We switch to a display title on first message only if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The length of the body prevails our title due to it being # so much smaller then our title length assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Another edge case where the title just isn't that long leaving # a lot of space for the [xx/xx] entries (no truncation needed) chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title[:20]) offset = 0 c_len = len(" [X/X]") assert len(chunks) == 5 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert title[:20] == chunk.get("title")[:-c_len] assert chunk.get("title")[-c_len:] == f" [{idx:01}/{len(chunks):01}]" # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Test forcing overflow_display_title_once # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The body length prevails due to our amalgamation flag assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 400 # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) c_len = len(" [XXXX/XXXX]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert ( title[: obj.title_maxlen][:-c_len] == chunk.get("title")[:-c_len] ) assert chunk.get("title")[-c_len:] == f" [{idx:04}/{len(chunks):04}]" # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off new_body = body * 2500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 150 # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 5 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) # overflow_display_title_once whle set to False is still ignored # because our title_maxlen > body_maxlen and a full title was # provided offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 # overflow_display_title_once whle set to False is still ignored # because our title_maxlen > body_maxlen and a full title was # provided for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # # Run again but with a smaller title # chunks = obj._apply_overflow(body=new_body, title=title[:30]) # overflow_display_title_once whle set to False is still ignored # because our body_maxlen (after title has been calculated with it) # is less then the overflow_display_count_threshold; hence a message # must be a certain minimum size in order to kick in offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title doesn't change assert len(chunk.get("title")) == 30 assert title[:30] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # # Next Test: Append title to body + split body # class TestNotification(NotifyBase): # Enforce no title title_maxlen = 0 # Enforce a body length based on the title. Make sure it's an int. body_maxlen = int(title_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) # Our final product is that our title has been appended to our body to # create one great big body. As a result we'll get quite a few lines back # now. offset = 0 # Our body will look like this in small chunks at the end of the day bulk = title + "\r\n" + body # Due to the new line added to the end assert len(chunks) == ( # wrap division in int() so Python 3 doesn't convert it to a float on # us int(len(bulk) / obj.body_maxlen) + (1 if len(bulk) % obj.body_maxlen else 0) ) for chunk in chunks: # Verification assert len(chunk.get("title")) == 0 assert len(chunk.get("body")) <= obj.body_maxlen # Our title is empty every time assert chunk.get("title") == "" # Our body is only broken up; not lost offset = assert_body( bulk, offset, chunk.get("body"), ) # # Test case where our title_len is shorter then the value # that would otherwise trigger the [XX/XX] elements # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title is truncated and no counter added assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Scenario where the title length is larger than the body # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = 50 # With amalgamation overflow_amalgamate_title = True # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 # overflow_display_title_once whle set to False is still ignored # because our title_maxlen > body_maxlen and a full title was # provided for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it due to it's length if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to 50 due # to amalamation. The lowest value always prevails assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) def test_notify_overflow_split_with_amalgamation_force_title_once(): """ API: Overflow With Amalgamation (title once) Split Functionality Testing """ # # A little preparation # # Number of characters per line row = 24 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num) for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content # back to being 1024 characters. body = body[0:1024] # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) # # First Test: Truncated Title # class TestNotification(NotifyBase): # Test title max length title_maxlen = 10 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Line Count Control # class TestNotification(NotifyBase): # Test title max length title_maxlen = 5 # Maximum number of lines body_max_line_count = 5 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert len(chunks[0].get("body").split("\n")) == obj.body_max_line_count assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Split body # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # No counter is displayed because our title is so enormous # We switch to a display title on first message only if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The length of the body prevails our title due to it being # so much smaller then our title length assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Another edge case where the title just isn't that long leaving # a lot of space for the [xx/xx] entries (no truncation needed) chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title[:20]) offset = 0 assert len(chunks) == 5 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The body length prevails due to our amalgamation flag assert len(chunk.get("title")) == 20 assert title[:20] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Test forcing overflow_display_title_once # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Only display title once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The body length prevails due to our amalgamation flag assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 400 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The body length prevails due to our amalgamation flag assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off new_body = body * 2500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The body length prevails due to our amalgamation flag assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 150 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 5 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # # Next Test: Append title to body + split body # class TestNotification(NotifyBase): # Enforce no title title_maxlen = 0 # Enforce a body length based on the title. Make sure it's an int. body_maxlen = int(title_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) # Our final product is that our title has been appended to our body to # create one great big body. As a result we'll get quite a few lines back # now. offset = 0 # Our body will look like this in small chunks at the end of the day bulk = title + "\r\n" + body # Due to the new line added to the end assert len(chunks) == ( # wrap division in int() so Python 3 doesn't convert it to a float on # us int(len(bulk) / obj.body_maxlen) + (1 if len(bulk) % obj.body_maxlen else 0) ) for chunk in chunks: # Verification assert len(chunk.get("title")) == 0 assert len(chunk.get("body")) <= obj.body_maxlen # Our title is empty every time assert chunk.get("title") == "" # Our body is only broken up; not lost offset = assert_body( bulk, offset, chunk.get("body"), ) # # Test case where our title_len is shorter then the value # that would otherwise trigger the [XX/XX] elements # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The body length prevails due to our amalgamation flag assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Scenario where the title length is larger than the body # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = 50 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it due to it's length if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to 50 due # to amalamation. The lowest value always prevails assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) def test_notify_overflow_split_no_amalgamation(): """ API: Overflow No Amalgamation Splits Functionality Testing """ # # A little preparation # # Number of characters per line row = 24 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num) for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content # back to being 1024 characters. body = body[0:1024] # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) # # First Test: Truncated Title # class TestNotification(NotifyBase): # Test title max length title_maxlen = 10 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Line Count Control # class TestNotification(NotifyBase): # Test title max length title_maxlen = 5 # Maximum number of lines body_max_line_count = 5 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert len(chunks[0].get("body").split("\n")) == obj.body_max_line_count assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Split body # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) c_len = len(" [X/X]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert title[:-c_len] == chunk.get("title")[:-c_len] assert chunk.get("title")[-c_len:] == f" [{idx:01}/{len(chunks):01}]" # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Another edge case where the title just isn't that long leaving # a lot of space for the [xx/xx] entries (no truncation needed) chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title[:20]) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert title[:20] == chunk.get("title")[:-c_len] assert chunk.get("title")[-c_len:] == f" [{idx:01}/{len(chunks):01}]" # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 400 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) c_len = len(" [XXXX/XXXX]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert ( title[: obj.title_maxlen][:-c_len] == chunk.get("title")[:-c_len] ) assert chunk.get("title")[-c_len:] == f" [{idx:04}/{len(chunks):04}]" # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off new_body = body * 4500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 150 # No amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 5 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) c_len = len(" [XX/XX]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert ( title[: obj.title_maxlen][:-c_len] == chunk.get("title")[:-c_len] ) assert chunk.get("title")[-c_len:] == f" [{idx:02}/{len(chunks):02}]" # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # # Next Test: Append title to body + split body # class TestNotification(NotifyBase): # Enforce no title title_maxlen = 0 # Enforce a body length based on the title. Make sure it's an int. body_maxlen = int(title_len / 4) # No Amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) # Our final product is that our title has been appended to our body to # create one great big body. As a result we'll get quite a few lines back # now. offset = 0 # Our body will look like this in small chunks at the end of the day bulk = title + "\r\n" + body # Due to the new line added to the end assert len(chunks) == ( # wrap division in int() so Python 3 doesn't convert it to a float on # us int(len(bulk) / obj.body_maxlen) + (1 if len(bulk) % obj.body_maxlen else 0) ) for chunk in chunks: # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title is empty every time assert chunk.get("title") == "" # Our body is only broken up; not lost offset = assert_body( bulk, offset, chunk.get("body"), ) # # Test case where our title_len is shorter then the value # that would otherwise trigger the [XX/XX] elements # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # No Amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title is truncated and no counter added assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Scenario where the title length is larger than the body # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = 50 # No Amalgamation overflow_amalgamate_title = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title is truncated and no counter added assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) def test_notify_overflow_split_no_amalgamation_force_title_always(): """ API: Overflow No Amalgamation (title always) Split Functionality Testing """ # # A little preparation # # Number of characters per line row = 24 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num) for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content # back to being 1024 characters. body = body[0:1024] # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) # # First Test: Truncated Title # class TestNotification(NotifyBase): # Test title max length title_maxlen = 10 # No amalgamation overflow_amalgamate_title = False # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Line Count Control # class TestNotification(NotifyBase): # Test title max length title_maxlen = 5 # Maximum number of lines body_max_line_count = 5 # No amalgamation overflow_amalgamate_title = False # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert len(chunks[0].get("body").split("\n")) == obj.body_max_line_count assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Split body # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # No amalgamation overflow_amalgamate_title = False # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) c_len = len(" [X/X]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert title[:-c_len] == chunk.get("title")[:-c_len] assert chunk.get("title")[-c_len:] == f" [{idx:01}/{len(chunks):01}]" # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Another edge case where the title just isn't that long leaving # a lot of space for the [xx/xx] entries (no truncation needed) chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title[:20]) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert title[:20] == chunk.get("title")[:-c_len] assert chunk.get("title")[-c_len:] == f" [{idx:01}/{len(chunks):01}]" # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 400 # No amalgamation overflow_amalgamate_title = False # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) c_len = len(" [XXXX/XXXX]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert ( title[: obj.title_maxlen][:-c_len] == chunk.get("title")[:-c_len] ) assert chunk.get("title")[-c_len:] == f" [{idx:04}/{len(chunks):04}]" # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off new_body = body * 4500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 150 # No amalgamation overflow_amalgamate_title = False # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 5 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) c_len = len(" [XX/XX]") offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has a counter added to it assert ( title[: obj.title_maxlen][:-c_len] == chunk.get("title")[:-c_len] ) assert chunk.get("title")[-c_len:] == f" [{idx:02}/{len(chunks):02}]" # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # # Next Test: Append title to body + split body # class TestNotification(NotifyBase): # Enforce no title title_maxlen = 0 # Enforce a body length based on the title. Make sure it's an int. body_maxlen = int(title_len / 4) # No Amalgamation overflow_amalgamate_title = False # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) # Our final product is that our title has been appended to our body to # create one great big body. As a result we'll get quite a few lines back # now. offset = 0 # Our body will look like this in small chunks at the end of the day bulk = title + "\r\n" + body # Due to the new line added to the end assert len(chunks) == ( # wrap division in int() so Python 3 doesn't convert it to a float on # us int(len(bulk) / obj.body_maxlen) + (1 if len(bulk) % obj.body_maxlen else 0) ) for chunk in chunks: # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title is empty every time assert chunk.get("title") == "" # Our body is only broken up; not lost offset = assert_body( bulk, offset, chunk.get("body"), ) # # Test case where our title_len is shorter then the value # that would otherwise trigger the [XX/XX] elements # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # No Amalgamation overflow_amalgamate_title = False # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title is truncated and no counter added assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Scenario where the title length is larger than the body # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = 50 # No Amalgamation overflow_amalgamate_title = False # Force title to be displayed always overflow_display_title_once = False def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for _idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title is truncated and no counter added assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) def test_notify_overflow_split_no_amalgamation_force_title_once(): """ API: Overflow No Amalgamation (title once) Split Functionality Testing """ # # A little preparation # # Number of characters per line row = 24 # Some variables we use to control the data we work with body_len = 1024 title_len = 1024 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num) for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content # back to being 1024 characters. body = body[0:1024] # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) # # First Test: Truncated Title # class TestNotification(NotifyBase): # Test title max length title_maxlen = 10 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Line Count Control # class TestNotification(NotifyBase): # Test title max length title_maxlen = 5 # Maximum number of lines body_max_line_count = 5 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 assert len(chunks[0].get("body").split("\n")) == obj.body_max_line_count assert title[0 : obj.title_maxlen] == chunks[0].get("title") # # Next Test: Split body # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # No counter is displayed because our title is so enormous # We switch to a display title on first message only if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The length of the body prevails our title due to it being # so much smaller then our title length assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Another edge case where the title just isn't that long leaving # a lot of space for the [xx/xx] entries (no truncation needed) chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title[:20]) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # No counter is displayed because our title is so enormous # We switch to a display title on first message only if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The length of the body prevails our title due to it being # so much smaller then our title length assert len(chunk.get("title")) == 20 assert title[:20] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # # Test forcing overflow_display_title_once # class TestNotification(NotifyBase): # Test title max length title_maxlen = title_len # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The body length prevails due to our amalgamation flag assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 400 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # No counter is displayed because our title is so enormous # We switch to a display title on first message only if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The length of the body prevails our title due to it being # so much smaller then our title length assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off new_body = body * 2500 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # No counter is displayed because our title is so enormous # We switch to a display title on first message only if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The length of the body prevails our title due to it being # so much smaller then our title length assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # Test larger messages # and that the body remains untouched class TestNotification(NotifyBase): # Test title max length title_maxlen = 150 # Enforce a body length. Make sure it's an int. body_maxlen = 150 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None new_body = body * 5 chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=new_body, title="") chunks = obj._apply_overflow(body=new_body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # # Next Test: Append title to body + split body # class TestNotification(NotifyBase): # Enforce no title title_maxlen = 0 # Enforce a body length based on the title. Make sure it's an int. body_maxlen = int(title_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) # Our final product is that our title has been appended to our body to # create one great big body. As a result we'll get quite a few lines back # now. offset = 0 # Our body will look like this in small chunks at the end of the day bulk = title + "\r\n" + body # Due to the new line added to the end assert len(chunks) == ( # wrap division in int() so Python 3 doesn't convert it to a float on # us int(len(bulk) / obj.body_maxlen) + (1 if len(bulk) % obj.body_maxlen else 0) ) for chunk in chunks: # Verification assert len(chunk.get("title")) == 0 assert len(chunk.get("body")) <= obj.body_maxlen # Our title is empty every time assert chunk.get("title") == "" # Our body is only broken up; not lost offset = assert_body( bulk, offset, chunk.get("body"), ) # # Test case where our title_len is shorter then the value # that would otherwise trigger the [XX/XX] elements # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = int(body_len / 4) # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # No counter is displayed because our title is so enormous # We switch to a display title on first message only if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # The length of the body prevails our title due to it being # so much smaller then our title length assert len(chunk.get("title")) == obj.title_maxlen assert title[: obj.title_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) # # Scenario where the title length is larger than the body # class TestNotification(NotifyBase): # Set a small title length title_maxlen = 100 # Enforce a body length. Make sure it's an int. body_maxlen = 50 # With amalgamation overflow_amalgamate_title = True # Force title displayed once overflow_display_title_once = True def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestNotification(overflow=OverflowMode.SPLIT) assert obj is not None # Verify that we break the title to a max length of our title_max # and that the body remains untouched chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) offset = 0 for idx, chunk in enumerate(chunks, start=1): # Verification assert len(chunk.get("title")) <= obj.title_maxlen assert len(chunk.get("body")) <= obj.body_maxlen # Our title has no counter added to it due to it's length if idx > 1: # Empty (no title displayed on following entries assert chunk.get("title") == "" else: # Because 150 is what we set the title limit to 50 due # to amalamation. The lowest value always prevails assert len(chunk.get("title")) == obj.body_maxlen assert title[: obj.body_maxlen] == chunk.get("title") # Our body is only broken up; not lost offset = assert_body( new_body, offset, chunk.get("body"), ) def test_notify_markdown_general(): """ API: Markdown General Testing """ # # A little preparation # # # First Test: Truncated Title # class TestMarkdownNotification(NotifyBase): # Force our title to wrap title_maxlen = 0 # Default Notify Format notify_format = NotifyFormat.MARKDOWN def __init__(self, *args, **kwargs): super().__init__(**kwargs) def notify(self, *args, **kwargs): # Pretend everything is okay return True # Load our object obj = TestMarkdownNotification() assert obj is not None # A bad header title = " # " body = "**Test Body**" chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 # whitspace is trimmed assert chunks[0].get("body") == "#\r\n**Test Body**" assert chunks[0].get("title") == "" # If we know our input is text however, we perform manipulation chunks = obj._apply_overflow(body="", title=title) chunks = obj._apply_overflow(body="", title="") chunks = obj._apply_overflow(body=body, title="") chunks = obj._apply_overflow( body=body, title=title, body_format=NotifyFormat.TEXT ) assert len(chunks) == 1 # Our title get's stripped off since it's not of valid markdown assert body.lstrip("\r\n\x0b\x0c").rstrip() == chunks[0].get("body") assert chunks[0].get("title") == "" @mock.patch("requests.request") def test_notify_emoji_general(mock_request): """ API: Emoji General Testing """ # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = response # Set up our emojis title = ":smile:" body = ":grin:" # general reference used below (using default values) asset = AppriseAsset() # # Test default emoji asset value # # Load our object ap_obj = Apprise(asset=asset) ap_obj.add("json://localhost") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # No changes assert dataset["title"] == title assert dataset["message"] == body mock_request.reset_mock() # # Test URL over-ride while default value set in asset # # Load our object ap_obj = Apprise(asset=asset) ap_obj.add("json://localhost?emojis=no") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # No changes assert dataset["title"] == title assert dataset["message"] == body mock_request.reset_mock() # # Test URL over-ride while default value set in asset pt 2 # # Load our object ap_obj = Apprise(asset=asset) ap_obj.add("json://localhost?emojis=yes") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # Emoji's are displayed assert dataset["title"] == "πŸ˜„" assert dataset["message"] == "πŸ˜ƒ" mock_request.reset_mock() # # Test URL over-ride while default value set in asset pt 2 # # Load our object ap_obj = Apprise(asset=asset) ap_obj.add("json://localhost?emojis=no") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # No changes assert dataset["title"] == title assert dataset["message"] == body mock_request.reset_mock() # # Test Default Emoji settings # # Set our interpret emoji's flag asset = AppriseAsset(interpret_emojis=True) # Re-create our object ap_obj = Apprise(asset=asset) # Load our object ap_obj.add("json://localhost") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # emoji's are displayed assert dataset["title"] == "πŸ˜„" assert dataset["message"] == "πŸ˜ƒ" mock_request.reset_mock() # # With Emoji's turned on by default, the user can optionally turn them # off. # # Re-create our object ap_obj = Apprise(asset=asset) ap_obj.add("json://localhost?emojis=no") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # No changes assert dataset["title"] == title assert dataset["message"] == body mock_request.reset_mock() # # With Emoji's turned on by default, there is no change when emojis # flag is set to on # # Re-create our object ap_obj = Apprise(asset=asset) ap_obj.add("json://localhost?emojis=yes") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # emoji's are displayed assert dataset["title"] == "πŸ˜„" assert dataset["message"] == "πŸ˜ƒ" mock_request.reset_mock() # # Enforce the disabling of emojis # # Set our interpret emoji's flag asset = AppriseAsset(interpret_emojis=False) # Re-create our object ap_obj = Apprise(asset=asset) # Load our object ap_obj.add("json://localhost") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # Disabled - no emojis assert dataset["title"] == title assert dataset["message"] == body mock_request.reset_mock() # # Enforce the disabling of emojis # # Set our interpret emoji's flag asset = AppriseAsset(interpret_emojis=False) # Re-create our object ap_obj = Apprise(asset=asset) # Load our object ap_obj.add("json://localhost?emojis=yes") assert len(ap_obj) == 1 assert ap_obj.notify(title=title, body=body) is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" dataset = json.loads(details[1]["data"]) # Disabled - no emojis assert dataset["title"] == title assert dataset["message"] == body mock_request.reset_mock() caronc-apprise-182f859/tests/test_plugin_blink1.py000066400000000000000000000334451524161175200222360ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import sys from unittest import mock from helpers import AppriseURLTester import pytest from apprise import Apprise, NotifyType from apprise.plugins.blink1 import ( BLINK1_DEFAULT_DURATION_MS, BLINK1_DEFAULT_FADE_MS, BLINK1_MAX_DURATION_MS, BLINK1_MAX_FADE_MS, BLINK1_REPORT_SIZE, Blink1LED, NotifyBlink1, _blink1_fade_buf, ) logging.disable(logging.CRITICAL) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _mk_device(report_size=BLINK1_REPORT_SIZE, open_exc=None): """Return a mock hidapi device that succeeds by default.""" dev = mock.Mock() dev.send_feature_report.return_value = report_size if open_exc is not None: dev.open.side_effect = open_exc return dev # --------------------------------------------------------------------------- # URL matrix used by AppriseURLTester # --------------------------------------------------------------------------- apprise_url_tests = ( # First available device, all defaults ( "blink1://", { "instance": NotifyBlink1, }, ), # Explicit underscore placeholder for first device ( "blink1://_/", { "instance": NotifyBlink1, }, ), # Specific device by serial number ( "blink1://ABCD1234/", { "instance": NotifyBlink1, }, ), # Minimum allowed parameter values ( "blink1://?duration=0&fade=0&ledn=0", { "instance": NotifyBlink1, }, ), # LED 1 (first LED only) ( "blink1://?ledn=1", { "instance": NotifyBlink1, }, ), # LED 2 (second LED only) ( "blink1://?ledn=2", { "instance": NotifyBlink1, }, ), # Maximum allowed duration ( f"blink1://?duration={BLINK1_MAX_DURATION_MS}", { "instance": NotifyBlink1, }, ), # Maximum allowed fade ( f"blink1://?fade={BLINK1_MAX_FADE_MS}", { "instance": NotifyBlink1, }, ), # Non-numeric duration -> TypeError ( "blink1://?duration=abc", { "instance": TypeError, }, ), # Negative duration -> TypeError ( "blink1://?duration=-1", { "instance": TypeError, }, ), # Duration exceeds maximum -> TypeError ( f"blink1://?duration={BLINK1_MAX_DURATION_MS + 1}", { "instance": TypeError, }, ), # Non-numeric fade -> TypeError ( "blink1://?fade=abc", { "instance": TypeError, }, ), # Negative fade -> TypeError ( "blink1://?fade=-1", { "instance": TypeError, }, ), # Fade exceeds maximum -> TypeError ( f"blink1://?fade={BLINK1_MAX_FADE_MS + 1}", { "instance": TypeError, }, ), ) # --------------------------------------------------------------------------- # Import-error path (run only when hidapi is NOT installed) # --------------------------------------------------------------------------- @pytest.mark.skipif( "hid" in sys.modules, reason="Requires that hidapi NOT be installed", ) def test_plugin_blink1_import_error(): """NotifyBlink1() returns None when hidapi is absent.""" obj = Apprise.instantiate("blink1://") assert obj is None # --------------------------------------------------------------------------- # All remaining tests require hidapi to be installed # --------------------------------------------------------------------------- def test_plugin_blink1_urls(): """NotifyBlink1() Apprise URL matrix.""" pytest.importorskip("hid") with ( mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid, mock.patch("apprise.plugins.blink1.time") as mock_time, ): mock_dev = _mk_device() mock_hid.device.return_value = mock_dev mock_time.sleep.return_value = None AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_blink1_device_open_failure(): """NotifyBlink1() returns False when the USB device cannot be opened.""" pytest.importorskip("hid") with mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid: mock_dev = _mk_device(open_exc=OSError("no device")) mock_hid.device.return_value = mock_dev obj = Apprise.instantiate("blink1://ABCD1234/") assert obj is not None assert obj.notify(body="test") is False def test_plugin_blink1_device_open_failure_no_serial(): """NotifyBlink1() failure log omits serial when none is configured.""" pytest.importorskip("hid") with mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid: mock_dev = _mk_device(open_exc=OSError("no device")) mock_hid.device.return_value = mock_dev obj = Apprise.instantiate("blink1://") assert obj is not None assert obj.notify(body="test") is False def test_plugin_blink1_send_feature_report_exception(): """NotifyBlink1() returns False and closes device on HID write error.""" pytest.importorskip("hid") with ( mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid, mock.patch("apprise.plugins.blink1.time") as mock_time, ): mock_dev = mock.Mock() mock_dev.send_feature_report.side_effect = OSError("USB error") mock_hid.device.return_value = mock_dev mock_time.sleep.return_value = None obj = Apprise.instantiate("blink1://") assert obj is not None assert obj.notify(body="test") is False # close() must always be called assert mock_dev.close.called def test_plugin_blink1_short_report_fails(): """NotifyBlink1() returns False when the HID report is truncated.""" pytest.importorskip("hid") with ( mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid, mock.patch("apprise.plugins.blink1.time") as mock_time, ): mock_dev = _mk_device(report_size=BLINK1_REPORT_SIZE - 1) mock_hid.device.return_value = mock_dev mock_time.sleep.return_value = None obj = Apprise.instantiate("blink1://") assert obj is not None assert obj.notify(body="test") is False def test_plugin_blink1_notify_types(): """NotifyBlink1() sends one fade per notification type.""" pytest.importorskip("hid") with ( mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid, mock.patch("apprise.plugins.blink1.time") as mock_time, ): mock_dev = _mk_device() mock_hid.device.return_value = mock_dev mock_time.sleep.return_value = None obj = Apprise.instantiate("blink1://ABCD1234/?fade=100&duration=50") assert obj is not None for notify_type in ( NotifyType.INFO, NotifyType.SUCCESS, NotifyType.WARNING, NotifyType.FAILURE, ): assert obj.notify(body="test", notify_type=notify_type) is True # Each notification sends two reports (color + off) assert mock_dev.send_feature_report.call_count == 8 assert mock_dev.close.call_count == 4 def test_plugin_blink1_fade_buf_layout(): """_blink1_fade_buf() constructs the correct 9-byte HID payload.""" buf = _blink1_fade_buf(255, 128, 0, 1000, ledn=1) assert len(buf) == 9 assert buf[0] == 0x01 # REPORT_ID assert buf[1] == ord("c") # command byte assert buf[2] == 255 # red assert buf[3] == 128 # green assert buf[4] == 0 # blue # fade_time = 1000 // 10 = 100 = 0x0064 assert buf[5] == 0x00 # th assert buf[6] == 0x64 # tl assert buf[7] == 1 # ledn assert buf[8] == 0x00 # padding def test_plugin_blink1_fade_rgb_args(): """NotifyBlink1() passes correct ledn and RGB values to the device.""" pytest.importorskip("hid") with ( mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid, mock.patch("apprise.plugins.blink1.time") as mock_time, ): mock_dev = _mk_device() mock_hid.device.return_value = mock_dev mock_time.sleep.return_value = None obj = Apprise.instantiate("blink1://?fade=200&duration=100&ledn=2") assert obj is not None assert obj.notify(body="test", notify_type=NotifyType.INFO) is True first_call_buf = mock_dev.send_feature_report.call_args_list[0][0][0] assert first_call_buf[1] == ord("c") # command byte assert first_call_buf[7] == 2 # ledn # fade_time = 200 // 10 = 20 = 0x0014 assert first_call_buf[5] == 0x00 assert first_call_buf[6] == 0x14 def test_plugin_blink1_off_sent_after_duration(): """NotifyBlink1() sends an off command after sleeping for fade+duration.""" pytest.importorskip("hid") with ( mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid, mock.patch("apprise.plugins.blink1.time") as mock_time, ): mock_dev = _mk_device() mock_hid.device.return_value = mock_dev mock_time.sleep.return_value = None obj = Apprise.instantiate("blink1://?fade=500&duration=2000") assert obj is not None assert obj.notify(body="test") is True mock_time.sleep.assert_called_once_with(2.5) # Second report should be the "off" command (all zeros for RGB) off_buf = mock_dev.send_feature_report.call_args_list[1][0][0] assert off_buf[2] == 0 # red assert off_buf[3] == 0 # green assert off_buf[4] == 0 # blue def test_plugin_blink1_off_command_failure_returns_false(): """NotifyBlink1() returns False when the off HID write fails.""" pytest.importorskip("hid") with ( mock.patch("apprise.plugins.blink1.blink1_hid") as mock_hid, mock.patch("apprise.plugins.blink1.time") as mock_time, ): mock_dev = mock.Mock() # First call (color fade) succeeds; second call (off) fails. mock_dev.send_feature_report.side_effect = [ BLINK1_REPORT_SIZE, BLINK1_REPORT_SIZE - 1, ] mock_hid.device.return_value = mock_dev mock_time.sleep.return_value = None obj = Apprise.instantiate("blink1://") assert obj is not None assert obj.notify(body="test") is False # close() must still be called assert mock_dev.close.called def test_plugin_blink1_url_round_trip(): """NotifyBlink1() URL reconstructs an equivalent object.""" obj = NotifyBlink1(serial="ABCD1234", duration=3000, fade=500, ledn=1) rebuilt = NotifyBlink1(**NotifyBlink1.parse_url(obj.url())) assert rebuilt.serial == "ABCD1234" assert rebuilt.duration == 3000 assert rebuilt.fade == 500 assert rebuilt.ledn == 1 def test_plugin_blink1_url_round_trip_no_serial(): """NotifyBlink1() URL round-trips correctly without a serial.""" obj = NotifyBlink1() rebuilt = NotifyBlink1(**NotifyBlink1.parse_url(obj.url())) assert rebuilt.serial is None assert rebuilt.duration == BLINK1_DEFAULT_DURATION_MS assert rebuilt.fade == BLINK1_DEFAULT_FADE_MS assert rebuilt.ledn == Blink1LED.ALL def test_plugin_blink1_url_identifier(): """NotifyBlink1() url_identifier distinguishes different connections.""" obj_a = NotifyBlink1() obj_b = NotifyBlink1(serial="ABCD1234") obj_c = NotifyBlink1(ledn=1) assert obj_a.url_identifier != obj_b.url_identifier assert obj_a.url_identifier != obj_c.url_identifier assert obj_b.url_identifier != obj_c.url_identifier def test_plugin_blink1_invalid_ledn_defaults(): """NotifyBlink1() silently falls back to ALL for an unrecognised ledn.""" obj = NotifyBlink1(ledn="99") assert obj.ledn == Blink1LED.ALL def test_plugin_blink1_runtime_deps(): """NotifyBlink1.runtime_deps() returns the importable hid module name.""" assert NotifyBlink1.runtime_deps() == ("hid",) def test_plugin_blink1_parse_url_underscore(): """NotifyBlink1() treats underscore host as first-device placeholder.""" result = NotifyBlink1.parse_url("blink1://_/") assert result is not None assert result.get("serial") is None def test_plugin_blink1_parse_url_serial(): """NotifyBlink1() extracts serial number from URL host field.""" result = NotifyBlink1.parse_url("blink1://DEADBEEF/") assert result is not None assert result["serial"] == "DEADBEEF" caronc-apprise-182f859/tests/test_plugin_bluesky.py000066400000000000000000000702521524161175200225310ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timezone import json import logging import os from unittest.mock import Mock, patch from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.bluesky import NotifyBlueSky # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") TWITTER_SCREEN_NAME = "apprise" # Our Testing URLs apprise_url_tests = ( ################################## # NotifyBlueSky ################################## ( "bluesky://", { # Missing user and app_pass "instance": TypeError, }, ), ( "bluesky://:@/", { "instance": TypeError, }, ), ( "bluesky://app-pw", { # Missing User "instance": TypeError, }, ), ( "bluesky://user@app-pw", { "instance": NotifyBlueSky, # Expected notify() response False (because we won't be able # to detect our user) "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "bsky://user@****", }, ), ( "bluesky://user@app-pw1?cache=no", { "instance": NotifyBlueSky, # At minimum we need an access token and did; below has no did "requests_response_text": { "accessJwt": "abcd", "refreshJwt": "abcd", }, "notify_response": False, }, ), ( "bluesky://user@app-pw2?cache=no", { "instance": NotifyBlueSky, # valid payload "requests_response_text": { "accessJwt": "abcd", "refreshJwt": "abcd", "did": "did:plc:1234", # Support plc response "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ], }, }, ), ( "bluesky://user@app-pw3", { # no cache; so we store our results "instance": NotifyBlueSky, # valid payload "requests_response_text": { "accessJwt": "abcd", "refreshJwt": "abcd", "did": "did:plc:1234", # For handling attachments "blob": "content", # Support plc response "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ], }, }, ), ( "bluesky://user.example.ca@app-pw3", { # no cache; so we store our results "instance": NotifyBlueSky, # valid payload "requests_response_text": { "accessJwt": "abcd", "refreshJwt": "abcd", "did": "did:plc:1234", # For handling attachments "blob": "content", # Support plc response "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ], }, }, ), # A duplicate of the entry above, this will cause cache to be referenced ( "bluesky://user@app-pw3", { # no cache; so we store our results "instance": NotifyBlueSky, # valid payload "requests_response_text": { "accessJwt": "abcd", "refreshJwt": "abcd", "did": "did:plc:1234", # For handling attachments "blob": "content", # Support plc response "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ], }, }, ), ( "bluesky://user@app-pw", { "instance": NotifyBlueSky, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, "requests_response_text": { "accessJwt": "abcd", "refreshJwt": "abcd", "did": "did:plc:1234", # Support plc response "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ], }, }, ), ( "bluesky://user@app-pw", { "instance": NotifyBlueSky, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, "requests_response_text": { "accessJwt": "abcd", "refreshJwt": "abcd", "did": "did:plc:1234", # Support plc response "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ], }, }, ), ) def good_response(data=None): """Prepare a good response.""" response = Mock() response.content = json.dumps( { "accessJwt": "abcd", "refreshJwt": "abcd", "did": "did:plc:1234", # Support plc response "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ], "ratelimit-reset": str( int(datetime.now(timezone.utc).timestamp()) + 3600 ), "ratelimit-remaining": "10", } if data is None else data ) response.status_code = requests.codes.ok # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) # Generate a very large rate-limit header window response.headers = { "ratelimit-reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() + 86400 ), "ratelimit-remaining": "1000", } return response def bad_response(data=None): """Prepare a bad response.""" response = Mock() response.content = json.dumps( { "error": "InvalidRequest", "message": "Something failed", } if data is None else data ) response.headers = {} response.status_code = requests.codes.internal_server_error return response @pytest.fixture def bluesky_url(): url = "bluesky://user@app-key" return url @pytest.fixture def good_message_response(): """Prepare a good response.""" response = good_response() return response @pytest.fixture def bad_message_response(): """Prepare a bad message response.""" response = bad_response() return response @pytest.fixture def good_media_response(): """Prepare a good media response.""" response = Mock() response.content = json.dumps( { "blob": { "$type": "blob", "mimeType": "image/jpeg", "ref": {"$link": "baf124idksduabcjkaa3iey4bfyq"}, "size": 73667, } } ) response.headers = {} response.status_code = requests.codes.ok return response def test_plugin_bluesky_urls(): """NotifyBlueSky() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_bluesky_general(mocker): """NotifyBlueSky() General Tests.""" mock_get = mocker.patch("requests.get") mock_post = mocker.patch("requests.post") # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) request = good_response() request.headers = { "ratelimit-reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "ratelimit-remaining": "1", } # Prepare Mock mock_get.return_value = request mock_post.return_value = request # Variation Initializations obj = NotifyBlueSky(user="handle", password="app-password") assert isinstance(obj, NotifyBlueSky) is True assert isinstance(obj.url(), str) is True # apprise room was found assert obj.send(body="test") is True # Change our status code and try again request.status_code = 403 assert obj.send(body="test") is False assert obj.ratelimit_remaining == 1 # Return the status request.status_code = requests.codes.ok # Force a reset request.headers["ratelimit-remaining"] = 0 # behind the scenes, it should cause us to update our rate limit assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 # This should cause us to block request.headers["ratelimit-remaining"] = 10 assert obj.send(body="test") is True assert obj.ratelimit_remaining == 10 # Handle cases where we simply couldn't get this field del request.headers["ratelimit-remaining"] assert obj.send(body="test") is True # It remains set to the last value assert obj.ratelimit_remaining == 10 # Reset our variable back to 1 request.headers["ratelimit-remaining"] = 1 # Handle cases where our epoch time is wrong del request.headers["ratelimit-reset"] assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block request.headers["ratelimit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() + 1 request.headers["ratelimit-remaining"] = 0 obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block request.headers["ratelimit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() - 1 request.headers["ratelimit-remaining"] = 0 obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our limits to always work request.headers["ratelimit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() request.headers["ratelimit-remaining"] = 1 obj.ratelimit_remaining = 1 assert obj.send(body="test") is True # Flush our cache forcing it is re-creating NotifyBlueSky._user_cache = {} assert obj.send(body="test") is True # Cause content response to be None request.content = None assert obj.send(body="test") is True # Invalid JSON request.content = "{" assert obj.send(body="test") is True # Return it to a parseable string request.content = "{}" results = NotifyBlueSky.parse_url("bluesky://handle@app-pass-word") assert isinstance(results, dict) is True # cause a json parsing issue now response_obj = None assert obj.send(body="test") is True response_obj = "{" assert obj.send(body="test") is True # Flush out our cache NotifyBlueSky._user_cache = {} response_obj = { "accessJwt": "abcd", "refreshJwt": "abcd", "did": "did:plc:1234", # Support plc response "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ], } request.content = json.dumps(response_obj) obj = NotifyBlueSky(user="handle", password="app-pass-word") assert obj.send(body="test") is True # Alter the key forcing us to look up a new value of ourselves again NotifyBlueSky._user_cache = {} NotifyBlueSky._whoami_cache = None obj.ckey = "different.then.it.was" assert obj.send(body="test") is True NotifyBlueSky._whoami_cache = None obj.ckey = "different.again" assert obj.send(body="test") is True def test_plugin_bluesky_edge_cases(): """NotifyBlueSky() Edge Cases.""" with pytest.raises(TypeError): NotifyBlueSky() @patch("requests.post") @patch("requests.get") def test_plugin_bluesky_attachments_basic( mock_get, mock_post, bluesky_url, good_message_response, good_media_response, ): """ NotifyBlueSky() Attachment Checks - Basic """ mock_get.return_value = good_message_response mock_post.side_effect = [ good_message_response, good_media_response, good_message_response, ] # Create application objects. obj = Apprise.instantiate(bluesky_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Verify API calls. assert mock_get.call_count == 2 assert ( mock_get.call_args_list[0][0][0] == "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle" ) assert ( mock_get.call_args_list[1][0][0] == "https://plc.directory/did:plc:1234" ) assert mock_post.call_count == 3 assert ( mock_post.call_args_list[0][0][0] == "https://example.pds.io/xrpc/com.atproto.server.createSession" ) assert ( mock_post.call_args_list[1][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[2][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) @patch("requests.post") @patch("requests.get") def test_plugin_bluesky_attachments_bad_message_response( mock_get, mock_post, bluesky_url, good_media_response, good_message_response, bad_message_response, ): mock_get.return_value = good_message_response mock_post.side_effect = [ good_message_response, bad_message_response, good_message_response, ] # Create application objects. obj = Apprise.instantiate(bluesky_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Our notification will fail now since our message will error out. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Verify API calls. assert mock_get.call_count == 2 assert ( mock_get.call_args_list[0][0][0] == "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle" ) assert ( mock_get.call_args_list[1][0][0] == "https://plc.directory/did:plc:1234" ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://example.pds.io/xrpc/com.atproto.server.createSession" ) assert ( mock_post.call_args_list[1][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) @patch("requests.post") @patch("requests.get") def test_plugin_bluesky_attachments_upload_fails( mock_get, mock_post, bluesky_url, good_media_response, good_message_response, ): # Test case where upload fails. mock_get.return_value = good_message_response mock_post.side_effect = [good_message_response, OSError] # Create application objects. obj = Apprise.instantiate(bluesky_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification; it will fail because of the message response. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Verify API calls. assert mock_get.call_count == 2 assert ( mock_get.call_args_list[0][0][0] == "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle" ) assert ( mock_get.call_args_list[1][0][0] == "https://plc.directory/did:plc:1234" ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://example.pds.io/xrpc/com.atproto.server.createSession" ) assert ( mock_post.call_args_list[1][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) @patch("requests.post") @patch("requests.get") def test_plugin_bluesky_attachments_invalid_attachment( mock_get, mock_post, bluesky_url, good_message_response, good_media_response, ): mock_get.return_value = good_message_response mock_post.side_effect = [good_message_response, good_media_response] # Create application objects. obj = Apprise.instantiate(bluesky_url) attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") ) # An invalid attachment will cause a failure. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Verify API calls. assert mock_get.call_count == 2 assert ( mock_get.call_args_list[0][0][0] == "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle" ) assert ( mock_get.call_args_list[1][0][0] == "https://plc.directory/did:plc:1234" ) # No post request as attachment is not good. assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://example.pds.io/xrpc/com.atproto.server.createSession" ) @patch("requests.post") @patch("requests.get") def test_plugin_bluesky_attachments_multiple_batch( mock_get, mock_post, bluesky_url, good_message_response, good_media_response, ): mock_get.return_value = good_message_response mock_post.side_effect = [ good_message_response, good_media_response, good_media_response, good_media_response, good_media_response, good_message_response, good_message_response, good_message_response, good_message_response, ] # instantiate our object obj = Apprise.instantiate(bluesky_url) # 4 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Verify API calls. assert mock_get.call_count == 2 assert ( mock_get.call_args_list[0][0][0] == "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle" ) assert ( mock_get.call_args_list[1][0][0] == "https://plc.directory/did:plc:1234" ) assert mock_post.call_count == 9 assert ( mock_post.call_args_list[0][0][0] == "https://example.pds.io/xrpc/com.atproto.server.createSession" ) assert ( mock_post.call_args_list[1][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[2][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[3][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[4][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[5][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) assert ( mock_post.call_args_list[6][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) assert ( mock_post.call_args_list[7][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) assert ( mock_post.call_args_list[8][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) # If we call the functions again, the only difference is # we no longer need to resolve the handle or create a session # as the previous one is fine. mock_get.reset_mock() mock_post.reset_mock() mock_get.return_value = good_message_response mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_media_response, good_message_response, good_message_response, good_message_response, good_message_response, ] assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Verify API calls. assert mock_get.call_count == 0 assert mock_post.call_count == 8 assert ( mock_post.call_args_list[0][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[1][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[2][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[3][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.uploadBlob" ) assert ( mock_post.call_args_list[4][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) assert ( mock_post.call_args_list[5][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) assert ( mock_post.call_args_list[6][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) assert ( mock_post.call_args_list[7][0][0] == "https://example.pds.io/xrpc/com.atproto.repo.createRecord" ) @patch("requests.post") @patch("requests.get") def test_plugin_bluesky_auth_failure( mock_get, mock_post, bluesky_url, good_message_response, bad_message_response, ): mock_get.return_value = good_message_response mock_post.return_value = bad_message_response # instantiate our object obj = Apprise.instantiate(bluesky_url) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # Verify API calls. assert mock_get.call_count == 2 assert ( mock_get.call_args_list[0][0][0] == "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle" ) assert ( mock_get.call_args_list[1][0][0] == "https://plc.directory/did:plc:1234" ) assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://example.pds.io/xrpc/com.atproto.server.createSession" ) @patch("requests.post") @patch("requests.get") def test_plugin_bluesky_did_web_and_plc_resolution( mock_get, mock_post, bluesky_url, good_message_response ): """ NotifyBlueSky() - Full coverage of did:web and did:plc path """ # Step 1: Identity resolution response (public.api.bsky.app) identity_response = good_response({"did": "did:plc:abcdefg1234567"}) # Step 2: PLC Directory lookup plc_response = good_response( { "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.pds.io", } ] } ) # Step 3: Auth session session_response = good_response() # Step 4: Create post post_response = good_response() mock_get.side_effect = [identity_response, plc_response] mock_post.side_effect = [session_response, post_response] obj = Apprise.instantiate(bluesky_url) assert obj.notify(body="Resolved PLC Flow") is True # Reset for did:web test identity_response = good_response({"did": "did:web:example.com"}) web_did_response = good_response( { "service": [ { "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://example.com", } ] } ) mock_get.side_effect = [identity_response, web_did_response] mock_post.side_effect = [session_response, post_response] obj = Apprise.instantiate(bluesky_url) assert obj.notify(body="Resolved WEB Flow") is True # Invalid DID scheme bad_did_response = good_response({"did": "did:unsupported:scheme"}) mock_get.side_effect = [bad_did_response] obj = Apprise.instantiate(bluesky_url) assert obj.notify(body="fail due to bad scheme") is False @patch("requests.get") def test_plugin_bluesky_pds_resolution_failures(mock_get): """ NotifyBlueSky() - Missing service field or invalid service endpoint """ identity_response = good_response({"did": "did:plc:missing-service"}) plc_no_service = good_response({"foo": "bar"}) mock_get.side_effect = [identity_response, plc_no_service] obj = NotifyBlueSky(user="handle", password="pass") did, endpoint = obj.get_identifier() assert (did, endpoint) == (False, False) identity_response = good_response({"did": "did:web:example.com"}) web_did_no_service = good_response({"foo": "bar"}) mock_get.side_effect = [identity_response, web_did_no_service] obj = NotifyBlueSky(user="handle", password="pass") did, endpoint = obj.get_identifier() assert (did, endpoint) == (False, False) @patch("requests.get") def test_plugin_bluesky_missing_pds_endpoint(mock_get): """ NotifyBlueSky() - test case where endpoint is missing from DID document """ # Return a valid DID resolution identity_response = good_response({"did": "did:plc:abcdefg1234567"}) # Return DID document with a service list, but no matching PDS type incomplete_pds_response = good_response( { "service": [ { "type": "SomeOtherService", "serviceEndpoint": "https://unrelated.example.com", } ] } ) mock_get.side_effect = [identity_response, incomplete_pds_response] obj = NotifyBlueSky(user="handle", password="app-pw") assert obj.get_identifier() == (False, False) caronc-apprise-182f859/tests/test_plugin_brevo.py000066400000000000000000000162741524161175200221740ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.brevo import NotifyBrevo logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # a test UUID we can use UUID4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752" # Our Testing URLs apprise_url_tests = ( ( "brevo://", { "instance": None, }, ), ( "brevo://:@/", { "instance": None, }, ), ( "brevo://abcd", { # Just an broken email (no api key or email) "instance": None, }, ), ( "brevo://abcd@host", { # Just an Email specified, no API Key "instance": None, }, ), ( "brevo://invalid-api-key+*-d:user@example.com", { # An invalid API Key "instance": TypeError, }, ), ( ("brevo://abcd:user@example.com/newuser@example.com?reply=%20!"), { # An invalid Reply-To address "instance": TypeError, }, ), ( "brevo://abcd:user@example.com?format=text", { # No To/Target Address(es) specified; so we sub in the same From # address "instance": NotifyBrevo, }, ), ( ( "brevo://abcd:user@example.com/newuser@example.com" "?reply=user@example.ca" ), { # A good email "instance": NotifyBrevo, "force_debug": True, }, ), ( "brevo://abcd:user@example.com/bademailaddress", { # won't be able to send email "instance": NotifyBrevo, "notify_response": False, }, ), ( ( "brevo://abcd:user@example.com/newuser@example.com" "?bcc=l2g@nuxref.com" ), { # A good email with Blind Carbon Copy "instance": NotifyBrevo, }, ), ( ( "brevo://abcd:user@example.com/newuser@example.com" "?cc=l2g@nuxref.com" ), { # A good email with Carbon Copy "instance": NotifyBrevo, }, ), ( ( "brevo://abcd:user@example.com/newuser@example.com" "?to=l2g@nuxref.com" ), { # A good email with Carbon Copy "instance": NotifyBrevo, }, ), ( "brevo://abcd:user@example.ca/newuser@example.ca", { "instance": NotifyBrevo, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "brevo://abcd:user@example.uk/newuser@example.uk", { "instance": NotifyBrevo, # throw a bizzare code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "brevo://abcd:user@example.au/newuser@example.au", { "instance": NotifyBrevo, # Throws a series of i/o exceptions with this flag # is set and tests that we gracfully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_brevo_urls(): """NotifyBrevo() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_brevo_edge_cases(mock_post, mock_get): """NotifyBrevo() Edge Cases.""" # no apikey with pytest.raises(TypeError): NotifyBrevo(apikey=None, from_email="user@example.com") # invalid from email with pytest.raises(TypeError): NotifyBrevo(apikey="abcd", from_email="!invalid") # no email with pytest.raises(TypeError): NotifyBrevo(apikey="abcd", from_email=None) # Invalid To email address NotifyBrevo( apikey="abcd", from_email="user@example.com", targets="!invalid" ) # Test invalid bcc/cc entries mixed with good ones assert isinstance( NotifyBrevo( apikey="abcd", from_email="l2g@example.com", bcc=("abc@def.com", "!invalid"), cc=("abc@test.org", "!invalid"), ), NotifyBrevo, ) @mock.patch("requests.post") def test_plugin_brevo_attachments(mock_post): """NotifyBrevo() Attachments.""" request = mock.Mock() request.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = request path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) obj = Apprise.instantiate("brevo://abcd:user@example.com") assert isinstance(obj, NotifyBrevo) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) mock_post.reset_mock() # Try again in a use case where we can't access the file with mock.patch("os.path.isfile", return_value=False): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Try again in a use case where we can't access the file with mock.patch("builtins.open", side_effect=OSError): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) caronc-apprise-182f859/tests/test_plugin_bulksms.py000066400000000000000000000173031524161175200225310ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyType from apprise.plugins.bulksms import NotifyBulkSMS logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "bulksms://", { # Instantiated but no auth, so no otification can happen "instance": NotifyBulkSMS, # Expected notify() response because we have no one to notify "notify_response": False, }, ), ( "bulksms://:@/", { # invalid auth "instance": NotifyBulkSMS, # Expected notify() response because we have no one to notify "notify_response": False, }, ), ( "bulksms://{}@12345678".format("a" * 10), { # Just user provided (no password) "instance": NotifyBulkSMS, # Expected notify() response because we have no one to notify "notify_response": False, }, ), ( "bulksms://{}:{}@{}".format("a" * 10, "b" * 10, "3" * 5), { # invalid nubmer provided "instance": NotifyBulkSMS, # Expected notify() response because we have no one to notify "notify_response": False, }, ), ( "bulksms://{}:{}@123/{}/abcd/".format("a" * 5, "b" * 10, "3" * 11), { # included group and phone, short number (123) dropped "instance": NotifyBulkSMS, "privacy_url": "bulksms://a...a:****@+33333333333/@abcd", }, ), ( "bulksms://{}:{}@{}?batch=y&unicode=n".format( "b" * 5, "c" * 10, "4" * 11 ), { "instance": NotifyBulkSMS, # Our expected url(privacy=True) startswith() response: "privacy_url": "bulksms://b...b:****@+4444444444", }, ), ( "bulksms://{}:{}@123456/{}".format("a" * 10, "b" * 10, "4" * 11), { # using short-code (6 characters) "instance": NotifyBulkSMS, }, ), ( "bulksms://{}:{}@{}".format("a" * 10, "b" * 10, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifyBulkSMS, }, ), # Test route group ( "bulksms://{}:{}@admin?route=premium".format("a" * 10, "b" * 10), { "instance": NotifyBulkSMS, }, ), ( "bulksms://{}:{}@admin?route=invalid".format("a" * 10, "b" * 10), { # invalid route "instance": TypeError, }, ), ( "bulksms://_?user={}&password={}&from={}".format( "a" * 10, "b" * 10, "5" * 11 ), { # use get args to acomplish the same thing "instance": NotifyBulkSMS, }, ), ( "bulksms://_?user={}&password={}&from={}".format( "a" * 10, "b" * 10, "5" * 3 ), { # use get args to acomplish the same thing "instance": TypeError, }, ), ( "bulksms://_?user={}&password={}&from={}&to={}".format( "a" * 10, "b" * 10, "5" * 11, "7" * 13 ), { # use to= "instance": NotifyBulkSMS, }, ), ( "bulksms://{}:{}@{}".format("a" * 10, "b" * 10, "a" * 3), { "instance": NotifyBulkSMS, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "bulksms://{}:{}@{}".format("a" * 10, "b" * 10, "6" * 11), { "instance": NotifyBulkSMS, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_bulksms_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_bulksms_edge_cases(mock_post): """NotifyBulkSMS() Edge Cases.""" # Initialize some generic (but valid) tokens user = "abcd" pwd = "mypass123" targets = [ "+1(555) 123-1234", "1555 5555555", "group", # A garbage entry "12", # NOw a valid one because a group was implicit "@12", ] # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Instantiate our object obj = Apprise.instantiate( "bulksms://{}:{}@{}?batch=n".format(user, pwd, "/".join(targets)) ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # We know there are 4 targets assert len(obj) == 4 # Test our call count assert mock_post.call_count == 4 # Test details = mock_post.call_args_list[0] payload = loads(details[1]["data"]) assert payload["to"] == "+15551231234" assert payload["body"] == "title\r\nbody" details = mock_post.call_args_list[1] payload = loads(details[1]["data"]) assert payload["to"] == "+15555555555" assert payload["body"] == "title\r\nbody" details = mock_post.call_args_list[2] payload = loads(details[1]["data"]) assert isinstance(payload["to"], dict) assert payload["to"]["name"] == "group" assert payload["body"] == "title\r\nbody" details = mock_post.call_args_list[3] payload = loads(details[1]["data"]) assert isinstance(payload["to"], dict) assert payload["to"]["name"] == "12" assert payload["body"] == "title\r\nbody" # Verify our URL looks good assert obj.url().startswith( "bulksms://{}:{}@{}".format( user, pwd, "/".join(["+15551231234", "+15555555555", "@group", "@12"]), ) ) assert "batch=no" in obj.url() # With our batch in place, our calculations are different obj = Apprise.instantiate( "bulksms://{}:{}@{}?batch=y".format(user, pwd, "/".join(targets)) ) # 2 groups and 2 phones are lumped together assert len(obj) == 3 caronc-apprise-182f859/tests/test_plugin_bulkvs.py000066400000000000000000000151361524161175200223610ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyType from apprise.plugins.bulkvs import NotifyBulkVS logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "bulkvs://", { # Instantiated but no auth, so no otification can happen "instance": TypeError, }, ), ( "bulkvs://:@/", { # invalid auth "instance": TypeError, }, ), ( "bulkvs://{}@9876543210/".format("a" * 10), { # Just user provided (no password) "instance": TypeError, }, ), ( "bulkvs://{}:{}@{}".format("u" * 10, "p" * 10, "3" * 5), { # invalid source number provided "instance": TypeError, }, ), ( "bulkvs://{}:{}@{}/{}".format("u" * 10, "p" * 10, "1" * 10, 2 * "5"), { # invalid target number provided "instance": NotifyBulkVS, # Expected notify() response because we have no one to notify "notify_response": False, }, ), ( "bulkvs://{}:{}@{}".format("u" * 10, "p" * 10, "2" * 10), { # default to ourselves "instance": NotifyBulkVS, }, ), ( "bulkvs://{}:{}@9876543210/{}/abcd/".format( "a" * 5, "b" * 10, "3" * 11 ), { # included phone, short number (123) and garbage string (abcd) # dropped "instance": NotifyBulkVS, "privacy_url": "bulkvs://a...a:****@9876543210/33333333333", }, ), ( "bulkvs://{}:{}@{}?batch=y".format("b" * 5, "c" * 10, "4" * 11), { "instance": NotifyBulkVS, # Our expected url(privacy=True) startswith() response: "privacy_url": "bulkvs://b...b:****@44444444444", }, ), ( "bulkvs://{}:{}@{}".format("a" * 10, "b" * 10, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifyBulkVS, }, ), ( "bulkvs://?user={}&password={}&from={}".format( "z" * 10, "y" * 10, "5" * 11 ), { # use get args to acomplish the same thing "instance": NotifyBulkVS, }, ), ( "bulkvs://?user={}&password={}&from={}&to={}".format( "a" * 10, "b" * 10, "5" * 11, "7" * 13 ), { # use to= "instance": NotifyBulkVS, }, ), ( "bulkvs://{}:{}@{}".format("a" * 10, "b" * 10, "5" * 11), { "instance": NotifyBulkVS, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "bulkvs://{}:{}@{}".format("a" * 10, "b" * 10, "5" * 11), { "instance": NotifyBulkVS, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_bulkvs_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_bulkvs_edge_cases(mock_post): """NotifyBulkVS() Edge Cases.""" # Initialize some generic (but valid) tokens user = "abcd" pwd = "mypass123" source = "1 (405) 123 1234" targets = [ "+1(555) 123-1234", "1555 5555555", # A garbage entry "12", # NOw a valid one because a group was implicit "@12", ] # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Instantiate our object obj = Apprise.instantiate( "bulkvs://{}:{}@{}/{}?batch=n".format( user, pwd, source, "/".join(targets) ) ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # We know there are 2 targets assert len(obj) == 2 # Test our call count assert mock_post.call_count == 2 # Test details = mock_post.call_args_list[0] payload = loads(details[1]["data"]) assert payload["From"] == "14051231234" assert payload["To"] == "15551231234" assert payload["Message"] == "title\r\nbody" details = mock_post.call_args_list[1] payload = loads(details[1]["data"]) assert payload["From"] == "14051231234" assert payload["To"] == "15555555555" assert payload["Message"] == "title\r\nbody" # Verify our URL looks good assert obj.url().startswith( "bulkvs://abcd:mypass123@14051231234/15551231234/15555555555" ) assert "batch=no" in obj.url() # With our batch in place, our calculations are different obj = Apprise.instantiate( "bulkvs://{}:{}@{}?batch=y".format(user, pwd, "/".join(targets)) ) # 2 phones are lumped together assert len(obj) == 1 caronc-apprise-182f859/tests/test_plugin_burstsms.py000066400000000000000000000154221524161175200227330ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.burstsms import NotifyBurstSMS logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "burstsms://", { # No API Key specified "instance": TypeError, }, ), ( "burstsms://:@/", { # invalid Auth key "instance": TypeError, }, ), ( "burstsms://{}@12345678".format("a" * 8), { # Just a key provided "instance": TypeError, }, ), ( "burstsms://{}:{}@%20".format("d" * 8, "e" * 16), { # Invalid source number "instance": TypeError, }, ), ( "burstsms://{}:{}@{}/123/{}/abcd/".format( "f" * 8, "g" * 16, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifyBurstSMS, # Expected notify() response because not all targets are valid "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "burstsms://f...f:****@", }, ), ( "burstsms://{}:{}@{}".format("h" * 8, "i" * 16, "5" * 11), { "instance": NotifyBurstSMS, # Expected notify() response because no targets are defined "notify_response": False, }, ), ( "burstsms://_?key={}&secret={}&from={}&to={}".format( "a" * 8, "b" * 16, "5" * 11, "6" * 11 ), { # use get args to acomplish the same thing "instance": NotifyBurstSMS, }, ), ( "burstsms://_?key={}&secret={}&from={}&to={}&batch=y".format( "a" * 8, "b" * 16, "5" * 11, "6" * 11 ), { # batch flag set "instance": NotifyBurstSMS, }, ), # Test our country ( "burstsms://_?key={}&secret={}&source={}&to={}&country=us".format( "a" * 8, "b" * 16, "5" * 11, "6" * 11 ), { "instance": NotifyBurstSMS, }, ), # Test an invalid country ( "burstsms://_?key={}&secret={}&source={}&to={}&country=invalid".format( "a" * 8, "b" * 16, "5" * 11, "6" * 11 ), { "instance": TypeError, }, ), # Test our validity ( "burstsms://_?key={}&secret={}&source={}&to={}&validity=10".format( "a" * 8, "b" * 16, "5" * 11, "6" * 11 ), { "instance": NotifyBurstSMS, }, ), # Test an invalid country ( "burstsms://_?key={}&secret={}&source={}&to={}&validity=invalid".format( "a" * 8, "b" * 16, "5" * 11, "6" * 11 ), { "instance": TypeError, }, ), ( "burstsms://_?key={}&secret={}&from={}&to={}".format( "a" * 8, "b" * 16, "5" * 11, "7" * 11 ), { # use to= "instance": NotifyBurstSMS, }, ), ( "burstsms://{}:{}@{}/{}".format("a" * 8, "b" * 16, "6" * 11, "7" * 11), { "instance": NotifyBurstSMS, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "burstsms://{}:{}@{}/{}".format("a" * 8, "b" * 16, "6" * 11, "7" * 11), { "instance": NotifyBurstSMS, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_burstsms_urls(): """NotifyBurstSMS() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_burstsms_edge_cases(mock_post): """NotifyBurstSMS() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens apikey = "{}".format("b" * 8) secret = "{}".format("b" * 16) source = "+1 (555) 123-3456" # No apikey specified with pytest.raises(TypeError): NotifyBurstSMS(apikey=None, secret=secret, source=source) with pytest.raises(TypeError): NotifyBurstSMS(apikey=" ", secret=secret, source=source) # No secret specified with pytest.raises(TypeError): NotifyBurstSMS(apikey=apikey, secret=None, source=source) with pytest.raises(TypeError): NotifyBurstSMS(apikey=apikey, secret=" ", source=source) # a error response response.status_code = 400 response.content = dumps( { "error": { "code": "FIELD_INVALID", "description": ( "Sender ID must be one of the numbers that are currently" " leased." ), }, } ) mock_post.return_value = response # Initialize our object obj = NotifyBurstSMS(apikey=apikey, secret=secret, source=source) # We will fail with the above error code assert obj.notify("title", "body", "info") is False caronc-apprise-182f859/tests/test_plugin_chanify.py000066400000000000000000000060431524161175200224710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.chanify import NotifyChanify logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "chanify://", { "instance": TypeError, }, ), ( "chanify://:@/", { "instance": TypeError, }, ), ( "chanify://%badtoken%", { "instance": TypeError, }, ), ( "chanify://abc123", { # Test token "instance": NotifyChanify, }, ), ( "chanify://?token=abc123", { # Test token "instance": NotifyChanify, }, ), ( "chanify://token", { "instance": NotifyChanify, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "chanify://token", { "instance": NotifyChanify, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "chanify://token", { "instance": NotifyChanify, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_chanify_urls(): """NotifyChanify() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_chime.py000066400000000000000000000360021524161175200221330ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyFormat from apprise.plugins.chime import NotifyChime logging.disable(logging.CRITICAL) # Fake but realistic-looking credentials TEST_WEBHOOK_ID = "aabbccdd-1234-5678-abcd-ef1234567890" TEST_TOKEN = "AaBbCcDdEeFf1234567890==" # Our Testing URLs apprise_url_tests = ( ( # No webhook_id or token "chime://", { "instance": TypeError, }, ), ( # Empty URL "chime://:@/", { "instance": TypeError, }, ), ( # Webhook ID supplied but no token "chime://aabbccdd-1234-5678-abcd-ef1234567890", { "instance": TypeError, }, ), ( # Valid webhook_id + token in path (token has base64 padding) "chime://aabbccdd-1234-5678-abcd-ef1234567890/AaBbCcDd1234%3D%3D", { "instance": NotifyChime, # Expected privacy URL prefix; pprint shows the decoded token # character, so the trailing = of AaBbCcDd1234== appears as = "privacy_url": "chime://a...0/A...=/", }, ), ( # Webhook token supplied via query string "chime://aabbccdd-1234-5678-abcd-ef1234567890" "?token=AaBbCcDd1234%3D%3D", { "instance": NotifyChime, }, ), ( # Webhook ID supplied via query string "chime://?webhook_id=aabbccdd-1234-5678-abcd-ef1234567890" "&token=AaBbCcDd1234%3D%3D", { "instance": NotifyChime, }, ), ( # Valid credentials; HTTP 500 response "chime://aabbccdd-1234-5678-abcd-ef1234567890/AaBbCcDd1234%3D%3D", { "instance": NotifyChime, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), ( # Valid credentials; unknown response code "chime://aabbccdd-1234-5678-abcd-ef1234567890/AaBbCcDd1234%3D%3D", { "instance": NotifyChime, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( # Valid credentials; request exceptions "chime://aabbccdd-1234-5678-abcd-ef1234567890/AaBbCcDd1234%3D%3D", { "instance": NotifyChime, # Throws a series of i/o exceptions with this flag set # and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( # Native Chime webhook URL "https://hooks.chime.aws/incomingwebhooks/" "aabbccdd-1234-5678-abcd-ef1234567890" "?token=AaBbCcDd1234%3D%3D", { "instance": NotifyChime, }, ), ) def test_plugin_chime_urls(): """NotifyChime() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_chime_init(mock_post): """NotifyChime() Initialization Checks.""" # Prepare a valid mock response mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Missing webhook_id raises TypeError with pytest.raises(TypeError): NotifyChime(webhook_id=None, token=TEST_TOKEN) # Blank webhook_id raises TypeError with pytest.raises(TypeError): NotifyChime(webhook_id=" ", token=TEST_TOKEN) # Missing token raises TypeError with pytest.raises(TypeError): NotifyChime(webhook_id=TEST_WEBHOOK_ID, token=None) # Blank token raises TypeError with pytest.raises(TypeError): NotifyChime(webhook_id=TEST_WEBHOOK_ID, token=" ") # Valid object instantiates without error obj = NotifyChime(webhook_id=TEST_WEBHOOK_ID, token=TEST_TOKEN) assert isinstance(obj, NotifyChime) # Confirm the webhook_id and token are stored correctly assert obj.webhook_id == TEST_WEBHOOK_ID assert obj.token == TEST_TOKEN @mock.patch("requests.post") def test_plugin_chime_send(mock_post): """NotifyChime() Send Checks.""" def _mk_resp(code): """Build a minimal mock response with the given status code.""" resp = requests.Request() resp.status_code = code # content is accessed in error-path debug logging resp.content = b"" return resp # Prepare a successful mock response mock_post.return_value = _mk_resp(requests.codes.ok) # Instantiate a valid object obj = NotifyChime(webhook_id=TEST_WEBHOOK_ID, token=TEST_TOKEN) assert isinstance(obj, NotifyChime) # Perform a successful send assert obj.notify(body="Test body", title="Test title") is True # Verify the POST was made exactly once assert mock_post.call_count == 1 # Confirm the URL used matches the expected Chime webhook URL pattern call_url = mock_post.call_args_list[0][0][0] assert "hooks.chime.aws/incomingwebhooks/" in call_url assert TEST_WEBHOOK_ID in call_url # The token is URL-encoded in the query string assert "token=" in call_url mock_post.reset_mock() # A 401 response returns False mock_post.return_value = _mk_resp(requests.codes.unauthorized) assert obj.notify(body="Test body") is False mock_post.reset_mock() # A 403 response returns False mock_post.return_value = _mk_resp(requests.codes.forbidden) assert obj.notify(body="Test body") is False mock_post.reset_mock() # A 404 response returns False mock_post.return_value = _mk_resp(requests.codes.not_found) assert obj.notify(body="Test body") is False mock_post.reset_mock() # A 429 too-many-requests response returns False mock_post.return_value = _mk_resp(429) assert obj.notify(body="Test body") is False mock_post.reset_mock() # A 500 response returns False mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert obj.notify(body="Test body") is False mock_post.reset_mock() # An unknown response code returns False mock_post.return_value = _mk_resp(999) assert obj.notify(body="Test body") is False mock_post.reset_mock() # A RequestException returns False mock_post.side_effect = requests.RequestException("Network error") assert obj.notify(body="Test body") is False @mock.patch("requests.post") def test_plugin_chime_url_roundtrip(mock_post): """NotifyChime() URL round-trip invariant.""" # Prepare our mock response mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Build a simple token that contains base64 padding (=) token_with_padding = "AaBbCcDd1234==" obj = NotifyChime( webhook_id=TEST_WEBHOOK_ID, token=token_with_padding, ) assert isinstance(obj, NotifyChime) # Generate a URL and parse it back generated_url = obj.url() results = NotifyChime.parse_url(generated_url) assert results is not None # Reconstruct from parsed results obj2 = NotifyChime(**results) assert isinstance(obj2, NotifyChime) # The connection identity must be identical assert obj.url_identifier == obj2.url_identifier # Token must survive the round-trip (including = padding) assert obj.token == obj2.token assert obj.webhook_id == obj2.webhook_id @mock.patch("requests.post") def test_plugin_chime_url_parsing(mock_post): """NotifyChime() URL Parsing Checks.""" # Prepare our mock response mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Parse a standard Apprise URL results = NotifyChime.parse_url( "chime://{}/{}".format(TEST_WEBHOOK_ID, TEST_TOKEN) ) assert results is not None assert results["webhook_id"] == TEST_WEBHOOK_ID assert results["token"] == TEST_TOKEN # ?token= query parameter overrides path-supplied token results = NotifyChime.parse_url( "chime://{}/ignored-token?token={}".format(TEST_WEBHOOK_ID, TEST_TOKEN) ) assert results is not None assert results["token"] == TEST_TOKEN # ?webhook_id= query parameter overrides host-supplied ID results = NotifyChime.parse_url( "chime://ignored-id/{}?webhook_id={}".format( TEST_TOKEN, TEST_WEBHOOK_ID ) ) assert results is not None assert results["webhook_id"] == TEST_WEBHOOK_ID # None is returned for an invalid URL assert NotifyChime.parse_url(None) is None # Missing token returns a result dict with no token results = NotifyChime.parse_url("chime://{}".format(TEST_WEBHOOK_ID)) # split_path on an empty fullpath returns no entries assert results is not None assert not results.get("token") @mock.patch("requests.post") def test_plugin_chime_native_url(mock_post): """NotifyChime() Native URL Parsing.""" # Prepare our mock response mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # A native URL with a URL-encoded token native_url = ( "https://hooks.chime.aws/incomingwebhooks/" "{webhook_id}?token={token}".format( webhook_id=TEST_WEBHOOK_ID, # URL-encode the token as it would appear in a real Chime URL token=NotifyChime.quote(TEST_TOKEN, safe=""), ) ) results = NotifyChime.parse_native_url(native_url) assert results is not None assert results["webhook_id"] == TEST_WEBHOOK_ID assert results["token"] == TEST_TOKEN # The constructed object must be functional obj = NotifyChime(**results) assert isinstance(obj, NotifyChime) assert obj.notify(body="Native URL test") is True # A non-matching URL returns None assert ( NotifyChime.parse_native_url("https://example.com/not-a-chime-url") is None ) @mock.patch("requests.post") def test_plugin_chime_privacy_url(mock_post): """NotifyChime() Privacy URL.""" # Prepare our mock response mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok obj = NotifyChime(webhook_id=TEST_WEBHOOK_ID, token=TEST_TOKEN) assert isinstance(obj, NotifyChime) # Privacy URL must mask credentials privacy = obj.url(privacy=True) assert TEST_TOKEN not in privacy assert TEST_WEBHOOK_ID not in privacy # Scheme must still be present assert privacy.startswith("chime://") @mock.patch("requests.post") def test_plugin_chime_apprise_integration(mock_post): """NotifyChime() Apprise integration.""" # Prepare a valid mock response mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Test loading via Apprise URL a = Apprise() url = "chime://{}/{}".format(TEST_WEBHOOK_ID, TEST_TOKEN) assert a.add(url) is True assert len(a) == 1 assert isinstance(a[0], NotifyChime) # Notify succeeds assert a.notify(body="Test", title="Title") is True assert mock_post.call_count == 1 mock_post.reset_mock() # Test failure response mock_post.return_value.status_code = requests.codes.internal_server_error assert a.notify(body="Test", title="Title") is False mock_post.reset_mock() # Test with native URL a2 = Apprise() native = ( "https://hooks.chime.aws/incomingwebhooks/" "{webhook_id}?token={token}".format( webhook_id=TEST_WEBHOOK_ID, token=NotifyChime.quote(TEST_TOKEN, safe=""), ) ) assert a2.add(native) is True assert len(a2) == 1 assert isinstance(a2[0], NotifyChime) mock_post.return_value.status_code = requests.codes.ok assert a2.notify(body="Native test", title="Title") is True @mock.patch("requests.post") def test_plugin_chime_url_identifier(mock_post): """NotifyChime() url_identifier uniqueness.""" # Two objects with the same credentials share an identifier obj1 = NotifyChime(webhook_id=TEST_WEBHOOK_ID, token=TEST_TOKEN) obj2 = NotifyChime(webhook_id=TEST_WEBHOOK_ID, token=TEST_TOKEN) assert obj1.url_identifier == obj2.url_identifier # Different webhook_id produces a different identifier obj3 = NotifyChime(webhook_id="different-id-abcd", token=TEST_TOKEN) assert obj1.url_identifier != obj3.url_identifier # Different token produces a different identifier obj4 = NotifyChime(webhook_id=TEST_WEBHOOK_ID, token="DifferentToken123") assert obj1.url_identifier != obj4.url_identifier # Targets must not affect the identifier (no targets for Chime) assert obj1.url_identifier[0] == NotifyChime.secure_protocol assert obj1.url_identifier[1] == TEST_WEBHOOK_ID assert obj1.url_identifier[2] == TEST_TOKEN @mock.patch("requests.post") def test_plugin_chime_html_to_markdown_format(mock_post): """NotifyChime(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Chime's notify_format is MARKDOWN by default aobj = Apprise() assert aobj.add("chime://{}/{}".format(TEST_WEBHOOK_ID, TEST_TOKEN)) # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Chime assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown, not as stripped plain text payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["Content"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_clickatell.py000066400000000000000000000140131524161175200231530ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.clickatell import NotifyClickatell logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "clickatell://", { # only schema provided "instance": TypeError, }, ), ( "clickatell:///", { # invalid apikey "instance": TypeError, }, ), ( "clickatell://@/", { # invalid apikey "instance": TypeError, }, ), ( "clickatell://{}@/".format("1" * 10), { # no api key provided "instance": TypeError, }, ), ( "clickatell://{}@{}/".format("1" * 3, "a" * 32), { # invalid From/Source "instance": TypeError }, ), ( "clickatell://{}/".format("a" * 32), { # no targets provided "instance": NotifyClickatell, # We have no one to notify "notify_response": False, }, ), ( "clickatell://{}@{}/".format("1" * 10, "a" * 32), { # no targets provided (no one to notify) "instance": NotifyClickatell, # We have no one to notify "notify_response": False, }, ), ( "clickatell://{}@{}/123/{}/abcd".format("1" * 10, "a" * 32, "3" * 15), { # valid everything but target numbers "instance": NotifyClickatell, # We have no one to notify "notify_response": False, }, ), ( "clickatell://{}/{}".format("1" * 10, "a" * 32), { # everything valid (no source defined) "instance": NotifyClickatell, # We have no one to notify "notify_response": False, }, ), ( "clickatell://{}@{}/{}".format("1" * 10, "a" * 32, "1" * 10), { # everything valid "instance": NotifyClickatell, }, ), ( "clickatell://{}/{}".format("a" * 32, "1" * 10), { # everything valid (no source) "instance": NotifyClickatell, }, ), ( "clickatell://_?apikey={}&from={}&to={},{}".format( "a" * 32, "1" * 10, "1" * 10, "1" * 10 ), { # use get args to accomplish the same thing "instance": NotifyClickatell, }, ), ( "clickatell://_?apikey={}".format("a" * 32), { # use get args "instance": NotifyClickatell, "notify_response": False, }, ), ( "clickatell://_?apikey={}&from={}".format("a" * 32, "1" * 10), { # use get args "instance": NotifyClickatell, "notify_response": False, }, ), ( "clickatell://{}@{}/{}".format("1" * 10, "a" * 32, "1" * 10), { "instance": NotifyClickatell, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "clickatell://{}@{}/{}".format("1" * 10, "a" * 32, "1" * 10), { "instance": NotifyClickatell, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_clickatell_urls(): """NotifyClickatell() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_clickatell_edge_cases(mock_post): """NotifyClickatell() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) apikeys apikey = "b" * 32 from_phone = "+1 (555) 123-3456" # No apikey specified with pytest.raises(TypeError): NotifyClickatell(apikey=None, from_phone=from_phone) # a error response response.status_code = 400 response.content = dumps( { "code": 21211, "message": "The 'To' number +1234567 is not a valid phone number.", } ) mock_post.return_value = response # Initialize our object obj = NotifyClickatell(apikey=apikey, from_phone=from_phone) # We will fail with the above error code assert obj.notify("title", "body", "info") is False caronc-apprise-182f859/tests/test_plugin_clicksend.py000066400000000000000000000073161524161175200230130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester from apprise.plugins.clicksend import NotifyClickSend logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "clicksend://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "clicksend://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "clicksend://user:pass@{}/{}/{}".format("1" * 9, "2" * 15, "a" * 13), { # invalid target numbers; we'll fail to notify anyone "instance": NotifyClickSend, "notify_response": False, }, ), ( "clicksend://user:pass@{}?batch=yes".format("3" * 14), { # valid number "instance": NotifyClickSend, }, ), ( "clicksend://user:pass@{}?batch=yes&to={}".format("3" * 14, "6" * 14), { # valid number but using the to= variable "instance": NotifyClickSend, # Our expected url(privacy=True) startswith() response: "privacy_url": "clicksend://user:****", }, ), ( "clicksend://user:pass@{}?batch=no".format("3" * 14), { # valid number - no batch "instance": NotifyClickSend, }, ), ( "clicksend://user@{}?batch=no&key=abc123".format("3" * 14), { # valid number - no batch "instance": NotifyClickSend, }, ), ( "clicksend://user:pass@{}".format("3" * 14), { "instance": NotifyClickSend, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "clicksend://user:pass@{}".format("3" * 14), { "instance": NotifyClickSend, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_clicksend_urls(): """NotifyClickSend() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_custom_form.py000066400000000000000000000442641524161175200234140ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.custom_form import NotifyForm logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "form://:@/", { "instance": None, }, ), ( "form://", { "instance": None, }, ), ( "forms://", { "instance": None, }, ), ( "form://localhost", { "instance": NotifyForm, }, ), ( "form://user@localhost?method=invalid", { "instance": TypeError, }, ), ( "form://user:pass@localhost", { "instance": NotifyForm, # Our expected url(privacy=True) startswith() response: "privacy_url": "form://user:****@localhost", }, ), ( "form://user@localhost", { "instance": NotifyForm, }, ), # Test method variations ( "form://user@localhost?method=put", { "instance": NotifyForm, }, ), ( "form://user@localhost?method=get", { "instance": NotifyForm, }, ), ( "form://user@localhost?method=post", { "instance": NotifyForm, }, ), ( "form://user@localhost?method=head", { "instance": NotifyForm, }, ), ( "form://user@localhost?method=delete", { "instance": NotifyForm, }, ), ( "form://user@localhost?method=patch", { "instance": NotifyForm, }, ), ( "form://user@localhost?method=update", { "instance": NotifyForm, }, ), ( "form://user@localhost?method=options", { "instance": NotifyForm, }, ), # Custom payload options ( "form://localhost:8080?:key=value&:key2=value2", { "instance": NotifyForm, }, ), # Continue testing other cases ( "form://localhost:8080", { "instance": NotifyForm, }, ), ( "form://user:pass@localhost:8080", { "instance": NotifyForm, }, ), ( "forms://localhost", { "instance": NotifyForm, }, ), ( "forms://user:pass@localhost", { "instance": NotifyForm, }, ), ( "forms://localhost:8080/path/", { "instance": NotifyForm, # Our expected url(privacy=True) startswith() response: "privacy_url": "forms://localhost:8080/path/", }, ), ( "forms://user:password@localhost:8080", { "instance": NotifyForm, # Our expected url(privacy=True) startswith() response: "privacy_url": "forms://user:****@localhost:8080", }, ), # Test our GET params ( "form://localhost:8080/path?-ParamA=Value", { "instance": NotifyForm, }, ), # Test our Headers ( "form://localhost:8080/path?+HeaderKey=HeaderValue", { "instance": NotifyForm, }, ), ( "form://user:pass@localhost:8081", { "instance": NotifyForm, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "form://user:pass@localhost:8082", { "instance": NotifyForm, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "form://user:pass@localhost:8083", { "instance": NotifyForm, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_custom_form_urls(): """NotifyForm() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.request") def test_plugin_custom_form_attachments(mock_request): """NotifyForm() Attachments.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" # Assign our mock object our return value mock_request.return_value = okay_response obj = Apprise.instantiate("form://user@localhost.localdomain/?method=post") assert isinstance(obj, NotifyForm) # Test Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test invalid attachment path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) mock_request.return_value = None mock_request.side_effect = OSError() # We can't send the message if we can't read the attachment assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Test Valid Attachment (load 3) path = ( os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ) attach = AppriseAttachment(path) # Return our good configuration mock_request.side_effect = None mock_request.return_value = okay_response with mock.patch("builtins.open", side_effect=OSError()): # We can't send the message we can't open the attachment for reading assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Fail on the 2nd attempt (but not the first) with mock.patch("builtins.open", side_effect=[None, OSError(), None]): # We can't send the message we can't open the attachment for reading assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Test file exception handling when performing post mock_request.return_value = None mock_request.side_effect = OSError() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # # Test attach-as # # Assign our mock object our return value mock_request.return_value = okay_response mock_request.side_effect = None obj = Apprise.instantiate( "form://user@localhost.localdomain/?attach-as=file" ) assert isinstance(obj, NotifyForm) # Test Single Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test Valid Attachment (load 3) (produces a warning) path = ( os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ) attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our other variations of accepted values # we support *, :, ?, ., +, %, and $ for attach_as in ( "file*", "*file", "file*file", "file:", ":file", "file:file", "file?", "?file", "file?file", "file.", ".file", "file.file", "file+", "+file", "file+file", "file$", "$file", "file$file", ): obj = Apprise.instantiate( f"form://user@localhost.localdomain/?attach-as={attach_as}" ) assert isinstance(obj, NotifyForm) # Test Single Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test Valid Attachment (load 3) (produces a warning) path = ( os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ) attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test invalid attach-as input obj = Apprise.instantiate("form://user@localhost.localdomain/?attach-as={") assert obj is None @mock.patch("requests.request") def test_plugin_custom_form_edge_cases(mock_request): """NotifyForm() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = response results = NotifyForm.parse_url( "form://localhost:8080/command?:message=msg&:abcd=test&method=POST" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] == 8080 assert results["host"] == "localhost" assert results["fullpath"] == "/command" assert results["path"] == "/" assert results["query"] == "command" assert results["schema"] == "form" assert results["url"] == "form://localhost:8080/command" assert isinstance(results["qsd:"], dict) is True assert results["qsd:"]["abcd"] == "test" assert results["qsd:"]["message"] == "msg" instance = NotifyForm(**results) assert isinstance(instance, NotifyForm) response = instance.send(title="title", body="body") assert response is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" assert details[0][1] == "http://localhost:8080/command" assert "abcd" in details[1]["data"] assert details[1]["data"]["abcd"] == "test" assert "title" in details[1]["data"] assert details[1]["data"]["title"] == "title" assert "message" not in details[1]["data"] # message over-ride was provided; the body is now in `msg` and not # `message` assert "msg" in details[1]["data"] assert details[1]["data"]["msg"] == "body" assert details[1]["data"]["type"] == NotifyType.INFO.value assert "NotifyType." not in str(details[1]["data"]) assert instance.url(privacy=False).startswith( "form://localhost:8080/command?" ) # Generate a new URL based on our last and verify key values are the same new_results = NotifyForm.parse_url(instance.url(safe=False)) for k in ( "user", "password", "port", "host", "fullpath", "path", "query", "schema", "url", "payload", "method", ): assert new_results[k] == results[k] # Reset our mock configuration mock_request.reset_mock() results = NotifyForm.parse_url( "form://localhost:8080/command?:type=&:message=msg&method=POST" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] == 8080 assert results["host"] == "localhost" assert results["fullpath"] == "/command" assert results["path"] == "/" assert results["query"] == "command" assert results["schema"] == "form" assert results["url"] == "form://localhost:8080/command" assert isinstance(results["qsd:"], dict) is True assert results["qsd:"]["message"] == "msg" instance = NotifyForm(**results) assert isinstance(instance, NotifyForm) response = instance.send(title="title", body="body") assert response is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" assert details[0][1] == "http://localhost:8080/command" assert "title" in details[1]["data"] assert details[1]["data"]["title"] == "title" # type was removed from response object assert "type" not in details[1]["data"] # message over-ride was provided; the body is now in `msg` and not # `message` assert details[1]["data"]["msg"] == "body" # 'body' is over-ridden by 'test' passed inline with the URL assert "message" not in details[1]["data"] assert "msg" in details[1]["data"] assert details[1]["data"]["msg"] == "body" assert instance.url(privacy=False).startswith( "form://localhost:8080/command?" ) # Generate a new URL based on our last and verify key values are the same new_results = NotifyForm.parse_url(instance.url(safe=False)) for k in ( "user", "password", "port", "host", "fullpath", "path", "query", "schema", "url", "payload", "method", ): assert new_results[k] == results[k] # Reset our mock configuration mock_request.reset_mock() results = NotifyForm.parse_url( "form://localhost:8080/command?:message=test&method=GET" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] == 8080 assert results["host"] == "localhost" assert results["fullpath"] == "/command" assert results["path"] == "/" assert results["query"] == "command" assert results["schema"] == "form" assert results["url"] == "form://localhost:8080/command" assert isinstance(results["qsd:"], dict) is True assert results["qsd:"]["message"] == "test" instance = NotifyForm(**results) assert isinstance(instance, NotifyForm) response = instance.send(title="title", body="body") assert response is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "GET" assert details[0][1] == "http://localhost:8080/command" assert "title" in details[1]["params"] assert details[1]["params"]["title"] == "title" # 'body' is over-ridden by 'test' passed inline with the URL assert "message" not in details[1]["params"] assert "test" in details[1]["params"] assert details[1]["params"]["test"] == "body" assert instance.url(privacy=False).startswith( "form://localhost:8080/command?" ) # Generate a new URL based on our last and verify key values are the same new_results = NotifyForm.parse_url(instance.url(safe=False)) for k in ( "user", "password", "port", "host", "fullpath", "path", "query", "schema", "url", "payload", "method", ): assert new_results[k] == results[k] mock_request.reset_mock() # Verify UPDATE method is forwarded correctly results = NotifyForm.parse_url("form://localhost?method=UPDATE") instance = NotifyForm(**results) assert instance.send(title="title", body="body") is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "UPDATE" assert details[0][1] == "http://localhost" mock_request.reset_mock() # Verify OPTIONS method is forwarded correctly results = NotifyForm.parse_url("form://localhost?method=OPTIONS") instance = NotifyForm(**results) assert instance.send(title="title", body="body") is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "OPTIONS" assert details[0][1] == "http://localhost" caronc-apprise-182f859/tests/test_plugin_custom_json.py000066400000000000000000000340421524161175200234130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.custom_json import NotifyJSON logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "json://:@/", { "instance": None, }, ), ( "json://", { "instance": None, }, ), ( "jsons://", { "instance": None, }, ), ( "json://localhost", { "instance": NotifyJSON, }, ), ( "json://user@localhost?method=invalid", { "instance": TypeError, }, ), ( "json://user:pass@localhost", { "instance": NotifyJSON, # Our expected url(privacy=True) startswith() response: "privacy_url": "json://user:****@localhost", }, ), ( "json://user@localhost", { "instance": NotifyJSON, }, ), # Test method variations ( "json://user@localhost?method=put", { "instance": NotifyJSON, }, ), ( "json://user@localhost?method=get", { "instance": NotifyJSON, }, ), ( "json://user@localhost?method=post", { "instance": NotifyJSON, "force_debug": True, }, ), ( "json://user@localhost?method=head", { "instance": NotifyJSON, }, ), ( "json://user@localhost?method=delete", { "instance": NotifyJSON, }, ), ( "json://user@localhost?method=patch", { "instance": NotifyJSON, }, ), ( "json://user@localhost?method=update", { "instance": NotifyJSON, }, ), ( "json://user@localhost?method=options", { "instance": NotifyJSON, }, ), # Continue testing other cases ( "json://localhost:8080", { "instance": NotifyJSON, }, ), ( "json://user:pass@localhost:8080", { "instance": NotifyJSON, }, ), ( "jsons://localhost", { "instance": NotifyJSON, }, ), ( "jsons://user:pass@localhost", { "instance": NotifyJSON, }, ), ( "jsons://localhost:8080/path/", { "instance": NotifyJSON, # Our expected url(privacy=True) startswith() response: "privacy_url": "jsons://localhost:8080/path/", }, ), # Test our GET params ( "json://localhost:8080/path?-ParamA=Value", { "instance": NotifyJSON, }, ), ( "jsons://user:password@localhost:8080", { "instance": NotifyJSON, # Our expected url(privacy=True) startswith() response: "privacy_url": "jsons://user:****@localhost:8080", }, ), # Test our Headers ( "json://localhost:8080/path?+HeaderKey=HeaderValue", { "instance": NotifyJSON, }, ), ( "json://user:pass@localhost:8081", { "instance": NotifyJSON, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "json://user:pass@localhost:8082", { "instance": NotifyJSON, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "json://user:pass@localhost:8083", { "instance": NotifyJSON, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_custom_json_urls(): """NotifyJSON() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.request") def test_plugin_custom_json_edge_cases(mock_request): """NotifyJSON() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = response # This string also tests that type is set to nothing results = NotifyJSON.parse_url( "json://localhost:8080/command?" ":message=msg&:test=value&method=GET" "&:type=" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] == 8080 assert results["host"] == "localhost" assert results["fullpath"] == "/command" assert results["path"] == "/" assert results["query"] == "command" assert results["schema"] == "json" assert results["url"] == "json://localhost:8080/command" assert isinstance(results["qsd:"], dict) assert results["qsd:"]["message"] == "msg" # empty special mapping assert results["qsd:"]["type"] == "" instance = NotifyJSON(**results) assert isinstance(instance, NotifyJSON) response = instance.send(title="title", body="body") assert response is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "GET" assert details[0][1] == "http://localhost:8080/command" assert "title" in details[1]["data"] dataset = json.loads(details[1]["data"]) assert dataset["title"] == "title" assert "message" not in dataset # Ensure NotifyType does not leak as an Enum string in JSON assert "NotifyType." not in details[1]["data"] assert "msg" in dataset # type was set to nothing which implies it should be removed assert "type" not in dataset # message over-ride was provided; the body is now in `msg` and not # `message` assert dataset["msg"] == "body" assert "test" in dataset assert dataset["test"] == "value" assert instance.url(privacy=False).startswith( "json://localhost:8080/command?" ) # Generate a new URL based on our last and verify key values are the same new_results = NotifyJSON.parse_url(instance.url(safe=False)) for k in ( "user", "password", "port", "host", "fullpath", "path", "query", "schema", "url", "method", ): assert new_results[k] == results[k] mock_request.reset_mock() results = NotifyJSON.parse_url( "json://localhost:8080/command?:message=msg&method=GET" ) instance = NotifyJSON(**results) assert instance.send(title="title", body="body") is True details = mock_request.call_args_list[0] assert details[0][0] == "GET" dataset = json.loads(details[1]["data"]) assert dataset["type"] == NotifyType.INFO.value assert "NotifyType." not in details[1]["data"] mock_request.reset_mock() # Verify UPDATE method is forwarded correctly results = NotifyJSON.parse_url("json://localhost?method=UPDATE") instance = NotifyJSON(**results) assert instance.send(title="title", body="body") is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "UPDATE" assert details[0][1] == "http://localhost" mock_request.reset_mock() # Verify OPTIONS method is forwarded correctly results = NotifyJSON.parse_url("json://localhost?method=OPTIONS") instance = NotifyJSON(**results) assert instance.send(title="title", body="body") is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "OPTIONS" assert details[0][1] == "http://localhost" @mock.patch("requests.request") def test_notify_json_plugin_attachments(mock_request): """NotifyJSON() Attachments.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" # Assign our mock object our return value mock_request.return_value = okay_response obj = Apprise.instantiate("json://localhost.localdomain/") assert isinstance(obj, NotifyJSON) # Test Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test invalid attachment path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # Test Valid Attachment (load 3) path = ( os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ) attach = AppriseAttachment(path) # Return our good configuration mock_request.side_effect = None mock_request.return_value = okay_response with mock.patch("builtins.open", side_effect=OSError()): # We can't send the message we can't open the attachment for reading assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # test the handling of our batch modes obj = Apprise.instantiate("json://no-reply@example.com/") assert isinstance(obj, NotifyJSON) # Now send an attachment normally without issues mock_request.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_request.call_count == 1 # Based on incomming webhook details defined here: # https://kb.synology.com/en-au/DSM/help/Chat/chat_integration @mock.patch("requests.request") def test_plugin_custom_form_for_synology(mock_request): """NotifyJSON() Synology Chat Test Case.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = response # This is rather confusing, it may be easier to leverage the # synology:// and synologys:// plugins instead, but this is just to prove # that the same message can be sent using the json:// plugin. results = NotifyJSON.parse_url( "jsons://localhost:8081/webapi/entry.cgi?" "-api=SYNO.Chat.External&-method=incoming&-version=2&-token=abc123" "&:message=text&:version=&:type=&:title=&:attachments" "&:file_url=https://i.redd.it/my2t4d2fx0u31.jpg" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] == 8081 assert results["host"] == "localhost" assert results["fullpath"] == "/webapi/entry.cgi" assert results["path"] == "/webapi/" assert results["query"] == "entry.cgi" assert results["schema"] == "jsons" assert results["url"] == "jsons://localhost:8081/webapi/entry.cgi" assert isinstance(results["qsd:"], dict) # Header Entries assert results["qsd-"]["api"] == "SYNO.Chat.External" assert results["qsd-"]["method"] == "incoming" assert results["qsd-"]["version"] == "2" assert results["qsd-"]["token"] == "abc123" instance = NotifyJSON(**results) assert isinstance(instance, NotifyJSON) response = instance.send(title="title", body="body") assert response is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" assert details[0][1] == "https://localhost:8081/webapi/entry.cgi" params = details[1]["params"] assert params.get("api") == "SYNO.Chat.External" assert params.get("method") == "incoming" assert params.get("version") == "2" assert params.get("token") == "abc123" payload = json.loads(details[1]["data"]) assert "version" not in payload assert "title" not in payload assert "message" not in payload assert "type" not in payload assert payload.get("text") == "body" assert payload.get("file_url") == "https://i.redd.it/my2t4d2fx0u31.jpg" caronc-apprise-182f859/tests/test_plugin_custom_xml.py000066400000000000000000000373311524161175200232460ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os import re from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.custom_xml import NotifyXML logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "xml://:@/", { "instance": None, }, ), ( "xml://", { "instance": None, }, ), ( "xmls://", { "instance": None, }, ), ( "xml://localhost", { "instance": NotifyXML, }, ), ( "xml://user@localhost", { "instance": NotifyXML, }, ), ( "xml://user@localhost?method=invalid", { "instance": TypeError, }, ), ( "xml://user:pass@localhost", { "instance": NotifyXML, # Our expected url(privacy=True) startswith() response: "privacy_url": "xml://user:****@localhost", }, ), # Test method variations ( "xml://user@localhost?method=put", { "instance": NotifyXML, }, ), ( "xml://user@localhost?method=get", { "instance": NotifyXML, }, ), ( "xml://user@localhost?method=post", { "instance": NotifyXML, "force_debug": True, }, ), ( "xml://user@localhost?method=head", { "instance": NotifyXML, }, ), ( "xml://user@localhost?method=delete", { "instance": NotifyXML, }, ), ( "xml://user@localhost?method=patch", { "instance": NotifyXML, }, ), ( "xml://user@localhost?method=update", { "instance": NotifyXML, }, ), ( "xml://user@localhost?method=options", { "instance": NotifyXML, }, ), # Continue testing other cases ( "xml://localhost:8080", { "instance": NotifyXML, }, ), ( "xml://user:pass@localhost:8080", { "instance": NotifyXML, }, ), ( "xmls://localhost", { "instance": NotifyXML, }, ), ( "xmls://user:pass@localhost", { "instance": NotifyXML, }, ), # Continue testing other cases ( "xml://localhost:8080", { "instance": NotifyXML, }, ), ( "xml://user:pass@localhost:8080", { "instance": NotifyXML, }, ), ( "xml://localhost", { "instance": NotifyXML, }, ), ( "xmls://user:pass@localhost", { "instance": NotifyXML, # Our expected url(privacy=True) startswith() response: "privacy_url": "xmls://user:****@localhost", }, ), ( "xml://user@localhost:8080/path/", { "instance": NotifyXML, "privacy_url": "xml://user@localhost:8080/path", }, ), ( "xmls://localhost:8080/path/", { "instance": NotifyXML, # Our expected url(privacy=True) startswith() response: "privacy_url": "xmls://localhost:8080/path/", }, ), ( "xmls://user:pass@localhost:8080", { "instance": NotifyXML, }, ), # Test our GET params ( "xml://localhost:8080/path?-ParamA=Value", { "instance": NotifyXML, }, ), # Test our Headers ( "xml://localhost:8080/path?+HeaderKey=HeaderValue", { "instance": NotifyXML, }, ), ( "xml://user:pass@localhost:8081", { "instance": NotifyXML, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "xml://user:pass@localhost:8082", { "instance": NotifyXML, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "xml://user:pass@localhost:8083", { "instance": NotifyXML, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_custom_xml_urls(): """NotifyXML() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.request") def test_notify_xml_plugin_attachments(mock_request): """NotifyXML() Attachments.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" # Assign our mock object our return value mock_request.return_value = okay_response obj = Apprise.instantiate("xml://localhost.localdomain/") assert isinstance(obj, NotifyXML) # Test Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test invalid attachment path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # Test Valid Attachment (load 3) path = ( os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ) attach = AppriseAttachment(path) # Return our good configuration mock_request.side_effect = None mock_request.return_value = okay_response with mock.patch("builtins.open", side_effect=OSError()): # We can't send the message we can't open the attachment for reading assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # test the handling of our batch modes obj = Apprise.instantiate("xml://no-reply@example.com/") assert isinstance(obj, NotifyXML) # Now send an attachment normally without issues mock_request.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_request.call_count == 1 @mock.patch("requests.request") def test_plugin_custom_xml_edge_cases(mock_request): """NotifyXML() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = response results = NotifyXML.parse_url( "xml://localhost:8080/command?:Message=Body&method=GET" "&:Key=value&:,=invalid&:MessageType=" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] == 8080 assert results["host"] == "localhost" assert results["fullpath"] == "/command" assert results["path"] == "/" assert results["query"] == "command" assert results["schema"] == "xml" assert results["url"] == "xml://localhost:8080/command" assert isinstance(results["qsd:"], dict) assert results["qsd:"]["Message"] == "Body" assert results["qsd:"]["Key"] == "value" assert results["qsd:"][","] == "invalid" instance = NotifyXML(**results) assert isinstance(instance, NotifyXML) # XSD URL is disabled due to custom formatting assert instance.xsd_url is None response = instance.send(title="title", body="body") assert response is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert "NotifyType." not in details[1]["data"] assert details[0][0] == "GET" assert details[0][1] == "http://localhost:8080/command" assert instance.url(privacy=False).startswith( "xml://localhost:8080/command?" ) # Generate a new URL based on our last and verify key values are the same new_results = NotifyXML.parse_url(instance.url(safe=False)) for k in ( "user", "password", "port", "host", "fullpath", "path", "query", "schema", "url", "method", ): assert new_results[k] == results[k] # Test our data set for our key/value pair assert re.search(r"[1-9]+\.[0-9]+", details[1]["data"]) assert "title" in details[1]["data"] assert "test" not in details[1]["data"] assert "" not in details[1]["data"] # MessageType was removed from the payload assert "" not in details[1]["data"] # However we can find our mapped Message to the new value Body assert "body" in details[1]["data"] # Custom entry assert "value" in details[1]["data"] mock_request.reset_mock() results = NotifyXML.parse_url( "xml://localhost:8081/command?method=POST&:New=Value" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] == 8081 assert results["host"] == "localhost" assert results["fullpath"] == "/command" assert results["path"] == "/" assert results["query"] == "command" assert results["schema"] == "xml" assert results["url"] == "xml://localhost:8081/command" assert isinstance(results["qsd:"], dict) assert results["qsd:"]["New"] == "Value" instance = NotifyXML(**results) assert isinstance(instance, NotifyXML) # XSD URL is disabled due to custom formatting assert instance.xsd_url is None response = instance.send(title="title", body="body") assert response is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert "NotifyType." not in details[1]["data"] assert details[0][0] == "POST" assert details[0][1] == "http://localhost:8081/command" assert instance.url(privacy=False).startswith( "xml://localhost:8081/command?" ) # Generate a new URL based on our last and verify key values are the same new_results = NotifyXML.parse_url(instance.url(safe=False)) for k in ( "user", "password", "port", "host", "fullpath", "path", "query", "schema", "url", "method", ): assert new_results[k] == results[k] # Test our data set for our key/value pair assert re.search(r"[1-9]+\.[0-9]+", details[1]["data"]) assert re.search( r"{}".format(NotifyType.INFO.value), details[1]["data"], ) assert r"title" in details[1]["data"] # No over-ride assert r"body" in details[1]["data"] mock_request.reset_mock() results = NotifyXML.parse_url( "xmls://localhost?method=POST&:Message=Body&:Subject=Title&:Version" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] is None assert results["host"] == "localhost" assert results["fullpath"] is None assert results["path"] is None assert results["query"] is None assert results["schema"] == "xmls" assert results["url"] == "xmls://localhost" assert isinstance(results["qsd:"], dict) assert results["qsd:"]["Version"] == "" assert results["qsd:"]["Message"] == "Body" assert results["qsd:"]["Subject"] == "Title" instance = NotifyXML(**results) assert isinstance(instance, NotifyXML) # XSD URL is disabled due to custom formatting assert instance.xsd_url is None response = instance.send(title="title", body="body") assert response is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert "NotifyType." not in details[1]["data"] assert details[0][0] == "POST" assert details[0][1] == "https://localhost" assert instance.url(privacy=False).startswith("xmls://localhost") # Generate a new URL based on our last and verify key values are the same new_results = NotifyXML.parse_url(instance.url(safe=False)) # Test that the Version has been dropped assert ( re.search(r"[1-9]+\.[0-9]+", details[1]["data"]) is None ) # Test our data set for our key/value pair assert re.search( r"{}".format(NotifyType.INFO.value), details[1]["data"], ) # Subject is swapped for Title assert r"title" not in details[1]["data"] assert r"title" in details[1]["data"] # Message is swapped for Body assert r"body" not in details[1]["data"] assert r"body" in details[1]["data"] mock_request.reset_mock() # Verify UPDATE method is forwarded correctly results = NotifyXML.parse_url("xml://localhost?method=UPDATE") instance = NotifyXML(**results) assert instance.send(title="title", body="body") is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "UPDATE" assert details[0][1] == "http://localhost" mock_request.reset_mock() # Verify OPTIONS method is forwarded correctly results = NotifyXML.parse_url("xml://localhost?method=OPTIONS") instance = NotifyXML(**results) assert instance.send(title="title", body="body") is True assert mock_request.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "OPTIONS" assert details[0][1] == "http://localhost" caronc-apprise-182f859/tests/test_plugin_d7networks.py000066400000000000000000000175501524161175200231640ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyType from apprise.plugins.d7networks import NotifyD7Networks logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "d7sms://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "d7sms://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "d7sms://token@{}/{}/{}".format("1" * 9, "2" * 15, "a" * 13), { # No valid targets to notify "instance": NotifyD7Networks, # Since there are no targets specified we expect a False return on # send() "notify_response": False, }, ), ( "d7sms://token1@{}?batch=yes".format("3" * 14), { # valid number "instance": NotifyD7Networks, # Our expected url(privacy=True) startswith() response: "privacy_url": "d7sms://t...1@", }, ), ( "d7sms://token:colon2@{}?batch=yes".format("3" * 14), { # valid number - token containing a colon "instance": NotifyD7Networks, # Our expected url(privacy=True) startswith() response: "privacy_url": "d7sms://t...2@", }, ), ( "d7sms://:token3@{}?batch=yes".format("3" * 14), { # valid number - token starting wit a colon "instance": NotifyD7Networks, # Our expected url(privacy=True) startswith() response: "privacy_url": "d7sms://:...3@", }, ), ( "d7sms://{}?token=token6".format("3" * 14), { # valid number - token starting wit a colon "instance": NotifyD7Networks, # Our expected url(privacy=True) startswith() response: "privacy_url": "d7sms://t...6@", }, ), ( "d7sms://token4@{}?unicode=no".format("3" * 14), { # valid number - test unicode "instance": NotifyD7Networks, # Our expected url(privacy=True) startswith() response: "privacy_url": "d7sms://t...4@", }, ), ( "d7sms://token8@{}/{}/?unicode=yes".format("3" * 14, "4" * 14), { # valid number - test unicode "instance": NotifyD7Networks, # Our expected url(privacy=True) startswith() response: "privacy_url": "d7sms://t...8@", }, ), ( "d7sms://token@{}?batch=yes&to={}".format("3" * 14, "6" * 14), { # valid number "instance": NotifyD7Networks, }, ), ( "d7sms://token@{}?batch=yes&from=apprise".format("3" * 14), { # valid number, utilizing the optional from= variable "instance": NotifyD7Networks, }, ), ( "d7sms://token@{}?batch=yes&source=apprise".format("3" * 14), { # valid number, utilizing the optional source= variable (same as # from) "instance": NotifyD7Networks, }, ), ( "d7sms://token@{}?batch=no".format("3" * 14), { # valid number - no batch "instance": NotifyD7Networks, }, ), ( "d7sms://token@{}".format("3" * 14), { "instance": NotifyD7Networks, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "d7sms://token@{}".format("3" * 14), { "instance": NotifyD7Networks, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_d7networks_urls(): """NotifyD7Networks() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_d7networks_edge_cases(mock_post): """NotifyD7Networks() Edge Cases tests.""" # Prepare Mock request = mock.Mock() request.content = "{}" request.status_code = requests.codes.ok mock_post.return_value = request # Initializations aobj = Apprise() assert aobj.add("d7sms://Token@15551231234/15551231236") body = "test message" # Send our notification assert aobj.notify(body=body, title="title", notify_type=NotifyType.INFO) # Not set to batch, so we send 2 different messages assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.d7networks.com/messages/v1/send" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.d7networks.com/messages/v1/send" ) # our first post data = loads(mock_post.call_args_list[0][1]["data"]) assert len(data["messages"]) == 1 message = data["messages"][0] assert len(message["recipients"]) == 1 assert message["content"] == "title\r\ntest message" assert message["data_coding"] == "auto" # our second post data = loads(mock_post.call_args_list[1][1]["data"]) assert len(data["messages"]) == 1 message = data["messages"][0] assert len(message["recipients"]) == 1 assert message["content"] == "title\r\ntest message" assert message["data_coding"] == "auto" # # Do a batch test now # mock_post.reset_mock() # Initializations aobj = Apprise() assert aobj.add("d7sms://Token@15551231234/15551231236?batch=yes") body = "test message" # Send our notification assert aobj.notify(body=body, title="title", notify_type=NotifyType.INFO) # All notifications go through in a batch assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.d7networks.com/messages/v1/send" ) data = loads(mock_post.call_args_list[0][1]["data"]) assert len(data["messages"]) == 1 message = data["messages"][0] # All of our phone numbers were added here assert len(message["recipients"]) == 2 assert "15551231234" in message["recipients"] assert "15551231236" in message["recipients"] assert message["content"] == "title\r\ntest message" assert message["data_coding"] == "auto" caronc-apprise-182f859/tests/test_plugin_dapnet.py000066400000000000000000000206101524161175200223170ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests import apprise from apprise import NotifyType from apprise.plugins.dapnet import DapnetPriority, NotifyDapnet logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "dapnet://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "dapnet://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "dapnet://user:pass", { # No call-sign specified "instance": TypeError, }, ), ( "dapnet://user@host", { # No password specified "instance": TypeError, }, ), ( "dapnet://user:pass@{}".format("DF1ABC"), { # valid call sign "instance": NotifyDapnet, "requests_response_code": requests.codes.created, }, ), ( "dapnet://user:pass@{}/{}".format("DF1ABC", "DF1DEF"), { # valid call signs "instance": NotifyDapnet, "requests_response_code": requests.codes.created, }, ), ( "dapnet://user:pass@DF1ABC-1/DF1ABC/DF1ABC-15", { # valid call signs; but a few are duplicates; # at the end there will only be 1 entry "instance": NotifyDapnet, "requests_response_code": requests.codes.created, # Our expected url(privacy=True) startswith() response: # Note that only 1 entry is saved (as other 2 are duplicates) "privacy_url": "dapnet://user:****@D...C?", }, ), ( "dapnet://user:pass@?to={},{}".format("DF1ABC", "DF1DEF"), { # support the to= argument "instance": NotifyDapnet, "requests_response_code": requests.codes.created, }, ), ( "dapnet://user:pass@{}?priority=normal".format("DF1ABC"), { # valid call sign with priority "instance": NotifyDapnet, "requests_response_code": requests.codes.created, }, ), ( "dapnet://user:pass@{}?priority=em&batch=false".format( "/".join(["DF1ABC", "0A1DEF"]) ), { # valid call sign with priority (emergency) + no batch # transmissions "instance": NotifyDapnet, "requests_response_code": requests.codes.created, }, ), ( "dapnet://user:pass@{}?priority=invalid".format("DF1ABC"), { # invalid priority "instance": NotifyDapnet, "requests_response_code": requests.codes.created, }, ), ( "dapnet://user:pass@{}?txgroups=dl-all,all".format("DF1ABC"), { # valid call sign with two transmitter groups "instance": NotifyDapnet, "requests_response_code": requests.codes.created, }, ), ( "dapnet://user:pass@{}?txgroups=invalid".format("DF1ABC"), { # valid call sign with invalid transmitter group "instance": NotifyDapnet, "requests_response_code": requests.codes.created, }, ), ( "dapnet://user:pass@{}/{}".format("abcdefghi", "a"), { # invalid call signs "instance": NotifyDapnet, "notify_response": False, }, ), # Edge cases ( "dapnet://user:pass@{}".format("DF1ABC"), { "instance": NotifyDapnet, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "dapnet://user:pass@{}".format("DF1ABC"), { "instance": NotifyDapnet, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_dapnet_urls(): """NotifyDapnet() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_dapnet_edge_cases(mock_post): """NotifyDapnet() Edge Cases.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.created # test the handling of our batch modes obj = apprise.Apprise.instantiate( "dapnet://user:pass@{}?batch=yes".format( "/".join(["DF1ABC", "DF1DEF"]) ) ) assert isinstance(obj, NotifyDapnet) # objects will be combined into a single post in batch mode assert len(obj) == 1 # Force our batch to break into separate messages obj.default_batch_size = 1 # We'll send 2 messages now assert len(obj) == 2 assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_dapnet_config_files(mock_post): """NotifyDapnet() Config File Cases.""" content = """ urls: - dapnet://user:pass@DF1ABC: - priority: 0 tag: dapnet_int normal - priority: "0" tag: dapnet_str_int normal - priority: normal tag: dapnet_str normal # This will take on normal (default) priority - priority: invalid tag: dapnet_invalid - dapnet://user1:pass2@DF1ABC: - priority: 1 tag: dapnet_int emerg - priority: "1" tag: dapnet_str_int emerg - priority: emergency tag: dapnet_str emerg """ # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.created # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 7 servers from that # 4x normal (invalid + 3 exclusivly specified to be so) # 3x emerg assert len(ac.servers()) == 7 assert len(aobj) == 7 assert len(list(aobj.find(tag="normal"))) == 3 for s in aobj.find(tag="normal"): assert s.priority == DapnetPriority.NORMAL assert len(list(aobj.find(tag="emerg"))) == 3 for s in aobj.find(tag="emerg"): assert s.priority == DapnetPriority.EMERGENCY assert len(list(aobj.find(tag="dapnet_str"))) == 2 assert len(list(aobj.find(tag="dapnet_str_int"))) == 2 assert len(list(aobj.find(tag="dapnet_int"))) == 2 assert len(list(aobj.find(tag="dapnet_invalid"))) == 1 assert ( next(aobj.find(tag="dapnet_invalid")).priority == DapnetPriority.NORMAL ) # Notifications work assert aobj.notify(title="title", body="body") is True caronc-apprise-182f859/tests/test_plugin_dbus.py000066400000000000000000000310051524161175200220010ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import sys import types from unittest.mock import ANY, Mock from helpers import reload_plugin import pytest import apprise # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) @pytest.fixture def mock_dbus_module(mocker): """ Creates a completely fake 'dbus' module structure in sys.modules so that we can test the plugin even if dbus is not installed. """ # 1. Create the base 'dbus' module dbus = types.ModuleType("dbus") dbus.DBusException = type("DBusException", (Exception,), {}) dbus.Byte = lambda x: x dbus.ByteArray = lambda x: x dbus.Interface = Mock(name="Interface") dbus.SessionBus = Mock(name="SessionBus") # 2. Mock the mainloops # We create the structure: dbus.mainloop.glib.DBusGMainLoop glib_module = types.ModuleType("dbus.mainloop.glib") glib_module.DBusGMainLoop = Mock(name="DBusGMainLoop") qt_module = types.ModuleType("dbus.mainloop.qt") qt_module.DBusQtMainLoop = Mock(name="DBusQtMainLoop") mainloop_pkg = types.ModuleType("dbus.mainloop") mainloop_pkg.glib = glib_module mainloop_pkg.qt = qt_module # Attach mainloop to the dbus module object so tests can access # mock_dbus_module.mainloop dbus.mainloop = mainloop_pkg # 3. Create the GI/GdkPixbuf mocks (for image support) gi = types.ModuleType("gi") gi.require_version = Mock() gi.repository = types.SimpleNamespace( GdkPixbuf=types.SimpleNamespace(Pixbuf=Mock(name="Pixbuf")) ) # Setup standard Pixbuf behavior mock_image = Mock() mock_image.get_width.return_value = 10 mock_image.get_height.return_value = 10 mock_image.get_rowstride.return_value = 1 mock_image.get_has_alpha.return_value = False mock_image.get_bits_per_sample.return_value = 8 mock_image.get_n_channels.return_value = 3 mock_image.get_pixels.return_value = b"pixeldata" gi.repository.GdkPixbuf.Pixbuf.new_from_file.return_value = mock_image # 4. Patch everything into sys.modules mocker.patch.dict( sys.modules, { "dbus": dbus, "dbus.mainloop": mainloop_pkg, "dbus.mainloop.glib": glib_module, "dbus.mainloop.qt": qt_module, "gi": gi, "gi.repository": gi.repository, }, ) return dbus def test_plugin_dbus_initialization_strategies(mock_dbus_module, mocker): """ Test the import logic 1. GLib present, Qt missing 2. Qt present, GLib missing 3. Both missing """ # Scenario A: GLib works, Qt fails # We simulate Qt missing by raising ImportError when accessing it sys.modules["dbus.mainloop.qt"] = None reload_plugin("dbus") from apprise.plugins.dbus import LOOP_GLIB, LOOP_QT, NotifyDBus assert LOOP_GLIB is not None assert LOOP_QT is None assert NotifyDBus.enabled is True # Scenario B: Qt works, GLib fails # Reset modules mock_dbus_module.mainloop.qt.DBusQtMainLoop.return_value = "qt_loop" mocker.patch.dict( sys.modules, { "dbus.mainloop.glib": None, "dbus.mainloop.qt": mock_dbus_module.mainloop.qt, }, ) reload_plugin("dbus") from apprise.plugins.dbus import LOOP_GLIB, LOOP_QT, NotifyDBus assert LOOP_GLIB is None assert LOOP_QT is not None assert NotifyDBus.enabled is True # Scenario C: Both fail mocker.patch.dict( sys.modules, {"dbus.mainloop.glib": None, "dbus.mainloop.qt": None} ) reload_plugin("dbus") from apprise.plugins.dbus import NotifyDBus assert NotifyDBus.enabled is False def test_plugin_dbus_image_support_initialization(mock_dbus_module, mocker): """ Test the GdkPixbuf import logic """ # Case 1: Success (Already set up by fixture) reload_plugin("dbus") from apprise.plugins.dbus import NOTIFY_DBUS_IMAGE_SUPPORT assert NOTIFY_DBUS_IMAGE_SUPPORT is True # Case 2: GI missing mocker.patch.dict(sys.modules, {"gi": None}) reload_plugin("dbus") from apprise.plugins.dbus import NOTIFY_DBUS_IMAGE_SUPPORT assert NOTIFY_DBUS_IMAGE_SUPPORT is False def test_plugin_dbus_send_success(mock_dbus_module, mocker): """ Test the happy path for send() """ reload_plugin("dbus") from apprise.plugins.dbus import NotifyDBus # Setup the mock chain: SessionBus() -> # .get_object() -> Interface() -> .Notify() mock_bus = mock_dbus_module.SessionBus.return_value mock_proxy = mock_bus.get_object.return_value mock_interface = mock_dbus_module.Interface.return_value obj = NotifyDBus() # Send notification assert obj.notify(title="Title", body="Body") is True # VERIFICATION # Check SessionBus was called mock_dbus_module.SessionBus.assert_called() # Check Interface was created mock_dbus_module.Interface.assert_called_with( mock_proxy, dbus_interface="org.freedesktop.Notifications" ) # Check Notify was called assert mock_interface.Notify.called args, _ = mock_interface.Notify.call_args # Arg 3 is Title, Arg 4 is Body assert args[3] == "Title" assert args[4] == "Body" def test_plugin_dbus_send_no_title(mock_dbus_module): """ Test the 'if not title' swap logic """ reload_plugin("dbus") from apprise.plugins.dbus import NotifyDBus mock_interface = mock_dbus_module.Interface.return_value obj = NotifyDBus() obj.notify(title="", body="OnlyBody") args, _ = mock_interface.Notify.call_args # Title (Arg 3) should now be "OnlyBody", Body (Arg 4) empty assert args[3] == "OnlyBody" assert args[4] == "" def test_plugin_dbus_send_connection_failure(mock_dbus_module): """ Test SessionBus raising DBusException """ reload_plugin("dbus") from apprise.plugins.dbus import NotifyDBus # Force constructor to crash mock_dbus_module.SessionBus.side_effect = mock_dbus_module.DBusException( "Connection Refused" ) obj = NotifyDBus() # Should handle exception and return False assert obj.notify(title="T", body="B") is False def test_plugin_dbus_send_notify_failure(mock_dbus_module): """ Test Interface.Notify raising Exception """ reload_plugin("dbus") from apprise.plugins.dbus import NotifyDBus # Force Notify to crash mock_interface = mock_dbus_module.Interface.return_value mock_interface.Notify.side_effect = Exception("Generic Failure") obj = NotifyDBus() assert obj.notify(title="T", body="B") is False def test_plugin_dbus_image_loading_failure(mock_dbus_module, mocker): """ Test image loading exception """ reload_plugin("dbus") # Force GdkPixbuf to crash import gi gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = Exception( "Bad Image" ) # Use Apprise.instantiate to handle parsing cleanly obj = apprise.Apprise.instantiate( "dbus://?image=yes", suppress_exceptions=False ) spy_logger = mocker.spy(obj, "logger") # Notification should still succeed (return True), just log a warning assert obj.notify(title="T", body="B") is True # Verify the warning was logged spy_logger.warning.assert_called_with( "Could not load notification icon (%s).", ANY ) def test_plugin_dbus_url_parsing(mock_dbus_module): """ Test various URL parameters and instantiation. """ reload_plugin("dbus") from apprise.plugins.dbus import DBusUrgency, NotifyDBus # Test Urgency mapping obj = apprise.Apprise.instantiate( "dbus://?urgency=high", suppress_exceptions=False ) assert isinstance(obj, NotifyDBus) assert obj.urgency == DBusUrgency.HIGH assert "urgency=high" in obj.url() obj = apprise.Apprise.instantiate( "dbus://?priority=low", suppress_exceptions=False ) assert isinstance(obj, NotifyDBus) assert obj.urgency == DBusUrgency.LOW assert "urgency=low" in obj.url() # Test X/Y coords obj = apprise.Apprise.instantiate( "dbus://?x=100&y=200", suppress_exceptions=False ) assert obj.x_axis == 100 assert obj.y_axis == 200 assert "x=100" in obj.url() assert "y=200" in obj.url() # Test Invalid X/Y with pytest.raises(TypeError): NotifyDBus(x_axis="invalid") def test_plugin_dbus_zero_coordinate_round_trip(mock_dbus_module): """ Coordinate 0 is a valid screen edge (send() emits it via an `is None` guard); url() must preserve it so the configuration survives a save/reload cycle instead of being silently dropped. """ reload_plugin("dbus") from apprise.plugins.dbus import NotifyDBus obj = apprise.Apprise.instantiate( "dbus://_/?x=0&y=0", suppress_exceptions=False ) assert isinstance(obj, NotifyDBus) assert obj.x_axis == 0 assert obj.y_axis == 0 url = obj.url() assert "x=0" in url assert "y=0" in url # The rendered URL must round-trip back to an identical object rather # than dropping x=0 (which previously also raised a TypeError on reload # whenever the surviving axis kept the guard truthy). reloaded = apprise.Apprise.instantiate(url, suppress_exceptions=False) assert isinstance(reloaded, NotifyDBus) assert reloaded.x_axis == 0 assert reloaded.y_axis == 0 # A single zero axis must also survive without dropping the other value. obj = apprise.Apprise.instantiate( "dbus://_/?x=0&y=5", suppress_exceptions=False ) reloaded = apprise.Apprise.instantiate( obj.url(), suppress_exceptions=False ) assert reloaded.x_axis == 0 assert reloaded.y_axis == 5 def test_plugin_dbus_schema_not_supported(mock_dbus_module, mocker): """ Dbus test for unsupported schema warning """ reload_plugin("dbus") from apprise.plugins.dbus import NotifyDBus with pytest.raises(TypeError): NotifyDBus(schema="not-a-real-schema") # Assert warning emitted (message content is stable) with pytest.raises(TypeError): NotifyDBus(schema="still-not-real") def test_plugin_dbus_send_sets_xy_meta_payload(mock_dbus_module): """ Covers setting x-y payload setting """ reload_plugin("dbus") from apprise.plugins.dbus import NotifyDBus mock_interface = mock_dbus_module.Interface.return_value obj = NotifyDBus(x_axis=100, y_axis=200) assert obj.notify(title="T", body="B") is True args, _ = mock_interface.Notify.call_args # meta output (app, id, icon, title, body, actions, meta, timeout) meta = args[6] assert meta["x"] == 100 assert meta["y"] == 200 def test_plugin_dbus_send_image_condition_false_skips_pixbuf( mock_dbus_module, mocker ): """ Covers NOTIFY_DBUS_IMAGE_SUPPORT and icon_path flag """ reload_plugin("dbus") import gi from apprise.plugins.dbus import NotifyDBus mock_interface = mock_dbus_module.Interface.return_value obj = NotifyDBus(include_image=False) # Ensure image_path is not even consulted when include_image=False spy_image_path = mocker.spy(obj, "image_path") assert obj.notify(title="T", body="B") is True assert mock_interface.Notify.called assert gi.repository.GdkPixbuf.Pixbuf.new_from_file.called is False assert spy_image_path.called is False caronc-apprise-182f859/tests/test_plugin_dingtalk.py000066400000000000000000000124461524161175200226510ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyFormat from apprise.plugins.dingtalk import NotifyDingTalk logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "dingtalk://", { # No Access Token specified "instance": TypeError, }, ), ( "dingtalk://a_bd_/", { # invalid Access Token "instance": TypeError, }, ), ( "dingtalk://12345678", { # access token "instance": NotifyDingTalk, # Our expected url(privacy=True) startswith() response: "privacy_url": "dingtalk://1...8", }, ), ( "dingtalk://{}/{}".format("a" * 8, "1" * 14), { # access token + phone number "instance": NotifyDingTalk, }, ), ( "dingtalk://{}/{}/invalid".format("a" * 8, "1" * 3), { # access token + 2 invalid phone numbers "instance": NotifyDingTalk, }, ), ( "dingtalk://{}/?to={}".format("a" * 8, "1" * 14), { # access token + phone number using 'to' "instance": NotifyDingTalk, }, ), # Test secret via user@ ( "dingtalk://secret@{}/?to={}".format("a" * 8, "1" * 14), { # access token + phone number using 'to' "instance": NotifyDingTalk, # Our expected url(privacy=True) startswith() response: "privacy_url": "dingtalk://****@a...a", }, ), # Test secret via secret= and token= ( "dingtalk://?token={}&to={}&secret={}".format( "b" * 8, "1" * 14, "a" * 15 ), { # access token + phone number using 'to' "instance": NotifyDingTalk, "privacy_url": "dingtalk://****@b...b", }, ), # Invalid secret ( "dingtalk://{}/?to={}&secret=_".format("a" * 8, "1" * 14), { "instance": TypeError, }, ), ( "dingtalk://{}?format=markdown".format("a" * 8), { # access token "instance": NotifyDingTalk, }, ), ( "dingtalk://{}".format("a" * 8), { "instance": NotifyDingTalk, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "dingtalk://{}".format("a" * 8), { "instance": NotifyDingTalk, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_dingtalk_urls(): """NotifyDingTalk() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_dingtalk_html_to_markdown_format(mock_post): """NotifyDingTalk(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Load via Apprise URL with markdown format enabled aobj = Apprise() assert aobj.add("dingtalk://{}?format=markdown".format("a" * 8)) # Notify with an HTML body; the framework converts it to Markdown # before dispatching to the DingTalk plugin assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown, not stripped plain text payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["markdown"]["text"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_discord.py000066400000000000000000001556561524161175200225160ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timedelta, timezone from json import loads # Disable logging for a cleaner testing output import logging import os from random import choice from string import ascii_uppercase as str_alpha, digits as str_num from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType from apprise.common import OverflowMode from apprise.plugins.discord import NotifyDiscord logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "discord://", { "instance": TypeError, }, ), # An invalid url ( "discord://:@/", { "instance": TypeError, }, ), # No webhook_token specified ( "discord://%s" % ("i" * 24), { "instance": TypeError, }, ), # Provide both an webhook id and a webhook token ( "discord://{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Provide a temporary username ( "discord://l2g@{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # test image= field ( "discord://{}/{}?format=markdown&footer=Yes&image=Yes&ping=Joe".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, # don't include an image by default "include_image": False, }, ), ( "discord://{}/{}?format=markdown&footer=Yes&image=No&fields=no".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "discord://jack@{}/{}?format=markdown&footer=Yes&image=Yes".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, "privacy_url": "discord://jack@i...i/t...t/", }, ), ( "https://discord.com/api/webhooks/{}/{}".format("0" * 10, "B" * 40), { # Native URL Support, support the provided discord URL from their # webpage. "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "https://discordapp.com/api/webhooks/{}/{}".format("0" * 10, "B" * 40), { # Legacy Native URL Support, support the older URL (to be # decomissioned on Nov 7th 2020) "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "https://discordapp.com/api/webhooks/{}/{}?footer=yes".format( "0" * 10, "B" * 40 ), { # Native URL Support with arguments "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, "privacy_url": "discord://0...0/B...B/", }, ), ( "https://discordapp.com/api/webhooks/{}/{}?footer=yes&botname=joe".format( "0" * 10, "B" * 40 ), { # Native URL Support with arguments "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, "privacy_url": "discord://joe@0...0/B...B/", }, ), ( "discord://{}/{}?format=markdown&avatar=No&footer=No".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "discord://{}/{}?flags=1".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "discord://{}/{}?flags=-1".format("i" * 24, "t" * 64), { # invalid flags specified (variation 1) "instance": TypeError, }, ), ( "discord://{}/{}?flags=invalid".format("i" * 24, "t" * 64), { # invalid flags specified (variation 2) "instance": TypeError, }, ), # different format support ( "discord://{}/{}?format=markdown".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Thread ID ( "discord://{}/{}?format=markdown&thread=abc123".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "discord://{}/{}?format=text".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test with href (title link) ( "discord://{}/{}?hmarkdown=true&ref=http://localhost".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test with url (title link) - Alias of href ( "discord://{}/{}?markdown=true&url=http://localhost".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test with avatar URL ( "discord://{}/{}?avatar_url=http://localhost/test.jpg".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test without image set ( "discord://{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, # don't include an image by default "include_image": False, }, ), # Test batch attachment mode enabled (default) ( "discord://{}/{}?batch=yes".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test batch attachment mode disabled (legacy one-per-message) ( "discord://{}/{}?batch=no".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "discord://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyDiscord, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "discord://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyDiscord, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "discord://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyDiscord, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_discord_urls(): """NotifyDiscord() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_discord_notifications(mock_post): """NotifyDiscord() Notifications/Ping Support.""" # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Test our header parsing when not lead with a header body = """ # Heading @everyone and @admin, wake and meet our new user <@123>; <@&456>" """ results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/?format=markdown" ) assert isinstance(results, dict) assert results["user"] is None assert results["webhook_id"] == webhook_id assert results["webhook_token"] == webhook_token assert results["password"] is None assert results["port"] is None assert results["host"] == webhook_id assert results["fullpath"] == f"/{webhook_token}/" assert results["path"] == f"/{webhook_token}/" assert results["query"] is None assert results["schema"] == "discord" assert results["url"] == f"discord://{webhook_id}/{webhook_token}/" instance = NotifyDiscord(**results) assert isinstance(instance, NotifyDiscord) response = instance.send(body=body) assert response is True assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert ( details[0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) payload = loads(details[1]["data"]) assert "allow_mentions" in payload assert "users" in payload["allow_mentions"] assert len(payload["allow_mentions"]["users"]) == 1 assert "123" in payload["allow_mentions"]["users"] assert "roles" in payload["allow_mentions"] assert len(payload["allow_mentions"]["roles"]) == 1 assert "456" in payload["allow_mentions"]["roles"] assert "parse" in payload["allow_mentions"] assert len(payload["allow_mentions"]["parse"]) == 2 assert "everyone" in payload["allow_mentions"]["parse"] assert "admin" in payload["allow_mentions"]["parse"] # Reset our object mock_post.reset_mock() results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/?format=text" ) assert isinstance(results, dict) assert results["user"] is None assert results["webhook_id"] == webhook_id assert results["webhook_token"] == webhook_token assert results["password"] is None assert results["port"] is None assert results["host"] == webhook_id assert results["fullpath"] == f"/{webhook_token}/" assert results["path"] == f"/{webhook_token}/" assert results["query"] is None assert results["schema"] == "discord" assert results["url"] == f"discord://{webhook_id}/{webhook_token}/" instance = NotifyDiscord(**results) assert isinstance(instance, NotifyDiscord) response = instance.send(body=body) assert response is True assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert ( details[0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) payload = loads(details[1]["data"]) # text mode does not ping unless ping is explicitly set to someone assert "allow_mentions" not in payload # Reset our object mock_post.reset_mock() # Test our header parsing when not lead with a header body = """ """ results = NotifyDiscord.parse_url( # & -> %26 for role otherwise & separates our URL from further parsing f"discord://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>" ) assert isinstance(results, dict) assert results["user"] is None assert results["webhook_id"] == webhook_id assert results["webhook_token"] == webhook_token assert results["password"] is None assert results["port"] is None assert results["host"] == webhook_id assert results["fullpath"] == f"/{webhook_token}/" assert results["path"] == f"/{webhook_token}/" assert results["query"] is None assert results["schema"] == "discord" assert results["url"] == f"discord://{webhook_id}/{webhook_token}/" instance = NotifyDiscord(**results) assert isinstance(instance, NotifyDiscord) response = instance.send(body=body) assert response is True assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert ( details[0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) payload = loads(details[1]["data"]) assert "allow_mentions" in payload assert len(payload["allow_mentions"]["users"]) == 1 assert "321" in payload["allow_mentions"]["users"] assert "<@321>" in payload["content"] assert len(payload["allow_mentions"]["roles"]) == 1 assert "654" in payload["allow_mentions"]["roles"] assert "<@&654>" in payload["content"] assert len(payload["allow_mentions"]["parse"]) == 1 assert "joe" in payload["allow_mentions"]["parse"] assert "@joe" in payload["content"] # Reset our object mock_post.reset_mock() # Test our body in text mode, with ping=set body = """ # Heading @everyone and @admin, wake and meet our new user <@123>; <@&456>" """ results = NotifyDiscord.parse_url( # & -> %26 for role otherwise & separates our URL from further parsing f"discord://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>" "&format=text" ) assert isinstance(results, dict) assert results["user"] is None assert results["webhook_id"] == webhook_id assert results["webhook_token"] == webhook_token assert results["password"] is None assert results["port"] is None assert results["host"] == webhook_id assert results["fullpath"] == f"/{webhook_token}/" assert results["path"] == f"/{webhook_token}/" assert results["query"] is None assert results["schema"] == "discord" assert results["url"] == f"discord://{webhook_id}/{webhook_token}/" instance = NotifyDiscord(**results) assert isinstance(instance, NotifyDiscord) response = instance.send(body=body) assert response is True assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert ( details[0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) payload = loads(details[1]["data"]) # Payload only includes elements on ping= line with text mode assert "allow_mentions" in payload assert "users" in payload["allow_mentions"] assert len(payload["allow_mentions"]["users"]) == 1 assert "321" in payload["allow_mentions"]["users"] assert "roles" in payload["allow_mentions"] assert len(payload["allow_mentions"]["roles"]) == 1 assert "654" in payload["allow_mentions"]["roles"] assert "parse" in payload["allow_mentions"] assert len(payload["allow_mentions"]["parse"]) == 1 assert "joe" in payload["allow_mentions"]["parse"] @mock.patch("requests.post") @mock.patch("time.sleep") def test_plugin_discord_general(mock_sleep, mock_post): """NotifyDiscord() General Checks.""" # Prevent throttling mock_sleep.return_value = True # Turn off clock skew for local testing NotifyDiscord.clock_skew = timedelta(seconds=0) # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = "" mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # Invalid webhook id with pytest.raises(TypeError): NotifyDiscord(webhook_id=None, webhook_token=webhook_token) # Invalid webhook id (whitespace) with pytest.raises(TypeError): NotifyDiscord(webhook_id=" ", webhook_token=webhook_token) # Invalid webhook token with pytest.raises(TypeError): NotifyDiscord(webhook_id=webhook_id, webhook_token=None) # Invalid webhook token (whitespace) with pytest.raises(TypeError): NotifyDiscord(webhook_id=webhook_id, webhook_token=" ") obj = NotifyDiscord( webhook_id=webhook_id, webhook_token=webhook_token, footer=True, thumbnail=False, ) assert obj.ratelimit_remaining == 1 # Test that we get a string response assert isinstance(obj.url(), str) is True # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Force a case where there are no more remaining posts allowed mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 0, } # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # behind the scenes, it should cause us to update our rate limit assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 # This should cause us to block mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 10, } assert obj.send(body="test") is True assert obj.ratelimit_remaining == 10 # Reset our variable back to 1 mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # Handle cases where our epoch time is wrong del mock_post.return_value.headers["X-RateLimit-Reset"] assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() + 1 ), "X-RateLimit-Remaining": 0, } obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Test 429 error response mock_post.return_value.status_code = requests.codes.too_many_requests # The below will attempt a second transmission and fail (because we didn't # set up a second post request to pass) :) assert obj.send(body="test") is False # Return our object, but place it in the future forcing us to block mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() - 1 ), "X-RateLimit-Remaining": 0, } assert obj.send(body="test") is True # Return our limits to always work obj.ratelimit_remaining = 1 # Return our headers to normal mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Simple Markdown Single line of text test_markdown = "body" desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True assert len(results) == 0 # Test our header parsing when not lead with a header test_markdown = """ A section of text that has no header at the top. It also has a hash tag # <- in the middle of a string. ## Heading 1 body # Heading 2 more content on multi-lines """ desc, results = obj.extract_markdown_sections(test_markdown) # we have a description assert isinstance(desc, str) is True assert desc.startswith("A section of text that has no header at the top.") assert desc.endswith("string.") assert isinstance(results, list) is True assert len(results) == 2 assert results[0]["name"] == "Heading 1" assert results[0]["value"] == "```md\nbody\n```" assert results[1]["name"] == "Heading 2" assert ( results[1]["value"] == "```md\nmore content\n on multi-lines\n```" ) # Test our header parsing test_markdown = ( "## Heading one\nbody body\n\n" + "# Heading 2 ##\n\nTest\n\n" + "more content\n" + "even more content \t\r\n\n\n" + "# Heading 3 ##\n\n\n" + "normal content\n" + "# heading 4\n" + "#### Heading 5" ) desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True # No desc details filled out assert isinstance(desc, str) is True assert not desc # We should have 5 sections (since there are 5 headers identified above) assert len(results) == 5 assert results[0]["name"] == "Heading one" assert results[0]["value"] == "```md\nbody body\n```" assert results[1]["name"] == "Heading 2" assert ( results[1]["value"] == "```md\nTest\n\nmore content\neven more content\n```" ) assert results[2]["name"] == "Heading 3" assert results[2]["value"] == "```md\nnormal content\n```" assert results[3]["name"] == "heading 4" assert results[3]["value"] == "```\n```" assert results[4]["name"] == "Heading 5" assert results[4]["value"] == "```\n```" # Create an apprise instance a = Apprise() # Our processing is slightly different when we aren't using markdown # as we do not pre-parse content during our notifications assert ( a.add( f"discord://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) # This call includes an image with it's payload: NotifyDiscord.discord_max_fields = 1 assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) # Throw an exception on the forth call to requests.post() # This allows to test our batch field processing response = mock.Mock() response.content = "" response.status_code = requests.codes.ok mock_post.return_value = response mock_post.side_effect = [ response, response, response, requests.RequestException(), ] # Test our markdown obj = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?format=markdown" ) assert isinstance(obj, NotifyDiscord) assert ( obj.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO ) is False ) mock_post.side_effect = None # Empty String desc, results = obj.extract_markdown_sections("") assert isinstance(results, list) is True assert len(results) == 0 # No desc details filled out assert isinstance(desc, str) is True assert not desc # String without Heading test_markdown = ( "Just a string without any header entries.\n" + "A second line" ) desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True assert len(results) == 0 # No desc details filled out assert isinstance(desc, str) is True assert ( desc == "Just a string without any header entries.\n" + "A second line" ) # Use our test markdown string during a notification assert ( obj.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO ) is True ) # Create an apprise instance a = Apprise() # Our processing is slightly different when we aren't using markdown # as we do not pre-parse content during our notifications assert ( a.add( f"discord://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) # This call includes an image with it's payload: assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.MARKDOWN, ) is True ) # Toggle our logo availability a.asset.image_url_logo = None assert ( a.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Create an apprise instance a = Apprise() # Reset our object mock_post.reset_mock() # Test our threading assert ( a.add(f"discord://{webhook_id}/{webhook_token}/?thread=12345") is True ) # This call includes an image with it's payload: assert a.notify(body="test", title="title") is True assert mock_post.call_count == 1 response = mock_post.call_args_list[0][1] assert "params" in response assert response["params"].get("thread_id") == "12345" @mock.patch("requests.post") def test_plugin_discord_overflow(mock_post): """NotifyDiscord() Overflow Checks.""" # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Some variables we use to control the data we work with body_len = 8000 title_len = 1024 # Number of characters per line row = 24 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num + " ") for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/?overflow=split" ) assert isinstance(results, dict) assert results["user"] is None assert results["webhook_id"] == webhook_id assert results["webhook_token"] == webhook_token assert results["password"] is None assert results["port"] is None assert results["host"] == webhook_id assert results["fullpath"] == f"/{webhook_token}/" assert results["path"] == f"/{webhook_token}/" assert results["query"] is None assert results["schema"] == "discord" assert results["url"] == f"discord://{webhook_id}/{webhook_token}/" instance = NotifyDiscord(**results) assert isinstance(instance, NotifyDiscord) results = instance._apply_overflow( body, title=title, overflow=OverflowMode.SPLIT ) # Ensure we never exceed 2000 characters for entry in results: assert len(entry["title"]) <= instance.title_maxlen assert len(entry["title"]) + len(entry["body"]) <= instance.body_maxlen @mock.patch("requests.post") def test_plugin_discord_markdown_extra(mock_post): """NotifyDiscord() Markdown Extra Checks.""" # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Reset our apprise object a = Apprise() # We want to further test our markdown support to accommodate bug rased on # 2022.10.25; see https://github.com/caronc/apprise/issues/717 assert ( a.add( f"discord://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) test_markdown = "[green-blue](https://google.com)" # This call includes an image with it's payload: assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) assert ( a.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) @mock.patch("requests.post") def test_plugin_discord_attachments(mock_post): """NotifyDiscord() Attachment Checks.""" # Initialize some generic (but valid) tokens webhook_id = "C" * 24 webhook_token = "D" * 64 # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" # Prepare a bad response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error bad_response.content = b"" # Prepare Mock return object mock_post.return_value = response # Test our markdown obj = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?format=markdown" ) # attach our content attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) assert ( mock_post.call_args_list[1][0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) # Reset our object mock_post.reset_mock() # Test notifications with mentions and attachments in it assert ( obj.notify( body="Say hello to <@1234>!", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) assert ( mock_post.call_args_list[1][0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) # Reset our object mock_post.reset_mock() # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # update our attachment to be valid attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) mock_post.return_value = None # Throw an exception on the first call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.side_effect = [side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # Throw an exception on the second call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.side_effect = [response, side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # handle a bad response bad_response = mock.Mock() bad_response.content = b"" bad_response.headers = {} bad_response.status_code = requests.codes.internal_server_error mock_post.side_effect = [response, bad_response] # We'll fail now because of an internal exception assert obj.send(body="test", attach=attach) is False @mock.patch("requests.post") def test_plugin_discord_markdown_fields_batches_exactly(mock_post): webhook_id = "A" * 24 webhook_token = "B" * 64 response = mock.Mock() response.status_code = requests.codes.ok response.content = "" response.headers = {} mock_post.return_value = response # Force tiny batches NotifyDiscord.discord_max_fields = 1 body = "# H1\nv1\n# H2\nv2\n# H3\nv3\n" obj = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?format=markdown&fields=yes" ) assert isinstance(obj, NotifyDiscord) assert obj.send(body=body) is True # H1, H2, H3 => 3 fields => 3 posts (since max_fields=1) assert mock_post.call_count == 3 @mock.patch("requests.post") def test_plugin_discord_markdown_ping_is_additive(mock_post): webhook_id = "A" * 24 webhook_token = "B" * 64 mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok body = "Body pings <@111> and <@&222> @everyone" results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/" "?format=markdown" "&ping=<@333>,<@%26444>,@joe" ) obj = NotifyDiscord(**results) assert obj.send(body=body) is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert "allow_mentions" in payload # union assert set(payload["allow_mentions"]["users"]) == {"111", "333"} assert set(payload["allow_mentions"]["roles"]) == {"222", "444"} assert set(payload["allow_mentions"]["parse"]) == {"everyone", "joe"} assert payload["content"].startswith("πŸ‘‰ ") @mock.patch("requests.post") def test_plugin_discord_html_ping_is_exclusive(mock_post): webhook_id = "A" * 24 webhook_token = "B" * 64 mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok body = "Body includes <@111> <@&222> @everyone but must be ignored" results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/" "?format=html" "&ping=<@333>,<@%26444>,@joe" ) obj = NotifyDiscord(**results) assert obj.send(body=body) is True payload = loads(mock_post.call_args_list[0][1]["data"]) assert set(payload["allow_mentions"]["users"]) == {"333"} assert set(payload["allow_mentions"]["roles"]) == {"444"} assert set(payload["allow_mentions"]["parse"]) == {"joe"} @mock.patch("requests.post") def test_plugin_discord_markdown_no_mentions_has_no_allow_mentions(mock_post): webhook_id = "A" * 24 webhook_token = "B" * 64 mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/?format=markdown" ) obj = NotifyDiscord(**results) assert obj.send(body="Hello world") is True payload = loads(mock_post.call_args_list[0][1]["data"]) assert "allow_mentions" not in payload assert "content" not in payload @mock.patch("requests.post") def test_plugin_discord_markdown_single_field_posts_once(mock_post): webhook_id = "A" * 24 webhook_token = "B" * 64 response = mock.Mock() response.status_code = requests.codes.ok response.content = "" response.headers = {} mock_post.return_value = response NotifyDiscord.discord_max_fields = 10 body = "# H1\nv1\n" obj = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?format=markdown&fields=yes" ) assert obj.send(body=body) is True assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_discord_attach_memory(mock_post): """Regression: AttachMemory must be sendable without OSError.""" from apprise.attachment.memory import AttachMemory webhook_id = "C" * 24 webhook_token = "D" * 64 response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = Apprise.instantiate(f"discord://{webhook_id}/{webhook_token}/") mem = AttachMemory( content=b"

    Test

    ", name="report.html", mimetype="text/html", ) assert obj.notify(body="Test", attach=mem) is True assert mock_post.call_count >= 1 @mock.patch("requests.post") def test_plugin_discord_html_to_markdown_format(mock_post): """NotifyDiscord(): HTML body is converted to Markdown.""" webhook_id = "A" * 24 webhook_token = "B" * 64 mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = "" mock_post.return_value.headers = {} # Instantiate a Discord plugin in Markdown mode with fields # disabled so the body lands in embeds[0]["description"] directly obj = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?format=markdown&fields=no" ) aobj = Apprise() aobj.add(obj) # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Discord assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive in the embed description as Markdown, # not as stripped plain text payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["embeds"][0]["description"] == "**hello** *world*" @mock.patch("requests.post") def test_plugin_discord_template_content(mock_post, tmpdir): """NotifyDiscord() template with 'content' field.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare a good mock response mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) # Write a minimal Discord webhook template to disk template = tmpdir.join("discord.json") template.write('{"content": "{{app_body}}"}') # Instantiate via URL with template path obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) # Verify the template was loaded assert obj.template # Notification must succeed assert ( obj.notify(body="hello", title="world", notify_type=NotifyType.INFO) is True ) assert mock_post.called is True # Verify the payload sent - content field contains the body posted = loads(mock_post.call_args_list[0][1]["data"]) assert posted["content"] == "hello" @mock.patch("requests.post") def test_plugin_discord_template_embeds(mock_post, tmpdir): """NotifyDiscord() template with 'embeds' field.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare a good mock response mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) # Write a template that uses the embeds structure template = tmpdir.join("embeds.json") template.write( '{"embeds": [{"title": "{{app_title}}", ' '"description": "{{app_body}}"}]}' ) # Instantiate via URL with template path obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) assert obj.template # Notification must succeed assert ( obj.notify( body="my body", title="my title", notify_type=NotifyType.INFO ) is True ) assert mock_post.called is True # Verify the embed payload posted = loads(mock_post.call_args_list[0][1]["data"]) assert "embeds" in posted assert posted["embeds"][0]["title"] == "my title" assert posted["embeds"][0]["description"] == "my body" def test_plugin_discord_template_tokens(tmpdir): """NotifyDiscord() template tokens and URL round-trip.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Write a minimal content template to disk template = tmpdir.join("discord.json") template.write('{"content": "{{app_body}}"}') # Instantiate with explicit tokens dict obj = NotifyDiscord( webhook_id=webhook_id, webhook_token=webhook_token, template=str(template), tokens={"mykey": "myval", "count": "42"}, ) assert obj.template assert obj.tokens["mykey"] == "myval" assert obj.tokens["count"] == "42" # Round-trip through url() and parse_url() url = obj.url() result = NotifyDiscord.parse_url(url) assert result is not None assert "mykey" in result["tokens"] assert result["tokens"]["mykey"] == "myval" obj2 = NotifyDiscord(**result) assert obj2.template assert obj2.tokens.get("mykey") == "myval" assert obj2.url_identifier == obj.url_identifier def test_plugin_discord_template_token_invalid(): """NotifyDiscord() non-dict tokens raises TypeError.""" webhook_id = "A" * 24 webhook_token = "B" * 64 with pytest.raises(TypeError): NotifyDiscord( webhook_id=webhook_id, webhook_token=webhook_token, tokens="not-a-dict", ) def test_plugin_discord_template_add_failure(): """NotifyDiscord() template add() failure raises TypeError.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Force add() to report failure so len(self.template) == 0 with ( mock.patch( "apprise.plugins.discord.AppriseAttachment.add", return_value=False, ), pytest.raises(TypeError), ): NotifyDiscord( webhook_id=webhook_id, webhook_token=webhook_token, template="file:///some/template.json", ) @mock.patch("requests.post") def test_plugin_discord_template_inaccessible(mock_post, tmpdir): """NotifyDiscord() inaccessible template fails gracefully.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare a mock response (should never be called) mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) # Create the template so that add() succeeds during init template = tmpdir.join("discord.json") template.write('{"content": "{{app_body}}"}') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) # Remove the file so the template becomes inaccessible os.remove(str(template)) # Notification must fail; no HTTP call should be made assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_discord_template_oserror(mock_post, tmpdir): """NotifyDiscord() OSError during template read fails gracefully.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare a mock response (should never be called) mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) # Write a file so the attachment resolves but open() will be mocked template = tmpdir.join("discord.json") template.write('{"content": "hello"}') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) # Patch open() to raise OSError when the template is read with mock.patch("builtins.open", side_effect=OSError): assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_discord_template_invalid_json(mock_post, tmpdir): """NotifyDiscord() invalid JSON template fails gracefully.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare a mock response (should never be called) mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) # Write a syntactically invalid JSON file template = tmpdir.join("bad.json") template.write("{ not valid json }") obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) # Notification must fail due to parse error assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_discord_template_not_dict(mock_post, tmpdir): """NotifyDiscord() template root must be a JSON object, not array.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare a mock response (should never be called) mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) # Write a JSON array instead of an object template = tmpdir.join("array.json") template.write('[{"content": "hello"}]') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) # Notification must fail because the root is not a dict assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_discord_template_payload_validation(mock_post, tmpdir): """NotifyDiscord() template payload field validation cases.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare a mock response (should never be called during failures) mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) template = tmpdir.join("t.json") # Case 1: no content and no embeds template.write('{"username": "bot"}') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False # Case 2: content is an empty string template.write('{"content": ""}') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) # Case 3: embeds is an empty list template.write('{"embeds": []}') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_discord_template_bad_embeds(mock_post, tmpdir): """NotifyDiscord() template embeds containing non-dict entries fails.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare a mock response (should never be called) mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) # Write a template where embeds contains a non-dict entry template = tmpdir.join("bad_embeds.json") template.write('{"embeds": ["not-a-dict"]}') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) # Notification must fail because the embed entry is not a dict assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_discord_template_send_failure(mock_post, tmpdir): """NotifyDiscord() HTTP failure in template mode.""" webhook_id = "A" * 24 webhook_token = "B" * 64 # Write a valid template template = tmpdir.join("discord.json") template.write('{"content": "hello"}') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) # Simulate an HTTP error response mock_post.return_value = mock.Mock( status_code=requests.codes.internal_server_error, content=b"", headers={}, ) assert ( obj.notify(body="test", title="t", notify_type=NotifyType.INFO) is False ) assert mock_post.called is True @mock.patch("requests.post") def test_plugin_discord_template_with_attachments(mock_post, tmpdir): """NotifyDiscord() template mode still sends attachments.""" webhook_id = "C" * 24 webhook_token = "D" * 64 # Write a valid template template = tmpdir.join("discord.json") template.write('{"content": "{{app_body}}"}') obj = Apprise.instantiate( "discord://{}/{}/".format(webhook_id, webhook_token) + "?template={}".format(str(template)) ) assert isinstance(obj, NotifyDiscord) # Prepare a good mock response for both the template call and attachment mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=b"", headers={}, ) # Attach a test file attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) assert ( obj.notify( body="test", title="t", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Both the template message and the attachment should have been posted assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_discord_attach_multi_batch(mock_post): """NotifyDiscord() multi-attachment batching.""" webhook_id = "C" * 24 webhook_token = "D" * 64 response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" response.headers = {} mock_post.return_value = response obj = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?format=markdown" ) assert isinstance(obj, NotifyDiscord) assert obj.batch is True # Three test attachments gif = os.path.join(TEST_VAR_DIR, "apprise-test.gif") png = os.path.join(TEST_VAR_DIR, "apprise-test.png") jpg = os.path.join(TEST_VAR_DIR, "apprise-test.jpeg") attach = AppriseAttachment([gif, png, jpg]) # Default batch mode: all 3 files go in one POST (plus 1 for the message) assert obj.send(body="test", attach=attach) is True assert mock_post.call_count == 2 mock_post.reset_mock() # Reduce max per batch to 1: each file gets its own POST (4 calls total) obj.discord_max_attachments = 1 assert obj.send(body="test", attach=attach) is True assert mock_post.call_count == 4 mock_post.reset_mock() obj.discord_max_attachments = 10 # Size-based split: force a tiny per-batch byte budget obj.discord_max_attach_bytes = 1 assert obj.send(body="test", attach=attach) is True assert mock_post.call_count == 4 mock_post.reset_mock() obj.discord_max_attach_bytes = 25 * 1024 * 1024 # batch=False: reverts to legacy one-per-message regardless of max obj_no_batch = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?batch=no" ) assert isinstance(obj_no_batch, NotifyDiscord) assert obj_no_batch.batch is False assert obj_no_batch.send(body="test", attach=attach) is True assert mock_post.call_count == 4 mock_post.reset_mock() # URL round-trip preserves batch flag url_on = NotifyDiscord( webhook_id=webhook_id, webhook_token=webhook_token, batch=True, ).url() assert "batch=yes" in url_on url_off = NotifyDiscord( webhook_id=webhook_id, webhook_token=webhook_token, batch=False, ).url() assert "batch=no" in url_off parsed = NotifyDiscord.parse_url(url_off) assert parsed["batch"] is False # Guard 1: inaccessible file causes failure; body POST still goes out with mock.patch("os.path.isfile", return_value=False): assert obj.send(body="test", attach=attach) is False assert mock_post.call_count == 1 mock_post.reset_mock() # Guard 2: OSError on open causes failure; body POST still goes out with mock.patch("builtins.open", side_effect=OSError()): assert obj.send(body="test", attach=attach) is False assert mock_post.call_count == 1 mock_post.reset_mock() # HTTP error on the attachment batch POST; body POST succeeds bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error bad_response.content = b"" bad_response.headers = {} mock_post.side_effect = [response, bad_response] assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.reset_mock() # RequestException on the attachment batch POST mock_post.side_effect = [response, requests.RequestException()] assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.reset_mock() # OSError raised by requests.post() during attachment send mock_post.side_effect = [response, OSError()] assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.reset_mock() # Partial batch failure: first batch (files 0+1) succeeds, second fails. # Set max=2 so 3 files split into [gif, png] and [jpg]. obj.discord_max_attachments = 2 mock_post.side_effect = [response, response, bad_response] assert obj.send(body="test", attach=attach) is False assert mock_post.call_count == 3 mock_post.side_effect = None obj.discord_max_attachments = 10 caronc-apprise-182f859/tests/test_plugin_dot.py000066400000000000000000001200121524161175200216270ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import json import logging from unittest import mock from urllib.parse import parse_qs, urlparse from helpers import AppriseURLTester from apprise.plugins.dot import NotifyDot class DummyAttachment: def __init__(self, payload="ZmFjZQ=="): self._payload = payload def base64(self): return self._payload logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "dot://", { # No API key or device ID "instance": None, }, ), ( "dot://@", { # No device ID "instance": None, }, ), ( "dot://apikey@", { # No device ID "instance": None, }, ), ( "dot://@device_id", { # No API key β€” loads but notify() returns False "instance": NotifyDot, "notify_response": False, }, ), ( "dot://apikey@device_id/", { # Default text mode "instance": NotifyDot, "privacy_url": "dot://****@device_id/?", }, ), ( "dot://apikey@device_id/?refresh=no", { # Disable refresh now "instance": NotifyDot, }, ), ( "dot://apikey@device_id/?signature=test_signature", { # With signature (text mode default) "instance": NotifyDot, }, ), ( "dot://apikey@device_id/?link=https://example.com", { # With link (text mode default) "instance": NotifyDot, }, ), ( "dot://apikey@device_id/?mode=text", { # Explicit text mode (same as default) "instance": NotifyDot, "privacy_url": "dot://****@device_id/?", }, ), ( # Backward-compat: /text/ path still parsed as text mode "dot://apikey@device_id/text/", { "instance": NotifyDot, "privacy_url": "dot://****@device_id/?", }, ), ( "dot://apikey@device_id/?mode=image&image=ZmFrZUJhc2U2NA==", { # Explicit image mode via ?mode= query param "instance": NotifyDot, "privacy_url": "dot://****@device_id/?", }, ), ( "dot://apikey@device_id/?image=ZmFrZUJhc2U2NA==", { # image= without mode= stays in default text mode (dual-send) "instance": NotifyDot, "privacy_url": "dot://****@device_id/?", }, ), ( "dot://apikey@device_id/image/?link=https://example.com" "&border=1&dither_type=ORDERED&dither_kernel=ATKINSON", { # Backward-compat /image/ path; no image payload -> fail "instance": NotifyDot, "notify_response": False, "attach_response": True, }, ), ( "dot://apikey@device_id/image/?image=ZmFrZUJhc2U2NA==" "&link=https://example.com&border=1" "&dither_type=DIFFUSION&dither_kernel=FLOYD_STEINBERG", { # Backward-compat /image/ path with image data "instance": NotifyDot, "privacy_url": "dot://****@device_id/?", }, ), ( "dot://apikey@device_id/", { "instance": NotifyDot, # Throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "dot://apikey@device_id/", { "instance": NotifyDot, # Throws a series of i/o exceptions "test_requests_exceptions": True, }, ), ( "dot://apikey@device_id/unknown/", { # Unknown path token defaults to text mode "instance": NotifyDot, "privacy_url": "dot://****@device_id/?", }, ), ) def test_plugin_dot_urls(): """NotifyDot() Apprise URLs.""" AppriseURLTester(tests=apprise_url_tests).run_all() # --------------------------------------------------------------------------- # Mode and initialization # --------------------------------------------------------------------------- def test_notify_dot_default_mode(): """Default mode is text.""" dot = NotifyDot(apikey="key", device_id="dev") assert dot.mode == "text" def test_notify_dot_invalid_mode(): """Invalid mode falls back to text.""" dot = NotifyDot(apikey="token", device_id="device", mode="invalid_mode") assert dot.mode == "text" dot = NotifyDot(apikey="token", device_id="device", mode=123) assert dot.mode == "text" def test_notify_dot_explicit_text_mode(): """Explicit text mode is honoured.""" dot = NotifyDot(apikey="key", device_id="dev", mode="text") assert dot.mode == "text" def test_notify_dot_explicit_image_mode(): """Explicit image mode is honoured.""" dot = NotifyDot(apikey="key", device_id="dev", mode="image") assert dot.mode == "image" def test_notify_dot_image_data_kept_in_text_mode(): """image_data is preserved in text mode for dual-send.""" dot = NotifyDot( apikey="token", device_id="device", mode="text", image_data="somebase64", ) assert dot.image_data == "somebase64" # --------------------------------------------------------------------------- # Text mode (default) β€” send logic # --------------------------------------------------------------------------- def test_notify_dot_auto_text_only(): """Default text mode sends text only when no image is available.""" dot = NotifyDot(apikey="token", device_id="device") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="hello", title="world") assert mock_post.call_count == 1 url = mock_post.call_args[0][0] assert "/text" in url payload = json.loads(mock_post.call_args[1]["data"]) assert payload["message"] == "hello" assert payload["title"] == "world" def test_notify_dot_auto_image_only_from_image_data(): """Auto mode sends image only when image_data set but no body/title.""" dot = NotifyDot( apikey="token", device_id="device", image_data="base64img", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="", title="") assert mock_post.call_count == 1 url = mock_post.call_args[0][0] assert "/image" in url payload = json.loads(mock_post.call_args[1]["data"]) assert payload["image"] == "base64img" def test_notify_dot_auto_dual_send_text_then_image(): """Auto mode sends text first, then image, when both are present.""" dot = NotifyDot( apikey="token", device_id="device", image_data="base64img", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="hello", title="world") assert mock_post.call_count == 2 first_url = mock_post.call_args_list[0][0][0] second_url = mock_post.call_args_list[1][0][0] assert "/text" in first_url assert "/image" in second_url def test_notify_dot_auto_dual_send_with_attachment(): """Auto mode sends text first then attachment as image.""" dot = NotifyDot(apikey="token", device_id="device") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send( body="hello", title="world", attach=[DummyAttachment("imgdata")], ) assert mock_post.call_count == 2 first_url = mock_post.call_args_list[0][0][0] second_url = mock_post.call_args_list[1][0][0] assert "/text" in first_url assert "/image" in second_url payload = json.loads(mock_post.call_args_list[1][1]["data"]) assert payload["image"] == "imgdata" def test_notify_dot_auto_image_from_attachment_no_body(): """Auto mode sends image-only when attachment given but no body.""" dot = NotifyDot(apikey="token", device_id="device") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="", title="", attach=[DummyAttachment("imgdata")]) assert mock_post.call_count == 1 assert "/image" in mock_post.call_args[0][0] def test_notify_dot_auto_nothing_to_send(): """Auto mode returns False when nothing is available to send.""" dot = NotifyDot(apikey="token", device_id="device") assert dot.send(body="", title="") is False def test_notify_dot_auto_partial_failure(): """Auto mode returns False when text succeeds but image fails.""" dot = NotifyDot( apikey="token", device_id="device", image_data="base64img", ) def side_effect(*args, **kwargs): resp = mock.Mock() url = args[0] resp.status_code = 200 if "/text" in url else 500 resp.content = b"" return resp with mock.patch("requests.post", side_effect=side_effect): assert dot.send(body="hello", title="") is False def test_notify_dot_auto_icon_passthrough(): """Auto mode passes self.icon to the text API call.""" dot = NotifyDot(apikey="token", device_id="device", icon="aW1h") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="hello", title="") payload = json.loads(mock_post.call_args[1]["data"]) assert payload["icon"] == "aW1h" # --------------------------------------------------------------------------- # Explicit image mode # --------------------------------------------------------------------------- def test_notify_dot_image_mode_requires_image(): """Explicit image mode fails without image data.""" dot = NotifyDot(apikey="token", device_id="device", mode="image") assert dot.notify(title="x", body="y") is False def test_notify_dot_image_mode_with_attachment(): """Image mode uses first attachment when no image_data provided.""" dot = NotifyDot( apikey="token", device_id="device", mode="image", link="https://example.com", border=1, dither_type="ORDERED", dither_kernel="ATKINSON", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send( body="payload", title="title", attach=[DummyAttachment("YmFzZTY0")], ) payload = json.loads(mock_post.call_args[1]["data"]) assert payload["image"] == "YmFzZTY0" assert "deviceId" not in payload def test_notify_dot_image_mode_with_existing_image_data(): """Image mode ignores attachment when image_data is already set.""" dot = NotifyDot( apikey="token", device_id="device", mode="image", image_data="existing_image_data", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send( body="test", title="test", attach=[DummyAttachment("attachment_data")], ) payload = json.loads(mock_post.call_args[1]["data"]) assert payload["image"] == "existing_image_data" def test_notify_dot_image_mode_multiple_attachments(): """Image mode uses only the first attachment.""" dot = NotifyDot(apikey="token", device_id="device", mode="image") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send( body="test", title="test", attach=[ DummyAttachment("first_attachment"), DummyAttachment("second_attachment"), ], ) payload = json.loads(mock_post.call_args[1]["data"]) assert payload["image"] == "first_attachment" def test_notify_dot_image_mode_with_failed_attachment(): """Image mode warns and fails when attachment.base64() raises.""" class FailedAttachment: def base64(self): raise Exception("Conversion failed") dot = NotifyDot(apikey="token", device_id="device", mode="image") assert ( dot.notify(title="test", body="test", attach=[FailedAttachment()]) is False ) def test_notify_dot_image_mode_attachment_none(): """Image mode fails when attachment is None.""" dot = NotifyDot(apikey="token", device_id="device", mode="image") assert dot.send(body="test", title="test", attach=[None]) is False def test_notify_dot_image_mode_attachment_falsy(): """Image mode fails when attachment is falsy.""" class FalsyAttachment: def __bool__(self): return False def base64(self): return "should_not_be_called" dot = NotifyDot(apikey="token", device_id="device", mode="image") assert ( dot.send(body="test", title="test", attach=[FalsyAttachment()]) is False ) def test_notify_dot_image_mode_no_border(): """Image mode omits border from payload when None.""" dot = NotifyDot( apikey="token", device_id="device", mode="image", image_data="base64img", ) dot.border = None resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="test", title="test") payload = json.loads(mock_post.call_args[1]["data"]) assert "border" not in payload def test_notify_dot_image_mode_no_dither(): """Image mode omits dither fields from payload when None.""" dot = NotifyDot( apikey="token", device_id="device", mode="image", image_data="base64img", ) dot.dither_type = None dot.dither_kernel = None resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="test", title="test") payload = json.loads(mock_post.call_args[1]["data"]) assert "ditherType" not in payload assert "ditherKernel" not in payload def test_notify_dot_image_mode_title_body_ignored(): """Image mode warns when title or body are provided.""" dot = NotifyDot( apikey="token", device_id="device", mode="image", image_data="base64img", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(title="ignored", body="also ignored") payload = json.loads(mock_post.call_args[1]["data"]) assert "title" not in payload assert "message" not in payload # --------------------------------------------------------------------------- # Explicit text mode # --------------------------------------------------------------------------- def test_notify_dot_text_mode_with_existing_icon(): """Text mode: icon= param with Text API; attachment goes to Image API.""" dot = NotifyDot( apikey="token", device_id="device", mode="text", signature="footer", icon="aW5jb24=", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send( title="hello", body="world", attach=[DummyAttachment("attachment_image")], ) # Two POSTs: text API first, image API second assert mock_post.call_count == 2 text_payload = json.loads(mock_post.call_args_list[0][1]["data"]) image_payload = json.loads(mock_post.call_args_list[1][1]["data"]) assert text_payload["message"] == "world" assert text_payload["icon"] == "aW5jb24=" assert "image" not in text_payload assert image_payload["image"] == "attachment_image" def test_notify_dot_text_mode_uses_attachment_as_image(): """Text mode sends attachment to Image API when no icon= is set.""" dot = NotifyDot( apikey="token", device_id="device", mode="text", signature="footer", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send( title="hello", body="world", attach=[DummyAttachment("attachment_image_data")], ) assert mock_post.call_count == 2 text_payload = json.loads(mock_post.call_args_list[0][1]["data"]) image_payload = json.loads(mock_post.call_args_list[1][1]["data"]) assert text_payload["message"] == "world" assert "icon" not in text_payload assert image_payload["image"] == "attachment_image_data" def test_notify_dot_text_mode_multiple_attachments_warning(): """Text mode warns when multiple attachments are provided.""" dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with ( mock.patch("requests.post", return_value=resp) as mock_post, mock.patch.object(dot.logger, "warning") as mock_warning, ): assert dot.send( title="hello", body="world", attach=[ DummyAttachment("first"), DummyAttachment("second"), ], ) mock_warning.assert_called_once() assert "Multiple attachments" in str(mock_warning.call_args) # First attachment sent as image to Image API (second POST) assert mock_post.call_count == 2 image_payload = json.loads(mock_post.call_args_list[1][1]["data"]) assert image_payload["image"] == "first" def test_notify_dot_text_mode_no_optional_fields(): """Text mode omits optional fields when not set.""" dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="test body", title="test title") payload = json.loads(mock_post.call_args[1]["data"]) assert "signature" not in payload assert "icon" not in payload assert payload.get("link") is None or "link" not in payload def test_notify_dot_text_mode_with_title_and_body(): """Text mode sends title and body correctly.""" dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="test_body", title="test_title") payload = json.loads(mock_post.call_args[1]["data"]) assert payload["message"] == "test_body" assert payload["title"] == "test_title" def test_notify_dot_text_mode_without_title(): """Text mode omits title from payload when empty.""" dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="test message", title="") payload = json.loads(mock_post.call_args[1]["data"]) assert "title" not in payload assert payload["message"] == "test message" def test_notify_dot_text_mode_without_body(): """Text mode omits message from payload when body is empty.""" dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="", title="test_title") payload = json.loads(mock_post.call_args[1]["data"]) assert payload["title"] == "test_title" assert "message" not in payload def test_notify_dot_text_mode_attachment_exception(): """Text mode warns on attachment error; text still sends without image.""" class ExceptionAttachment: def base64(self): raise Exception("Attachment base64 conversion fails") dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with ( mock.patch("requests.post", return_value=resp) as mock_post, mock.patch.object(dot.logger, "warning") as mock_warning, ): assert dot.send( title="hello", body="world", attach=[ExceptionAttachment()], ) assert mock_warning.called assert any( "Failed to process attachment" in str(c) for c in mock_warning.call_args_list ) # Only text API called (image_data resolved to None on exception) assert mock_post.call_count == 1 payload = json.loads(mock_post.call_args[1]["data"]) assert payload["message"] == "world" assert "icon" not in payload def test_notify_dot_text_mode_attachment_none(): """Text mode sends only text when attachment is None.""" dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(title="hello", body="world", attach=[None]) assert mock_post.call_count == 1 payload = json.loads(mock_post.call_args[1]["data"]) assert payload["message"] == "world" assert "icon" not in payload def test_notify_dot_text_mode_attachment_falsy(): """Text mode sends only text when attachment is falsy.""" class FalsyAttachment: def __bool__(self): return False def base64(self): return "should_not_be_called" dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send( title="hello", body="world", attach=[FalsyAttachment()], ) assert mock_post.call_count == 1 payload = json.loads(mock_post.call_args[1]["data"]) assert payload["message"] == "world" assert "icon" not in payload def test_notify_dot_title_handling(): """Text mode omits title from payload when empty.""" dot = NotifyDot(apikey="token", device_id="device", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="test", title="provided_title") payload = json.loads(mock_post.call_args[1]["data"]) assert payload["title"] == "provided_title" with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send(body="test", title="") payload = json.loads(mock_post.call_args[1]["data"]) assert "title" not in payload # --------------------------------------------------------------------------- # URL generation # --------------------------------------------------------------------------- def test_notify_dot_url_generation(): """URL generation for text (default) and image modes.""" # text mode (default) β€” no mode param emitted text_dot = NotifyDot( apikey="token", device_id="device", signature="sig", icon="aW5jb24=", ) text_url = text_dot.url() parsed = urlparse(text_url) assert parsed.path == "/" query = parse_qs(parsed.query) assert query["refresh"] == ["yes"] assert query["signature"] == ["sig"] assert "mode" not in query # Explicit image mode β€” mode=image in params image_dot = NotifyDot( apikey="token", device_id="device", mode="image", image_data="aW1hZ2U=", link="https://example.com", border=1, dither_type="ORDERED", dither_kernel="ATKINSON", ) image_url = image_dot.url() image_query = parse_qs(urlparse(image_url).query) assert image_query["mode"] == ["image"] assert image_query["image"] == ["aW1hZ2U="] assert image_query["border"] == ["1"] def test_notify_dot_url_generation_defaults(): """Default text mode omits mode param; image mode includes it.""" dot = NotifyDot(apikey="token", device_id="device") url = dot.url() assert "refresh=yes" in url assert "mode=" not in url dot_image = NotifyDot( apikey="token", device_id="device", mode="image", image_data="img", dither_type="ORDERED", dither_kernel="ATKINSON", ) url_image = dot_image.url() assert "mode=image" in url_image assert "dither_type=ORDERED" in url_image assert "dither_kernel=ATKINSON" in url_image def test_notify_dot_url_generation_defaults_no_dither(): """Default dither values are omitted from URL.""" dot = NotifyDot( apikey="token", device_id="device", mode="image", image_data="img", dither_type="DIFFUSION", dither_kernel="FLOYD_STEINBERG", ) url = dot.url() assert "dither_type" not in url assert "dither_kernel" not in url def test_notify_dot_url_with_border(): """URL includes border when set.""" dot = NotifyDot( apikey="token", device_id="device", mode="image", image_data="img", border=1, ) assert "border=1" in dot.url() def test_notify_dot_url_with_link(): """URL includes link when set.""" dot = NotifyDot( apikey="token", device_id="device", link="https://example.com", ) assert "link=" in dot.url() def test_notify_dot_url_with_task_key(): """URL includes task_key when set.""" dot = NotifyDot( apikey="token", device_id="device", task_key="url_task", ) assert "task_key=url_task" in dot.url() # --------------------------------------------------------------------------- # parse_url # --------------------------------------------------------------------------- def test_notify_dot_parse_url_mode_and_image(): """parse_url handles image mode via path and query param.""" # Backward-compat: /image/ path selects image mode result = NotifyDot.parse_url( "dot://token@device/image/?image=Zm9vYmFy&link=https://example.com" ) assert result["mode"] == "image" assert result["image_data"] == "Zm9vYmFy" assert result["link"] == "https://example.com" # New-style: ?mode= query param result2 = NotifyDot.parse_url( "dot://token@device/?mode=image&image=Zm9vYmFy" ) assert result2["mode"] == "image" assert result2["image_data"] == "Zm9vYmFy" # image= without mode= stays as default text mode (dual-send) result3 = NotifyDot.parse_url("dot://token@device/?image=Zm9vYmFy") assert result3["mode"] == "text" assert result3["image_data"] == "Zm9vYmFy" # Invalid ?mode= falls back to text (default) result4 = NotifyDot.parse_url("dot://token@device/?mode=invalid") assert result4["mode"] == "text" # Unknown path token falls back to text (default) fallback = NotifyDot.parse_url("dot://token@device/unknown/?refresh=no") assert fallback["mode"] == "text" assert fallback["refresh_now"] is False def test_notify_dot_parse_url_with_all_params(): """parse_url handles all parameters.""" result = NotifyDot.parse_url( "dot://apikey@device/image/?refresh=yes&signature=sig&icon=icon_b64" "&link=https://example.com&border=1&dither_type=ORDERED" "&dither_kernel=ATKINSON&image=img_b64" ) assert result["mode"] == "image" assert result["refresh_now"] is True assert result["signature"] == "sig" assert result["icon"] == "icon_b64" assert result["link"] == "https://example.com" assert result["border"] == 1 assert result["dither_type"] == "ORDERED" assert result["dither_kernel"] == "ATKINSON" assert result["image_data"] == "img_b64" def test_notify_dot_parse_url_no_host(): """parse_url succeeds when host provides device_id.""" result = NotifyDot.parse_url("dot://apikey@device/text/") assert result is not None assert result.get("device_id") == "device" def test_notify_dot_parse_url_without_host(): """parse_url returns None when host is empty.""" assert NotifyDot.parse_url("dot://apikey@/text/") is None def test_notify_dot_parse_url_with_empty_refresh(): """parse_url omits refresh_now when not specified.""" result = NotifyDot.parse_url("dot://apikey@device/text/") assert result is not None assert result.get("refresh_now") is None def test_notify_dot_parse_url_task_key(): """parse_url extracts task_key.""" result = NotifyDot.parse_url("dot://apikey@device_id/?task_key=parsed") assert result is not None assert result["task_key"] == "parsed" def test_notify_dot_parse_url_without_host_field(): """parse_url with host=None sets no device_id.""" from apprise import NotifyBase with mock.patch.object(NotifyBase, "parse_url") as mock_parse: mock_parse.return_value = { "user": "apikey", "password": None, "port": None, "host": None, "fullpath": "/text/", "path": "", "query": None, "schema": "dot", "qsd": {"refresh": "yes"}, "secure": False, "verify": True, } result = NotifyDot.parse_url("dot://fake") assert result is not None assert result.get("mode") == "text" assert result.get("device_id") is None assert result.get("apikey") == "apikey" assert result.get("refresh_now") is True # --------------------------------------------------------------------------- # url_identifier # --------------------------------------------------------------------------- def test_notify_dot_url_identifier(): """url_identifier includes mode.""" dot = NotifyDot(apikey="token", device_id="device", mode="image") assert dot.url_identifier == ("dot", "token", "device", "image") dot_text = NotifyDot(apikey="token", device_id="device") assert dot_text.url_identifier == ("dot", "token", "device", "text") # --------------------------------------------------------------------------- # URL round-trip # --------------------------------------------------------------------------- def test_notify_dot_url_roundtrip_with_task_key(): """parse_url -> NotifyDot -> url() preserves task_key and signature.""" original = "dot://apikey@device123/text/?task_key=test&signature=sig" result = NotifyDot.parse_url(original) dot = NotifyDot(**result) reconstructed = dot.url(privacy=False) assert "device123" in reconstructed # text is the default mode; mode= is not emitted assert "mode=" not in reconstructed assert "task_key=test" in reconstructed assert "signature=sig" in reconstructed # --------------------------------------------------------------------------- # Missing credentials # --------------------------------------------------------------------------- def test_notify_dot_no_device_id(): """send() returns False when device_id is missing.""" dot = NotifyDot(apikey="token", device_id=None) assert dot.notify(title="test", body="test") is False assert len(dot) == 0 # --------------------------------------------------------------------------- # API v2 endpoint construction # --------------------------------------------------------------------------- def test_notify_dot_api_v2_endpoints(): """API v2 endpoints embed device_id in URL path.""" dot = NotifyDot(apikey="test_key", device_id="DEVICE123", mode="text") assert dot.text_api_url == ( "https://dot.mindreset.tech/api/authV2/open/device/DEVICE123/text" ) dot_img = NotifyDot(apikey="test_key", device_id="DEVICE456", mode="image") assert dot_img.image_api_url == ( "https://dot.mindreset.tech/api/authV2/open/device/DEVICE456/image" ) def test_notify_dot_deviceid_not_in_payload(): """deviceId is NOT in text or image payload (API v2).""" dot = NotifyDot(apikey="test_key", device_id="12345", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send("test body", "test title") payload = json.loads(mock_post.call_args[1]["data"]) assert "deviceId" not in payload assert "/device/12345/text" in mock_post.call_args[0][0] dot_img = NotifyDot( apikey="test_key", device_id="67890", mode="image", image_data="base64data", ) with mock.patch("requests.post", return_value=resp) as mock_post: assert dot_img.send("", "") payload = json.loads(mock_post.call_args[1]["data"]) assert "deviceId" not in payload assert "/device/67890/image" in mock_post.call_args[0][0] # --------------------------------------------------------------------------- # task_key # --------------------------------------------------------------------------- def test_notify_dot_task_key_in_text_payload(): """taskKey appears in text API payload.""" dot = NotifyDot(apikey="k", device_id="d", mode="text", task_key="my_task") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send("body", "title") assert json.loads(mock_post.call_args[1]["data"])["taskKey"] == "my_task" def test_notify_dot_task_key_in_image_payload(): """taskKey appears in image API payload.""" dot = NotifyDot( apikey="k", device_id="d", mode="image", task_key="img_task", image_data="base64", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send("", "") assert json.loads(mock_post.call_args[1]["data"])["taskKey"] == "img_task" def test_notify_dot_task_key_none_not_in_payload(): """taskKey is absent from payload when None.""" dot = NotifyDot(apikey="k", device_id="d", mode="text", task_key=None) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send("test", "title") assert "taskKey" not in json.loads(mock_post.call_args[1]["data"]) # --------------------------------------------------------------------------- # refresh_now # --------------------------------------------------------------------------- def test_notify_dot_refresh_now_defaults(): """refreshNow defaults to True and appears correctly in payload.""" for refresh_now, expected in ((None, True), (True, True), (False, False)): dot = NotifyDot( apikey="k", device_id="d", mode="text", refresh_now=refresh_now, ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send("test", "title") payload = json.loads(mock_post.call_args[1]["data"]) assert payload["refreshNow"] is expected def test_notify_dot_refresh_now_in_url(): """refresh=yes appears in URL when refresh_now is None (defaults True).""" dot = NotifyDot(apikey="key", device_id="id", refresh_now=None) assert "refresh=yes" in dot.url() # --------------------------------------------------------------------------- # Authorization header # --------------------------------------------------------------------------- def test_notify_dot_authorization_header(): """Authorization header uses Bearer format.""" dot = NotifyDot(apikey="MY_SECRET_KEY", device_id="12345", mode="text") resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send("test", "title") headers = mock_post.call_args[1]["headers"] assert headers["Authorization"] == "Bearer MY_SECRET_KEY" # --------------------------------------------------------------------------- # Special-character device_id # --------------------------------------------------------------------------- def test_notify_dot_device_id_encoding(): """Device IDs with special characters are URL-encoded.""" cases = [ ("device/id", "device%2Fid"), ("device?id", "device%3Fid"), ("device#id", "device%23id"), ("device%id", "device%25id"), ("device id", "device%20id"), ("device@id", "device%40id"), ] for device_id, expected in cases: dot = NotifyDot(apikey="test_key", device_id=device_id) assert expected in dot.url(), ( f"device_id '{device_id}' should encode as '{expected}'" ) # --------------------------------------------------------------------------- # Combined parameter tests # --------------------------------------------------------------------------- def test_notify_dot_combined_text_params(): """Text mode sends all configured parameters correctly.""" dot = NotifyDot( apikey="key", device_id="device", mode="text", task_key="combined_task", refresh_now=False, signature="test_sig", link="https://example.com", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send("body", "title") payload = json.loads(mock_post.call_args[1]["data"]) assert "deviceId" not in payload assert payload["taskKey"] == "combined_task" assert payload["refreshNow"] is False assert payload["signature"] == "test_sig" assert payload["link"] == "https://example.com" assert payload["title"] == "title" assert payload["message"] == "body" def test_notify_dot_combined_image_params(): """Image mode sends all configured parameters correctly.""" dot = NotifyDot( apikey="key", device_id="img_device", mode="image", image_data="base64image", task_key="img_task", refresh_now=True, link="https://example.com", border=1, dither_type="ORDERED", dither_kernel="ATKINSON", ) resp = mock.Mock() resp.status_code = 200 with mock.patch("requests.post", return_value=resp) as mock_post: assert dot.send("", "") payload = json.loads(mock_post.call_args[1]["data"]) assert "deviceId" not in payload assert payload["image"] == "base64image" assert payload["taskKey"] == "img_task" assert payload["refreshNow"] is True assert payload["link"] == "https://example.com" assert payload["border"] == 1 assert payload["ditherType"] == "ORDERED" assert payload["ditherKernel"] == "ATKINSON" assert "/device/img_device/image" in mock_post.call_args[0][0] def test_notify_dot_debug_logging_branch(): """Exercise the isEnabledFor(DEBUG) branch inside _post.""" dot = NotifyDot(apikey="key", device_id="dev123", mode="text") resp = mock.Mock() resp.status_code = 200 with ( mock.patch.object(dot.logger, "isEnabledFor", return_value=True), mock.patch("requests.post", return_value=resp), ): assert dot.send("body", "title") caronc-apprise-182f859/tests/test_plugin_eight00com.py000066400000000000000000000420111524161175200230020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging import os from unittest import mock from urllib.parse import urlparse from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.eight00com import NotifyEight00com logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "eight00com://", { # No token -> TypeError "instance": TypeError, }, ), ( "eight00com://:@/", { # Empty token -> TypeError "instance": TypeError, }, ), ( "eight00com://GOODTOKEN@badphone", { # Non-numeric from-phone -> TypeError "instance": TypeError, }, ), ( "eight00com://GOODTOKEN@9876543210/12", { # Target too short to be a valid phone number; it is # dropped, leaving no targets -> notify_response False "instance": NotifyEight00com, "notify_response": False, }, ), ( "eight00com://{}@{}".format("t" * 10, "8" * 10), { # Valid token + source only; defaults to texting ourselves "instance": NotifyEight00com, }, ), ( "eight00com://{}@{}/{}".format("t" * 10, "8" * 10, "5" * 11), { # Valid token, source, and one target "instance": NotifyEight00com, "privacy_url": ( "eight00com://t...t@{}/{}".format("8" * 10, "5" * 11) ), }, ), ( "eight00com://{}@{}/{}/{}".format( "t" * 10, "8" * 10, "5" * 11, "6" * 11 ), { # Multiple targets "instance": NotifyEight00com, }, ), ( "eight00com://?token={}&from={}".format("t" * 10, "8" * 10), { # Token and source via query params "instance": NotifyEight00com, }, ), ( "eight00com://?token={}&from={}&to={}".format( "t" * 10, "8" * 10, "5" * 11 ), { # Token, source, and target all via query params "instance": NotifyEight00com, }, ), ( "eight00com://{}@{}".format("t" * 10, "8" * 10), { "instance": NotifyEight00com, # HTTP 500 -> failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "eight00com://{}@{}".format("t" * 10, "8" * 10), { "instance": NotifyEight00com, # Unknown HTTP code -> failure "response": False, "requests_response_code": 999, }, ), ( "eight00com://{}@{}".format("t" * 10, "8" * 10), { "instance": NotifyEight00com, # Simulate network I/O exceptions "test_requests_exceptions": True, }, ), ) def test_plugin_eight00com_urls(): """NotifyEight00com() Apprise URLs.""" AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_eight00com_init(mock_post): """NotifyEight00com() initialization and edge cases.""" # Prepare a generic success response response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response # Missing token -> TypeError with pytest.raises(TypeError): NotifyEight00com(token=None, source="8005551234") # Empty token -> TypeError with pytest.raises(TypeError): NotifyEight00com(token="", source="8005551234") # Invalid source phone -> TypeError with pytest.raises(TypeError): NotifyEight00com(token="mytoken", source="notaphone") # Valid with source only: defaults to texting ourselves obj = NotifyEight00com(token="mytoken", source="8005551234") assert obj.targets == ["8005551234"] assert len(obj) == 1 # Invalid target is dropped with a warning logged obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["12"], ) assert obj.targets == [] # __len__ returns 1 minimum (consistent with httpsms pattern) assert len(obj) == 1 # Multiple valid targets obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5551234567", "6661234567"], ) assert len(obj.targets) == 2 assert len(obj) == 2 @mock.patch("requests.post") def test_plugin_eight00com_sms(mock_post): """NotifyEight00com() SMS send path.""" response = requests.Request() response.status_code = requests.codes.ok response.content = b"{}" mock_post.return_value = response # Single target obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543"], ) assert obj.notify(body="Test", title="Title") is True assert mock_post.call_count == 1 # Verify payload payload = loads(mock_post.call_args[1]["data"]) assert payload["sender"] == "+8005551234" assert payload["recipient"] == "+5559876543" assert payload["message"] == "Title\r\nTest" # Verify Authorization header headers = mock_post.call_args[1]["headers"] assert headers["Authorization"] == "Bearer mytoken" mock_post.reset_mock() # Two targets -> two POST calls obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543", "4445551234"], ) assert obj.notify(body="Hello") is True assert mock_post.call_count == 2 mock_post.reset_mock() # HTTP 500 -> failure response.status_code = requests.codes.internal_server_error obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543"], ) assert obj.notify(body="Test") is False mock_post.reset_mock() # Unknown status code -> failure response.status_code = 999 assert obj.notify(body="Test") is False mock_post.reset_mock() # RequestException -> failure mock_post.side_effect = requests.RequestException("Connection error") response.status_code = requests.codes.ok assert obj.notify(body="Test") is False mock_post.side_effect = None # No targets after bad-number drops -> failure without HTTP call obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["12"], ) mock_post.reset_mock() assert obj.notify(body="Test") is False assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_eight00com_url_round_trip(mock_post): """NotifyEight00com() URL round-trip and privacy_url.""" response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response # Round-trip with one target obj1 = NotifyEight00com( token="secrettoken", source="8005551234", targets=["5559876543"], ) result = NotifyEight00com.parse_url(obj1.url()) obj2 = NotifyEight00com(**result) assert obj1.url_identifier == obj2.url_identifier assert len(obj1.targets) == len(obj2.targets) # Privacy URL masks the token privacy = obj1.url(privacy=True) assert "secrettoken" not in privacy assert "s...n" in privacy # Round-trip with no explicit target (self-send omits path) obj3 = NotifyEight00com(token="mytoken", source="8005551234") result = NotifyEight00com.parse_url(obj3.url()) obj4 = NotifyEight00com(**result) assert obj3.url_identifier == obj4.url_identifier # Self-send: targets are preserved assert obj3.targets == obj4.targets # url_identifier is schema / source / token -- not targets assert obj1.url_identifier == ( "eight00com", "8005551234", "secrettoken", ) @mock.patch("requests.post") def test_plugin_eight00com_parse_url(mock_post): """NotifyEight00com() parse_url edge cases.""" response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response # Standard form: token@source/target result = NotifyEight00com.parse_url( "eight00com://mytoken@8005551234/5559876543" ) assert result["token"] == "mytoken" assert result["source"] == "8005551234" assert "5559876543" in result["targets"] # ?token= override result = NotifyEight00com.parse_url( "eight00com://8005551234?token=newtoken&from=8009876543&to=5551234567" ) assert result["token"] == "newtoken" assert result["source"] == "8009876543" # ?from= causes host to become a target instead of source result = NotifyEight00com.parse_url( "eight00com://mytoken@9998887776?from=8005551234" ) assert result["token"] == "mytoken" assert result["source"] == "8005551234" # The host (9998887776) becomes a target assert "9998887776" in " ".join(result["targets"]) # ?to= adds targets result = NotifyEight00com.parse_url( "eight00com://mytoken@8005551234?to=5559876543" ) assert "5559876543" in " ".join(result["targets"]) # Unparseable URL -> None assert NotifyEight00com.parse_url(None) is None @mock.patch("requests.post") def test_plugin_eight00com_attach_success(mock_post): """NotifyEight00com() attachment send -- success path.""" response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert ( obj.notify( body="Test MMS", title="", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Exactly one POST for one target assert mock_post.call_count == 1 # Confirm the MMS fields were sent as multipart (files kwarg set) call_kwargs = mock_post.call_args[1] assert call_kwargs["files"] is not None assert call_kwargs["data"]["sender"] == "+8005551234" assert call_kwargs["data"]["recipient"] == "+5559876543" assert call_kwargs["data"]["message"] == "Test MMS" # Confirm authorization header is present assert call_kwargs["headers"]["Authorization"] == "Bearer mytoken" # Verify the URL host is correct call_url = mock_post.call_args[0][0] assert urlparse(call_url).hostname == "api.800.com" @mock.patch("requests.post") def test_plugin_eight00com_attach_multi(mock_post): """NotifyEight00com() MMS with multiple attachments.""" response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543"], ) attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert ( obj.notify( body="Multi-attachment MMS", attach=attach, ) is True ) assert mock_post.call_count == 1 # Two media[] tuples in the files list files = mock_post.call_args[1]["files"] assert len(files) == 2 assert all(f[0] == "media[]" for f in files) @mock.patch("requests.post") def test_plugin_eight00com_attach_inaccessible(mock_post): """NotifyEight00com() attachment -- inaccessible file guard.""" response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) # Simulate the file being inaccessible (os.path.isfile -> False) with mock.patch("os.path.isfile", return_value=False): assert obj.notify(body="Test", attach=attach) is False # No HTTP call should have been made assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_eight00com_attach_oserror(mock_post): """NotifyEight00com() attachment -- OSError on open guard.""" response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) with mock.patch("builtins.open", side_effect=OSError("IO fail")): assert obj.notify(body="Test", attach=attach) is False # No HTTP call should have been made assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_eight00com_attach_http_error(mock_post): """NotifyEight00com() attachment -- server HTTP error response.""" response = requests.Request() response.status_code = requests.codes.internal_server_error response.content = b"{}" mock_post.return_value = response obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Test", attach=attach) is False assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_eight00com_attach_request_exception(mock_post): """NotifyEight00com() attachment -- RequestException guard.""" mock_post.side_effect = requests.RequestException("Connection error") obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Test", attach=attach) is False @mock.patch("requests.post") def test_plugin_eight00com_attach_partial_failure(mock_post): """NotifyEight00com() MMS partial target failure.""" def _side_effect(*args, **kwargs): # First call succeeds, second fails if mock_post.call_count == 1: resp = requests.Request() resp.status_code = requests.codes.ok return resp resp = requests.Request() resp.status_code = requests.codes.internal_server_error resp.content = b"{}" return resp mock_post.side_effect = _side_effect obj = NotifyEight00com( token="mytoken", source="8005551234", targets=["5559876543", "4441234567"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) # One success and one failure -> overall False assert obj.notify(body="Test", attach=attach) is False assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_eight00com_apprise_integration(mock_post): """NotifyEight00com() integration with Apprise.""" response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response # Load via URL a = Apprise() assert a.add("eight00com://mytoken@8005551234/5559876543") is True assert a.notify(body="Integration test") is True assert mock_post.call_count == 1 mock_post.reset_mock() # Load via URL with ?to= and ?from= a2 = Apprise() assert ( a2.add("eight00com://?token=mytoken&from=8005551234&to=5559876543") is True ) assert a2.notify(body="Integration test 2") is True assert mock_post.call_count == 1 caronc-apprise-182f859/tests/test_plugin_email.py000066400000000000000000004241471524161175200221500ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime from email.header import decode_header from inspect import cleandoc import logging import os import re import shutil import smtplib import ssl import sys from unittest import mock import pytest from apprise import ( Apprise, AppriseAsset, AppriseAttachment, AttachBase, NotifyBase, NotifyType, PersistentStoreMode, utils, ) from apprise.attachment.memory import AttachMemory from apprise.config import ConfigBase from apprise.exception import AppriseException from apprise.plugins import email try: import pgpy except ImportError: pgpy = None # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") TEST_URLS = ( ################################## # NotifyEmail ################################## ( "mailto://", { "instance": TypeError, }, ), ( "mailtos://", { "instance": TypeError, }, ), ( "mailto://:@/", { "instance": TypeError, }, ), # No Username ( "mailtos://:pass@nuxref.com:567", { # Can't prepare a To address using this expression "instance": TypeError, }, ), ( # invalid Timezone "mailto://user:pass@fastmail.com?tz=invalid", { # An error is thrown for this "instance": TypeError, }, ), # Pre-Configured Email Services ( "mailto://user:pass@gmail.com", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@hotmail.com", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@live.com", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@prontomail.com", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@yahoo.com", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@yahoo.ca", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@fastmail.com?tz=UTC", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@sendgrid.com", { "instance": email.NotifyEmail, }, ), # GMX Support ( "mailto://user:pass@gmx.net", { "instance": email.NotifyEmail, "privacy_url": "mailtos://user:****@gmx.net", }, ), ( "mailto://user:pass@gmx.com", { "instance": email.NotifyEmail, "privacy_url": "mailtos://user:****@gmx.com", }, ), ( "mailto://user:pass@gmx.de", { "instance": email.NotifyEmail, "privacy_url": "mailtos://user:****@gmx.de", }, ), ( # STARTTLS flag checking (and privacy URL expectation) "mailtos://user:pass@gmx.net?mode=starttls", { "instance": email.NotifyEmail, "privacy_url": "mailtos://user:****@gmx.net", }, ), # Yandex ( "mailto://user:pass@yandex.com", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@yandex.ru", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@yandex.fr", { "instance": email.NotifyEmail, }, ), # Custom Emails ( "mailtos://user:pass@nuxref.com:567", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@nuxref.com?mode=ssl", { # mailto:// with mode=ssl causes us to convert to ssl "instance": email.NotifyEmail, # Our expected url(privacy=True) startswith() response: "privacy_url": "mailtos://user:****@nuxref.com", }, ), ( "mailto://user:pass@nuxref.com:567?format=html", { "instance": email.NotifyEmail, }, ), ( "mailtos://user:pass@nuxref.com:567?to=l2g@nuxref.com", { "instance": email.NotifyEmail, }, ), ( "mailtos://user:pass@domain.com?user=admin@mail-domain.com", { "instance": email.NotifyEmail, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com", { "instance": email.NotifyEmail, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com&pgp=yes", { # Test deprecated pgp=yes flag (backward compat); notify() fails # because no key is provided and PGP requires one to be useful. "instance": email.NotifyEmail, "response": False, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com&pgp=encrypt", { # Test canonical pgp=encrypt mode string; notify() fails because # no public key is available to encrypt with. "instance": email.NotifyEmail, "response": False, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com&pgp=e", { # Test pgp= prefix shorthand ('e' -> 'encrypt'); notify() fails # because no public key is available to encrypt with. "instance": email.NotifyEmail, "response": False, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com&pgp=sign", { # Test pgp=sign mode string; notify() fails because no private key # is provided and signing requires one. "instance": email.NotifyEmail, "response": False, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com&pgp=s", { # Test pgp= prefix shorthand ('s' -> 'sign'); notify() fails # because no private key is provided. "instance": email.NotifyEmail, "response": False, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com&wkd=yes", { # wkd=yes implies pgp=encrypt; notify() fails because no public # key can be discovered (no WKD server behind this test address). "instance": email.NotifyEmail, "response": False, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com" "&pgp=encrypt&pgpkey=%2Fpath%2Fto%2Fpub.asc", { # Test deprecated pgpkey= alias; exercises the deprecation # warning branch in parse_url() and maps to pgp_key. # notify() returns False because the key path does not exist. "instance": email.NotifyEmail, "response": False, }, ), ( "mailtos://%20@domain.com?user=admin@mail-domain.com" "&pgp=encrypt&pgppub=%2Fpath%2Fto%2Fpub.asc" "&pgpkey=%2Fold%2Fpath.asc", { # Test pgppub= wins over deprecated pgpkey= when both present. # notify() returns False because the key path does not exist. "instance": email.NotifyEmail, "response": False, }, ), ( "mailtos://user:pass@nuxref.com:567/l2g@nuxref.com", { "instance": email.NotifyEmail, }, ), ( ( "mailto://user:pass@example.com:2525?user=l2g@example.com" "&pass=l2g@apprise!is!Awesome" ), { "instance": email.NotifyEmail, }, ), ( ( "mailto://user:pass@example.com:2525?user=l2g@example.com" "&pass=l2g@apprise!is!Awesome&format=text" ), { "instance": email.NotifyEmail, }, ), ( # Test Carbon Copy ( "mailtos://user:pass@example.com?smtp=smtp.example.com" "&name=l2g&cc=noreply@example.com,test@example.com" ), { "instance": email.NotifyEmail, }, ), ( # Test Blind Carbon Copy ( "mailtos://user:pass@example.com?smtp=smtp.example.com" "&name=l2g&bcc=noreply@example.com,test@example.com" ), { "instance": email.NotifyEmail, }, ), ( # Test Carbon Copy with bad email ( "mailtos://user:pass@example.com?smtp=smtp.example.com" "&name=l2g&cc=noreply@example.com,@" ), { "instance": email.NotifyEmail, }, ), ( # Test Blind Carbon Copy with bad email ( "mailtos://user:pass@example.com?smtp=smtp.example.com" "&name=l2g&bcc=noreply@example.com,@" ), { "instance": email.NotifyEmail, }, ), ( # Test Reply To ( "mailtos://user:pass@example.com?smtp=smtp.example.com" "&name=l2g&reply=test@example.com,test2@example.com" ), { "instance": email.NotifyEmail, }, ), ( # Test Reply To with bad email ( "mailtos://user:pass@example.com?smtp=smtp.example.com" "&name=l2g&reply=test@example.com,@" ), { "instance": email.NotifyEmail, }, ), # headers ( ( "mailto://user:pass@localhost.localdomain" "?+X-Customer-Campaign-ID=Apprise" ), { "instance": email.NotifyEmail, }, ), # No Password ( "mailtos://user:@nuxref.com", { "instance": email.NotifyEmail, }, ), # Invalid From Address; but just gets put as the from name instead # Hence the below generats From: "@ " ( "mailtos://user:pass@nuxref.com?from=@", { "instance": email.NotifyEmail, }, ), # Invalid From Address ( "mailtos://nuxref.com?user=&pass=.", { "instance": TypeError, }, ), # Invalid To Address is accepted, but we won't be able to properly email # using the notify() call ( "mailtos://user:pass@nuxref.com?to=@", { "instance": email.NotifyEmail, "response": False, }, ), # Valid URL, but can't structure a proper email ( 'mailtos://nuxref.com?user=%20"&pass=.', { "instance": TypeError, }, ), # Invalid From (and To) Address ( "mailtos://nuxref.com?to=test", { "instance": TypeError, }, ), # Invalid Secure Mode ( "mailtos://user:pass@example.com?mode=notamode", { "instance": TypeError, }, ), # STARTTLS flag checking ( "mailtos://user:pass@gmail.com?mode=starttls", { "instance": email.NotifyEmail, # Our expected url(privacy=True) startswith() response: "privacy_url": "mailtos://user:****@gmail.com", }, ), # SSL flag checking ( "mailtos://user:pass@gmail.com?mode=ssl", { "instance": email.NotifyEmail, }, ), # Can make a To address using what we have (l2g@nuxref.com) ( "mailtos://nuxref.com?user=l2g&pass=.", { "instance": email.NotifyEmail, # Our expected url(privacy=True) startswith() response: "privacy_url": "mailtos://l2g:****@nuxref.com", }, ), ( "mailto://user:pass@localhost:2525", { "instance": email.NotifyEmail, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_smtplib_exceptions": True, }, ), # Use of both 'name' and 'from' together; these are synonymous ( ( "mailtos://user:pass@nuxref.com?" "from=jack@gmail.com&name=Jason" ), {"instance": email.NotifyEmail}, ), # Test no auth at all ( "mailto://localhost?from=test@example.com&to=test@example.com", { "instance": email.NotifyEmail, "privacy_url": "mailto://localhost", }, ), # Test multi-emails where some are bad ( "mailto://user:pass@localhost/test@example.com/test2@/$@!/", { "instance": email.NotifyEmail, "privacy_url": "mailto://user:****@localhost/", }, ), ( "mailto://user:pass@localhost/?bcc=test2@,$@!/", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@localhost/?cc=test2@,$@!/", { "instance": email.NotifyEmail, }, ), ( "mailto://user:pass@localhost/?reply=test2@,$@!/", { "instance": email.NotifyEmail, }, ), ) @mock.patch("smtplib.SMTP") @mock.patch("smtplib.SMTP_SSL") def test_plugin_email(mock_smtp, mock_smtpssl): """NotifyEmail() General Checks.""" # iterate over our dictionary and test it out for url, meta in TEST_URLS: # Our expected instance instance = meta.get("instance", None) # Our expected server objects self = meta.get("self", None) # Our expected Query response (True, False, or exception type) response = meta.get("response", True) # Our expected privacy url # Don't set this if don't need to check it's value privacy_url = meta.get("privacy_url") test_smtplib_exceptions = meta.get("test_smtplib_exceptions", False) # Our mock of our socket action mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True # Create a mock SMTP Object mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket if test_smtplib_exceptions: # Handle exception testing; first we turn the boolean flag ito # a list of exceptions test_smtplib_exceptions = ( smtplib.SMTPHeloError( 0, "smtplib.SMTPHeloError() not handled" ), smtplib.SMTPException( 0, "smtplib.SMTPException() not handled" ), RuntimeError(0, "smtplib.HTTPError() not handled"), smtplib.SMTPRecipientsRefused( "smtplib.SMTPRecipientsRefused() not handled" ), smtplib.SMTPSenderRefused( 0, "smtplib.SMTPSenderRefused() not handled", "addr@example.com", ), smtplib.SMTPDataError( 0, "smtplib.SMTPDataError() not handled" ), smtplib.SMTPServerDisconnected( "smtplib.SMTPServerDisconnected() not handled" ), ) try: obj = Apprise.instantiate(url, suppress_exceptions=False) if obj is None: # We're done (assuming this is what we were expecting) assert instance is None continue if instance is None: # Expected None but didn't get it raise AssertionError() assert isinstance(obj, instance) if isinstance(obj, NotifyBase): # We loaded okay; now lets make sure we can reverse this url assert isinstance(obj.url(), str) # Get our URL Identifier assert isinstance(obj.url_id(), str) # Verify we can acquire a target count as an integer assert isinstance(len(obj), int) # Test url() with privacy=True assert isinstance(obj.url(privacy=True), str) # Some Simple Invalid Instance Testing assert instance.parse_url(None) is None assert instance.parse_url(object) is None assert instance.parse_url(42) is None if privacy_url and not obj.url(privacy=True).startswith( # Assess that our privacy url is as expected privacy_url ): raise AssertionError( f"URL: {url} Privacy URL:" f" '{obj.url(privacy=True)[: len(privacy_url)]}' !=" f" expected '{privacy_url}'" ) # Instantiate the exact same object again using the URL from # the one that was already created properly obj_cmp = Apprise.instantiate(obj.url()) # Our object should be the same instance as what we had # originally expected above. if not isinstance(obj_cmp, NotifyBase): # Assert messages are hard to trace back with the way # these tests work. Just printing before throwing our # assertion failure makes things easier to debug later on raise AssertionError() # Verify there is no change from the old and the new assert len(obj) == len(obj_cmp), ( f"{len(obj)} targets found in " f"{obj.url(privacy=True)}, " f"But {len(obj_cmp)} targets found in " f"{obj_cmp.url(privacy=True)}" ) if self: # Iterate over our expected entries inside of our object for key, val in self.items(): # Test that our object has the desired key assert hasattr(key, obj) assert getattr(key, obj) == val try: if test_smtplib_exceptions is False: # Verify we can acquire a target count as an integer targets = len(obj) # check that we're as expected assert ( obj.notify( title="test", body="body", notify_type=NotifyType.INFO, ) == response ) if response: # If we successfully got a response, there must have # been at least 1 target present assert targets > 0 else: for exception in test_smtplib_exceptions: mock_socket.sendmail.side_effect = exception try: assert ( obj.notify( title="test", body="body", notify_type=NotifyType.INFO, ) is False ) except AssertionError: # Don't mess with these entries raise except Exception: # We can't handle this exception type raise except AssertionError: # Don't mess with these entries raise except Exception as e: # Check that we were expecting this exception to happen if not isinstance(e, response): raise except AssertionError: # Don't mess with these entries raise except Exception as e: # Handle our exception if instance is None: raise if not isinstance(e, instance): raise @mock.patch("smtplib.SMTP") @mock.patch("smtplib.SMTP_SSL") def test_plugin_email_webbase_lookup(mock_smtp, mock_smtpssl): """NotifyEmail() Web Based Lookup Tests.""" # Insert a test email at the head of our table email.templates.EMAIL_TEMPLATES = ( *( ( "Testing Lookup", re.compile(r"^(?P[^@]+)@(?Pl2g\.com)$", re.I), { "port": 123, "smtp_host": "smtp.l2g.com", "secure": True, "login_type": (email.WebBaseLogin.USERID,), }, ), ), *email.templates.EMAIL_TEMPLATES, ) obj = Apprise.instantiate( "mailto://user:pass@l2g.com", suppress_exceptions=True, ) assert isinstance(obj, email.NotifyEmail) assert len(obj.targets) == 1 assert (False, "user@l2g.com") in obj.targets assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "user@l2g.com" assert obj.password == "pass" assert obj.user == "user" assert obj.secure is True assert obj.port == 123 assert obj.smtp_host == "smtp.l2g.com" # We get the same results if an email is identified as the username # because the USERID variable forces that we can't use an email obj = Apprise.instantiate( "mailto://_:pass@l2g.com?user=user@test.com", suppress_exceptions=True ) assert obj.user == "user" @mock.patch("smtplib.SMTP") def test_plugin_email_smtplib_init_fail(mock_smtplib): """NotifyEmail() Test exception handling when calling smtplib.SMTP()""" obj = Apprise.instantiate( "mailto://user:pass@gmail.com", suppress_exceptions=False ) assert isinstance(obj, email.NotifyEmail) # Support Exception handling of smtplib.SMTP mock_smtplib.side_effect = RuntimeError("Test") assert ( obj.notify(body="body", title="test", notify_type=NotifyType.INFO) is False ) # A handled and expected exception mock_smtplib.side_effect = smtplib.SMTPException("Test") assert ( obj.notify(body="body", title="test", notify_type=NotifyType.INFO) is False ) @mock.patch("smtplib.SMTP") def test_plugin_email_smtplib_send_okay(mock_smtplib): """NotifyEmail() Test a successfully sent email.""" # Defaults to HTML obj = Apprise.instantiate( "mailto://user:pass@gmail.com", suppress_exceptions=False ) assert isinstance(obj, email.NotifyEmail) # Support an email simulation where we can correctly quit mock_smtplib.starttls.return_value = True mock_smtplib.login.return_value = True mock_smtplib.sendmail.return_value = True mock_smtplib.quit.return_value = True assert ( obj.notify(body="body", title="test", notify_type=NotifyType.INFO) is True ) # Set Text obj = Apprise.instantiate( "mailto://user:pass@gmail.com?format=text", suppress_exceptions=False ) assert isinstance(obj, email.NotifyEmail) assert ( obj.notify(body="body", title="test", notify_type=NotifyType.INFO) is True ) # Create an apprise object to work with as well a = Apprise() assert a.add("mailto://user:pass@gmail.com?format=text") # Send Attachment with success attach = os.path.join(TEST_VAR_DIR, "apprise-test.gif") assert ( obj.notify( body="body", title="test", notify_type=NotifyType.INFO, attach=attach, ) is True ) # same results happen from our Apprise object assert a.notify(body="body", title="test", attach=attach) is True # test using an Apprise Attachment object assert ( obj.notify( body="body", title="test", notify_type=NotifyType.INFO, attach=AppriseAttachment(attach), ) is True ) # same results happen from our Apprise object assert ( a.notify(body="body", title="test", attach=AppriseAttachment(attach)) is True ) # test using an Apprise in memory attachement object attachment = AppriseAttachment() attachment.add( AttachMemory( content="attachment", name="attached.txt", mimetype="text/plain", ) ) assert ( obj.notify( body="body", title="test", notify_type=NotifyType.INFO, attach=attachment, ) is True ) # same results happen from our Apprise object assert a.notify(body="body", title="test", attach=attachment) is True max_file_size = AttachBase.max_file_size # Now do a case where the file can't be sent AttachBase.max_file_size = 1 assert ( obj.notify( body="body", title="test", notify_type=NotifyType.INFO, attach=attach, ) is False ) # same results happen from our Apprise object assert a.notify(body="body", title="test", attach=attach) is False # Restore value AttachBase.max_file_size = max_file_size @mock.patch("smtplib.SMTP") def test_plugin_email_smtplib_send_multiple_recipients(mock_smtplib): """Verify that NotifyEmail() will use a single SMTP session for submitting multiple emails.""" # Defaults to HTML obj = Apprise.instantiate( "mailto://user:pass@mail.example.org?" "to=foo@example.net,bar@example.com&" "cc=baz@example.org&bcc=qux@example.org", suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) assert ( obj.notify(body="body", title="test", notify_type=NotifyType.INFO) is True ) assert mock_smtplib.mock_calls == [ mock.call("mail.example.org", 25, None, timeout=15), mock.call().login("user", "pass"), mock.call().sendmail( "user@mail.example.org", ["foo@example.net", "baz@example.org", "qux@example.org"], mock.ANY, ), mock.call().sendmail( "user@mail.example.org", ["bar@example.com", "baz@example.org", "qux@example.org"], mock.ANY, ), mock.call().quit(), ] # No from= used in the above assert re.match(r".*from=.*", obj.url()) is None # No mode= as this isn't a secure connection assert re.match(r".*mode=.*", obj.url()) is None # No smtp= as the SMTP server is the same as the hostname in this case assert re.match(r".*smtp=.*", obj.url()) is None # URL is assembled based on provided user assert ( re.match(r"^mailto://user:pass\@mail.example.org/.*", obj.url()) is not None ) # Verify our added emails are still part of the URL assert re.match(r".*/foo%40example.net[/?].*", obj.url()) is not None assert re.match(r".*/bar%40example.com[/?].*", obj.url()) is not None assert re.match(r".*bcc=qux%40example.org.*", obj.url()) is not None assert re.match(r".*cc=baz%40example.org.*", obj.url()) is not None @mock.patch("smtplib.SMTP") def test_plugin_email_timezone(mock_smtp): """NotifyEmail() Timezone Handling""" response = mock.Mock() mock_smtp.return_value = response # Loads America/Toronto results = email.NotifyEmail.parse_url( "mailtos://user:pass123@hotmail.com:123?tz=Toronto" ) assert isinstance(results, dict) # timezone is detected assert "tz" in results # Instantiate the object obj = email.NotifyEmail(**results) assert isinstance(obj, email.NotifyEmail) assert obj.tzinfo.key == "America/Toronto" # Verify our URL has defined our timezone # %2F = escaped '/' assert "tz=America%2FToronto" in obj.url() # No Timezone setup/default results = email.NotifyEmail.parse_url( "mailtos://user:pass123@hotmail.com:1235" ) assert "tz" not in results # Instantiate the object obj = email.NotifyEmail(**results) assert isinstance(obj, email.NotifyEmail) # Defaults to our system assert obj.tzinfo == datetime.now().astimezone().tzinfo assert "tz=" not in obj.url() # Now we'll work with an Asset to identify how it can hold # our default global variable (initialization proves case # insensitive initialization is supported) asset = AppriseAsset(timezone="aMErica/vanCOUver") # Instatiate our object once again using the same variable set # as above obj = email.NotifyEmail(**results, asset=asset) # Defaults to our system # lower() is required since Mac and Window are not case sensitive and will # See output as it was passed in and not corrected per IANA assert obj.tzinfo.key.lower() == "america/vancouver" assert "tz=" not in obj.url() # Having ourselves a default variable also does not prevent # anyone from defining their own over-ride is still supported: # Loads America/Montreal results = email.NotifyEmail.parse_url( "mailtos://user:pass123@hotmail.com:321?tz=Montreal" ) assert isinstance(results, dict) # timezone is detected assert "tz" in results # Instantiate the object obj = email.NotifyEmail(**results) assert isinstance(obj, email.NotifyEmail) assert obj.tzinfo.key == "America/Montreal" # Verify our URL has defined our timezone # %2F = escaped '/' assert "tz=America%2FMontreal" in obj.url() @mock.patch("smtplib.SMTP") def test_plugin_email_smtplib_internationalization(mock_smtp): """NotifyEmail() Internationalization Handling.""" # i18n test email_url = "".join( [ "mailto://user:pass@gmail.com?", "name=НапримСр%20Ρ‚Π°ΠΊ", # noqa: RUF001 ] ) obj = Apprise.instantiate( email_url, suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) class SMTPMock: def sendmail(self, *args, **kwargs): """Over-ride sendmail calls so we can check our our internationalization formatting went.""" match_subject = re.search( r"\n?(?PSubject: (?P(.+?)))\n(?:[a-z0-9-]+:)", args[2], re.I | re.M | re.S, ) assert match_subject is not None match_from = re.search( r"^(?PFrom: (?P.+) <(?P[^>]+)>)$", args[2], re.I | re.M, ) assert match_from is not None # Verify our output was correctly stored assert match_from.group("email") == "user@gmail.com" assert ( decode_header(match_from.group("name"))[0][0].decode("utf-8") == "НапримСр Ρ‚Π°ΠΊ" ) assert ( decode_header(match_subject.group("subject"))[0][0].decode( "utf-8" ) == "Ψ―ΨΉΩˆΩ†Ψ§ Ω†Ψ¬ΨΉΩ„ Ψ§Ω„ΨΉΨ§Ω„Ω… Ω…ΩƒΨ§Ω†Ψ§ أفآل." ) # Dummy Function def quit(self, *args, **kwargs): return True # Dummy Function def starttls(self, *args, **kwargs): return True # Dummy Function def login(self, *args, **kwargs): return True # Prepare our object we will test our generated email against mock_smtp.return_value = SMTPMock() # Further test encoding through the message content as well assert ( obj.notify( # Google Translated to Arabic: # "Let's make the world a better place." title="Ψ―ΨΉΩˆΩ†Ψ§ Ω†Ψ¬ΨΉΩ„ Ψ§Ω„ΨΉΨ§Ω„Ω… Ω…ΩƒΨ§Ω†Ψ§ أفآل.", # Google Translated to Hungarian: "One line of code at a time.' body="Egy sor kΓ³dot egyszerre.", notify_type=NotifyType.INFO, ) is True ) def test_plugin_email_url_escaping(): """NotifyEmail() Test that user/passwords are properly escaped from URL.""" # quote(' %20') passwd = "%20%2520" # Basically we want to check that ' ' equates to %20 and % equates to %25 # So the above translates to ' %20' (a space in front of %20). We want # to verify the handling of the password escaping and when it happens. # a very bad response would be ' ' (double space) obj = email.NotifyEmail.parse_url( f"mailto://user:{passwd}@gmail.com?format=text" ) assert isinstance(obj, dict) assert "password" in obj # Escaping doesn't happen at this stage because we want to leave this to # the plugins discretion assert obj.get("password") == "%20%2520" obj = Apprise.instantiate( f"mailto://user:{passwd}@gmail.com?format=text", suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) # The password is escaped only 'once' assert obj.password == " %20" def test_plugin_email_url_variations(): """NotifyEmail() Test URL variations to ensure parsing is correct.""" # Test variations of username required to be an email address # user@example.com obj = Apprise.instantiate( "mailto://{user}:{passwd}@example.com?smtp=example.com".format( user="apprise%40example21.ca", passwd="abcd123" ), suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) assert obj.password == "abcd123" assert obj.user == "apprise@example21.ca" # No from= used in the above assert re.match(r".*from=.*", obj.url()) is None # No mode= as this isn't a secure connection assert re.match(r".*mode=.*", obj.url()) is None # No smtp= as the SMTP server is the same as the hostname in this case # even though it was explicitly specified assert re.match(r".*smtp=.*", obj.url()) is None # URL is assembled based on provided user assert ( re.match(r"^mailto://apprise:abcd123\@example.com/.*", obj.url()) is not None ) # test username specified in the url body (as an argument) # this always over-rides the entry at the front of the url obj = Apprise.instantiate( "mailto://_:{passwd}@example.com?user={user}".format( user="apprise%40example21.ca", passwd="abcd123" ), suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) assert obj.password == "abcd123" assert obj.user == "apprise@example21.ca" # No from= used in the above assert re.match(r".*from=.*", obj.url()) is None # No mode= as this isn't a secure connection assert re.match(r".*mode=.*", obj.url()) is None # No smtp= as the SMTP server is the same as the hostname in this case assert re.match(r".*smtp=.*", obj.url()) is None # URL is assembled based on provided user assert ( re.match(r"^mailto://apprise:abcd123\@example.com/.*", obj.url()) is not None ) # test user and password specified in the url body (as an argument) # this always over-rides the entries at the front of the url obj = Apprise.instantiate( "mailtos://_:_@example.com?user={user}&pass={passwd}".format( user="apprise%40example21.ca", passwd="abcd123" ), suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) assert obj.password == "abcd123" assert obj.user == "apprise@example21.ca" assert len(obj.targets) == 1 assert (False, "apprise@example.com") in obj.targets assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "apprise@example.com" assert obj.targets[0][0] is False assert obj.targets[0][1] == obj.from_addr[1] # No from= used in the above assert re.match(r".*from=.*", obj.url()) is None # Default mode is starttls assert re.match(r".*mode=starttls.*", obj.url()) is not None # No smtp= as the SMTP server is the same as the hostname in this case assert re.match(r".*smtp=.*", obj.url()) is None # URL is assembled based on provided user assert ( re.match(r"^mailtos://apprise:abcd123\@example.com/.*", obj.url()) is not None ) # test user and password specified in the url body (as an argument) # this always over-rides the entries at the front of the url # this is similar to the previous test except we're only specifying # this information in the kwargs obj = Apprise.instantiate( "mailto://example.com?user={user}&pass={passwd}".format( user="apprise%40example21.ca", passwd="abcd123" ), suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) assert obj.password == "abcd123" assert obj.user == "apprise@example21.ca" assert len(obj.targets) == 1 assert (False, "apprise@example.com") in obj.targets assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "apprise@example.com" assert obj.targets[0][0] is False assert obj.targets[0][1] == obj.from_addr[1] assert obj.smtp_host == "example.com" # No from= used in the above assert re.match(r".*from=.*", obj.url()) is None # No mode= as this isn't a secure connection assert re.match(r".*mode=.*", obj.url()) is None # No smtp= as the SMTP server is the same as the hostname in this case assert re.match(r".*smtp=.*", obj.url()) is None # URL is assembled based on provided user assert ( re.match(r"^mailto://apprise:abcd123\@example.com/.*", obj.url()) is not None ) # test a complicated example obj = Apprise.instantiate( "mailtos://{user}:{passwd}@{host}:{port}" "?smtp={smtp_host}&format=text&from=Charles<{this}>&to={that}".format( user="apprise%40example21.ca", passwd="abcd123", host="example.com", port=1234, this="from@example.jp", that="to@example.jp", smtp_host="smtp.example.edu", ), suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) assert obj.password == "abcd123" assert obj.user == "apprise@example21.ca" assert obj.host == "example.com" assert obj.port == 1234 assert obj.smtp_host == "smtp.example.edu" assert len(obj.targets) == 1 assert (False, "to@example.jp") in obj.targets assert obj.from_addr[0] == "Charles" assert obj.from_addr[1] == "from@example.jp" assert ( re.match(r".*from=Charles%20%3Cfrom%40example.jp%3E.*", obj.url()) is not None ) # Test Tagging under various urll encodings for toaddr in ( "/john.smith+mytag@domain.com", "?to=john.smith+mytag@domain.com", "/john.smith%2Bmytag@domain.com", "?to=john.smith%2Bmytag@domain.com", ): obj = Apprise.instantiate(f"mailto://user:pass@domain.com{toaddr}") assert isinstance(obj, email.NotifyEmail) assert obj.password == "pass" assert obj.user == "user" assert obj.host == "domain.com" assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "user@domain.com" assert len(obj.targets) == 1 assert obj.targets[0][0] is False assert obj.targets[0][1] == "john.smith+mytag@domain.com" def test_plugin_email_dict_variations(): """NotifyEmail() Test email dictionary variations to ensure parsing is correct.""" # Test variations of username required to be an email address # user@example.com obj = Apprise.instantiate( { "schema": "mailto", "user": "apprise@example.com", "password": "abd123", "host": "example.com", }, suppress_exceptions=False, ) assert isinstance(obj, email.NotifyEmail) @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_url_parsing(mock_smtp, mock_smtp_ssl): """NotifyEmail() Test email url parsing.""" response = mock.Mock() mock_smtp_ssl.return_value = response mock_smtp.return_value = response # Test variations of username required to be an email address # user@example.com; we also test an over-ride port on a template driven # mailto:// entry results = email.NotifyEmail.parse_url( "mailtos://user:pass123@hotmail.com:444" "?to=user2@yahoo.com&name=test%20name" ) assert isinstance(results, dict) assert results["from_addr"] == "test name" assert results["user"] == "user" assert results["port"] == 444 assert results["host"] == "hotmail.com" assert results["password"] == "pass123" assert "user2@yahoo.com" in results["targets"] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "user@hotmail.com" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "user2@yahoo.com" assert msg.split("\n")[-3] == "test" # Our URL port was over-ridden (on template) to use 444 # We can verify that this was correctly saved assert obj.url().startswith( "mailtos://user:pass123@hotmail.com:444/user2%40yahoo.com" ) assert "mode=starttls" in obj.url() assert "smtp=smtp-mail.outlook.com" in obj.url() mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # The below switches the `name` with the `to` to verify the results # are the same; it also verfies that the mode gets changed to SSL # instead of STARTTLS results = email.NotifyEmail.parse_url( "mailtos://user:pass123@hotmail.com?smtp=override.com" "&name=test%20name&to=user2@yahoo.com&mode=ssl" ) assert isinstance(results, dict) assert results["from_addr"] == "test name" assert results["user"] == "user" assert results["host"] == "hotmail.com" assert results["password"] == "pass123" assert "user2@yahoo.com" in results["targets"] assert results["secure_mode"] == "ssl" obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 1 assert response.starttls.call_count == 0 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "user@hotmail.com" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "user2@yahoo.com" assert msg.split("\n")[-3] == "test" user, pw = response.login.call_args[0] # the SMTP Server was ovr assert pw == "pass123" assert user == "user" assert obj.url().startswith( "mailtos://user:pass123@hotmail.com/user2%40yahoo.com" ) # Test that our template over-ride worked assert "mode=ssl" in obj.url() assert "smtp=override.com" in obj.url() # No reply address specified assert "reply=" not in obj.url() mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # # Test outlook/hotmail lookups # results = email.NotifyEmail.parse_url("mailtos://user:pass123@hotmail.com") obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert obj.smtp_host == "smtp-mail.outlook.com" # No entries in the reply_to assert not obj.reply_to assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "pass123" assert user == "user@hotmail.com" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url("mailtos://user:pass123@outlook.com") obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert obj.smtp_host == "smtp.outlook.com" # No entries in the reply_to assert not obj.reply_to assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "pass123" assert user == "user@outlook.com" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url( "mailtos://user:pass123@outlook.com.au" ) obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert obj.smtp_host == "smtp.outlook.com" # No entries in the reply_to assert not obj.reply_to assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "pass123" assert user == "user@outlook.com.au" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # Consisitency Checks results = email.NotifyEmail.parse_url( "mailtos://outlook.com?smtp=smtp.outlook.com" "&user=user@outlook.com&pass=app.pw" ) obj1 = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj1, email.NotifyEmail) assert obj1.smtp_host == "smtp.outlook.com" assert obj1.user == "user@outlook.com" assert obj1.password == "app.pw" assert obj1.secure_mode == "starttls" assert obj1.port == 587 assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj1.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "app.pw" assert user == "user@outlook.com" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url("mailtos://user:app.pw@outlook.com") obj2 = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj2, email.NotifyEmail) assert obj2.smtp_host == obj1.smtp_host assert obj2.user == obj1.user assert obj2.password == obj1.password assert obj2.secure_mode == obj1.secure_mode assert obj2.port == obj1.port assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj2.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "app.pw" assert user == "user@outlook.com" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url("mailto://user:pass@comcast.net") obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert obj.smtp_host == "smtp.comcast.net" assert obj.user == "user@comcast.net" assert obj.password == "pass" assert obj.secure_mode == "ssl" assert obj.port == 465 assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 1 assert response.starttls.call_count == 0 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "pass" assert user == "user@comcast.net" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url("mailtos://user:pass123@live.com") obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # No entries in the reply_to assert not obj.reply_to assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "pass123" assert user == "user@live.com" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url("mailtos://user:pass123@hotmail.com") obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # No entries in the reply_to assert not obj.reply_to assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "pass123" assert user == "user@hotmail.com" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # # Test Port Over-Riding # results = email.NotifyEmail.parse_url( "mailtos://abc:password@xyz.cn:465?smtp=smtp.exmail.qq.com&mode=ssl" ) obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # Verify our over-rides are in place assert obj.smtp_host == "smtp.exmail.qq.com" assert obj.port == 465 assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "abc@xyz.cn" assert obj.secure_mode == "ssl" # No entries in the reply_to assert not obj.reply_to # No from= used in the above assert re.match(r".*from=.*", obj.url()) is None # No Our secure connection is SSL assert re.match(r".*mode=ssl.*", obj.url()) is not None # No smtp= as the SMTP server is the same as the hostname in this case assert re.match(r".*smtp=smtp.exmail.qq.com.*", obj.url()) is not None # URL is assembled based on provided user (:465 is dropped because it # is a default port when using xyz.cn) assert ( re.match(r"^mailtos://abc:password@xyz.cn/.*", obj.url()) is not None ) assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 1 assert response.starttls.call_count == 0 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "password" assert user == "abc" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url( "mailtos://abc:password@xyz.cn?" "smtp=smtp.exmail.qq.com&mode=ssl&port=465" ) obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # Verify our over-rides are in place assert obj.smtp_host == "smtp.exmail.qq.com" assert obj.port == 465 assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "abc@xyz.cn" assert obj.secure_mode == "ssl" # No entries in the reply_to assert not obj.reply_to assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 1 assert response.starttls.call_count == 0 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "password" assert user == "abc" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # # Test Reply-To Email # results = email.NotifyEmail.parse_url( "mailtos://user:pass@example.com?reply=noreply@example.com" ) obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # Verify our over-rides are in place assert obj.smtp_host == "example.com" assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "user@example.com" assert obj.secure_mode == "starttls" assert obj.url().startswith("mailtos://user:pass@example.com") # Test that our template over-ride worked assert "reply=noreply%40example.com" in obj.url() assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "pass" assert user == "user" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # # Test Reply-To Email with Name Inline # results = email.NotifyEmail.parse_url( "mailtos://user:pass@example.com?reply=Chris" ) obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # Verify our over-rides are in place assert obj.smtp_host == "example.com" assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "user@example.com" assert obj.secure_mode == "starttls" assert obj.url().startswith("mailtos://user:pass@example.com") # Test that our template over-ride worked assert "reply=Chris%20%3Cnoreply%40example.ca%3E" in obj.url() assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 user, pw = response.login.call_args[0] assert pw == "pass" assert user == "user" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # Fast Mail Handling # Test variations of username required to be an email address # user@example.com; we also test an over-ride port on a template driven # mailto:// entry results = email.NotifyEmail.parse_url( "mailto://fastmail.com/?to=hello@concordium-explorer.nl" "&user=joe@mydomain.nl&pass=abc123" "&from=Concordium Explorer Bot" ) assert isinstance(results, dict) assert ( results["from_addr"] == "Concordium Explorer Bot" ) assert results["user"] == "joe@mydomain.nl" assert results["port"] is None assert results["host"] == "fastmail.com" assert results["password"] == "abc123" assert "hello@concordium-explorer.nl" in results["targets"] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 1 assert response.starttls.call_count == 0 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "bot@concordium-explorer.nl" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "hello@concordium-explorer.nl" assert msg.split("\n")[-3] == "test" user, pw = response.login.call_args[0] assert pw == "abc123" assert user == "joe@mydomain.nl" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # Issue github.com/caronc/apprise/issue/1040 # mailto://fastmail.com?user=username@customdomain.com \ # &to=username@customdomain.com&pass=password123 # # should just have to be written like (to= omitted) # mailto://fastmail.com?user=username@customdomain.com&pass=password123 # results = email.NotifyEmail.parse_url( "mailto://fastmail.com?user=username@customdomain.com&pass=password123" ) assert isinstance(results, dict) assert results["user"] == "username@customdomain.com" assert results["from_addr"] == "" assert results["port"] is None assert results["host"] == "fastmail.com" assert results["password"] == "password123" assert results["smtp_host"] == "" obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # During instantiation, our variables get detected assert obj.smtp_host == "smtp.fastmail.com" assert obj.from_addr == ["Apprise", "username@customdomain.com"] assert obj.host == "customdomain.com" # detected from assert (False, "username@customdomain.com") in obj.targets assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 1 assert response.starttls.call_count == 0 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "username@customdomain.com" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "username@customdomain.com" assert msg.split("\n")[-3] == "test" user, pw = response.login.call_args[0] assert pw == "password123" assert user == "username@customdomain.com" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # Similar test as above, just showing that we can over-ride the From= # with these custom URLs as well and not require a full email results = email.NotifyEmail.parse_url( "mailto://fastmail.com?user=username@customdomain.com" "&pass=password123&from=Custom" ) assert isinstance(results, dict) assert results["user"] == "username@customdomain.com" assert results["from_addr"] == "Custom" assert results["port"] is None assert results["host"] == "fastmail.com" assert results["password"] == "password123" assert results["smtp_host"] == "" obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # During instantiation, our variables get detected assert obj.smtp_host == "smtp.fastmail.com" assert obj.from_addr == ["Custom", "username@customdomain.com"] assert obj.host == "customdomain.com" # detected from assert (False, "username@customdomain.com") in obj.targets assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 1 assert response.starttls.call_count == 0 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "username@customdomain.com" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "username@customdomain.com" assert msg.split("\n")[-3] == "test" user, pw = response.login.call_args[0] assert pw == "password123" assert user == "username@customdomain.com" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # Issue github.com/caronc/apprise/issue/941 # mail domain = mail-domain.com # host domain = domain.subdomain.com # PASSWORD needs to be fetched since a user= was provided # - this is an edge case that is tested here results = email.NotifyEmail.parse_url( "mailtos://PASSWORD@domain.subdomain.com:587?" "user=admin@mail-domain.com&to=mail@mail-domain.com" ) assert isinstance(results, dict) # From_Addr could not be detected at this stage, but will be # handled during instantiation assert results["from_addr"] == "" assert results["user"] == "admin@mail-domain.com" assert results["port"] == 587 assert results["host"] == "domain.subdomain.com" assert results["password"] == "PASSWORD" assert "mail@mail-domain.com" in results["targets"] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) # Not that our from_address takes on 'admin@domain.subdomain.com' assert obj.from_addr == ["Apprise", "admin@domain.subdomain.com"] assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert response.starttls.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "admin@domain.subdomain.com" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "mail@mail-domain.com" assert msg.split("\n")[-3] == "test" user, pw = response.login.call_args[0] assert user == "admin@mail-domain.com" assert pw == "PASSWORD" @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_plus_in_toemail(mock_smtp, mock_smtp_ssl): """NotifyEmail() support + in To Email address.""" response = mock.Mock() mock_smtp_ssl.return_value = response mock_smtp.return_value = response # We want to test the case where a + is found in the To address; we want to # ensure that it is supported results = email.NotifyEmail.parse_url( "mailtos://user:pass123@gmail.com" "?to=Plus Support" ) assert isinstance(results, dict) assert results["user"] == "user" assert results["host"] == "gmail.com" assert results["password"] == "pass123" assert results["port"] is None assert "Plus Support" in results["targets"] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert len(obj.targets) == 1 assert ("Plus Support", "test+notification@gmail.com") in obj.targets assert obj.smtp_host == "smtp.gmail.com" assert obj.from_addr == ["Apprise", "user@gmail.com"] assert obj.host == "gmail.com" assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "user@gmail.com" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "test+notification@gmail.com" assert msg.split("\n")[-3] == "test" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # # Perform the same test where the To field jsut contains the + in the # address # results = email.NotifyEmail.parse_url( "mailtos://user:pass123@gmail.com?to=test+notification@gmail.com" ) assert isinstance(results, dict) assert results["user"] == "user" assert results["host"] == "gmail.com" assert results["password"] == "pass123" assert results["port"] is None assert "test+notification@gmail.com" in results["targets"] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert len(obj.targets) == 1 assert (False, "test+notification@gmail.com") in obj.targets assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "user@gmail.com" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "test+notification@gmail.com" assert msg.split("\n")[-3] == "test" mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() # # Perform the same test where the To field is in the URL itself # results = email.NotifyEmail.parse_url( "mailtos://user:pass123@gmail.com/test+notification@gmail.com" ) assert isinstance(results, dict) assert results["user"] == "user" assert results["host"] == "gmail.com" assert results["password"] == "pass123" assert results["port"] is None assert "test+notification@gmail.com" in results["targets"] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert len(obj.targets) == 1 assert (False, "test+notification@gmail.com") in obj.targets assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("test") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "user@gmail.com" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "test+notification@gmail.com" assert msg.split("\n")[-3] == "test" @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_formatting_990(mock_smtp, mock_smtp_ssl): """ NotifyEmail() GitHub Issue 990 https://github.com/caronc/apprise/issues/990 Email formatting not working correctly """ response = mock.Mock() mock_smtp_ssl.return_value = response mock_smtp.return_value = response results = email.NotifyEmail.parse_url( "mailtos://mydomain.com?smtp=mail.local.mydomain.com" "&user=noreply@mydomain.com&pass=mypassword" "&from=noreply@mydomain.com&to=me@mydomain.com&mode=ssl&port=465" ) assert isinstance(results, dict) assert results["user"] == "noreply@mydomain.com" assert results["host"] == "mydomain.com" assert results["smtp_host"] == "mail.local.mydomain.com" assert results["password"] == "mypassword" assert results["secure_mode"] == "ssl" assert results["port"] == "465" assert "me@mydomain.com" in results["targets"] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert len(obj.targets) == 1 assert (False, "me@mydomain.com") in obj.targets def test_plugin_email_variables_1087(): """ NotifyEmail() GitHub Issue 1087 https://github.com/caronc/apprise/issues/1087 Email variables reported not working correctly """ # Valid Configuration result, _ = ConfigBase.config_parse( cleandoc(""" # # Test Email Parsing # urls: - mailtos://alt.lan/: - user: testuser@alt.lan pass: xxxxXXXxxx smtp: smtp.alt.lan to: alteriks@alt.lan """), asset=AppriseAsset(), ) assert isinstance(result, list) assert len(result) == 1 email_ = result[0] assert email_.from_addr == ["Apprise", "testuser@alt.lan"] assert email_.user == "testuser@alt.lan" assert email_.smtp_host == "smtp.alt.lan" assert email_.targets == [(False, "alteriks@alt.lan")] assert email_.password == "xxxxXXXxxx" # Valid Configuration result, _ = ConfigBase.config_parse( cleandoc(""" # # Test Email Parsing where YAML tokens over-ride qsd # urls: - mailtos://alt.lan/?pass=abcd&user=joe@alt.lan: - user: testuser@alt.lan pass: xxxxXXXxxx smtp: smtp.alt.lan to: alteriks@alt.lan """), asset=AppriseAsset(), ) assert isinstance(result, list) assert len(result) == 1 # YAML child tokens are higher priority than URL query-string # parameters -- testuser and xxxxXXXxxx win over joe and abcd. email_ = result[0] assert email_.from_addr == ["Apprise", "testuser@alt.lan"] assert email_.user == "testuser@alt.lan" assert email_.smtp_host == "smtp.alt.lan" assert email_.targets == [(False, "alteriks@alt.lan")] assert email_.password == "xxxxXXXxxx" @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_to_handling_1356(mock_smtp, mock_smtp_ssl): """ NotifyEmail() GitHub Issue 1356 https://github.com/caronc/apprise/issues/1356 Email not correctly preparing the `to:` """ response = mock.Mock() mock_smtp_ssl.return_value = response mock_smtp.return_value = response results = email.NotifyEmail.parse_url( "mailtos://smtp-relay.gmail.com?" "from=user@custom-domain.casa&to=alerts@anothercustomdomain.net" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["host"] == "smtp-relay.gmail.com" assert results["port"] is None assert results["from_addr"] == "user@custom-domain.casa" assert results["smtp_host"] == "" assert "alerts@anothercustomdomain.net" in results["targets"] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert len(obj.targets) == 1 assert (False, "alerts@anothercustomdomain.net") in obj.targets assert obj.smtp_host == "smtp-relay.gmail.com" assert obj.from_addr == (False, "user@custom-domain.casa") assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("body", "title") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 # No login occurs as no user/pass was provided assert response.login.call_count == 0 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "user@custom-domain.casa" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "alerts@anothercustomdomain.net" assert msg.split("\n")[-3] == "body" @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_variables_1334(mock_smtp, mock_smtp_ssl): """ NotifyEmail() GitHub Issue 1334 https://github.com/caronc/apprise/issues/1334 Localhost & Local Domain default user """ response = mock.Mock() mock_smtp_ssl.return_value = response mock_smtp.return_value = response results = email.NotifyEmail.parse_url("mailto://localhost") assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["host"] == "localhost" assert results["port"] is None assert results["from_addr"] == "" assert results["smtp_host"] == "" assert results["targets"] == [] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert len(obj.targets) == 1 assert (False, "root@localhost") in obj.targets assert obj.smtp_host == "localhost" assert obj.secure is False assert obj.from_addr == [False, "root@localhost"] assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("body", "title") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 0 # No login occurs as no user/pass was provided assert response.login.call_count == 0 assert response.sendmail.call_count == 1 # # Again, but a different variation of the localhost domain # mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url( "mailtos://user@localhost.localdomain" ) assert isinstance(results, dict) assert results["user"] == "user" assert results["password"] is None assert results["host"] == "localhost.localdomain" assert results["port"] is None assert results["from_addr"] == "" assert results["smtp_host"] == "" assert results["targets"] == [] obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) assert len(obj.targets) == 1 assert (False, "user@localhost.localdomain") in obj.targets assert obj.smtp_host == "localhost.localdomain" assert obj.secure is True assert obj.from_addr == ["Apprise", "user@localhost.localdomain"] assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("body", "title") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 # No login occurs as no user/pass was provided assert response.login.call_count == 0 assert response.sendmail.call_count == 1 @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_host_detection_from_source_email(mock_smtp, mock_smtp_ssl): """NotifyEmail() Discord Issue reporting that the following did not work: mailtos://?smtp=mobile.charter.net&pass=password&user=name@spectrum.net """ response = mock.Mock() mock_smtp_ssl.return_value = response mock_smtp.return_value = response results = email.NotifyEmail.parse_url( "mailtos://spectrum.net?smtp=mobile.charter.net" "&pass=password&user=name@spectrum.net" ) assert isinstance(results, dict) assert results["user"] == "name@spectrum.net" assert results["host"] == "spectrum.net" assert results["smtp_host"] == "mobile.charter.net" assert results["password"] == "password" obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) is True assert len(obj.targets) == 1 assert (False, "name@spectrum.net") in obj.targets assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "name@spectrum.net" assert obj.password == "password" assert obj.user == "name@spectrum.net" assert obj.secure is True assert obj.port == 587 assert obj.smtp_host == "mobile.charter.net" assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("body", "title") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "name@spectrum.net" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "name@spectrum.net" assert msg.split("\n")[-3] == "body" # # Now let's do a shortened version of the same URL where the host isn't # specified but is parseable from he user login # mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url( "mailtos://?smtp=mobile.charter.net" "&pass=password&user=name@spectrum.net" ) assert isinstance(results, dict) assert results["user"] == "name@spectrum.net" assert results["host"] == "" # No hostname defined; it's detected later assert results["smtp_host"] == "mobile.charter.net" assert results["password"] == "password" obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) is True assert len(obj.targets) == 1 assert (False, "name@spectrum.net") in obj.targets assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "name@spectrum.net" assert obj.password == "password" assert obj.user == "name@spectrum.net" assert obj.secure is True assert obj.port == 587 assert obj.smtp_host == "mobile.charter.net" assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("body", "title") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "name@spectrum.net" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "name@spectrum.net" assert msg.split("\n")[-3] == "body" # # Now let's do a shortened version of the same URL where the host isn't # specified but is parseable from he user login # mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url( "mailtos://?smtp=mobile.charter.net" "&pass=password&user=userid-without-domain" ) assert isinstance(results, dict) assert results["user"] == "userid-without-domain" assert results["host"] == "" # No hostname defined assert results["smtp_host"] == "mobile.charter.net" assert results["password"] == "password" with pytest.raises(TypeError): # We will fail Apprise.instantiate(results, suppress_exceptions=False) # # Now support target emails in place of the hostname # mock_smtp.reset_mock() mock_smtp_ssl.reset_mock() response.reset_mock() results = email.NotifyEmail.parse_url( "mailtos://John Doe?smtp=mobile.charter.net" "&pass=password&user=name@spectrum.net" ) assert isinstance(results, dict) assert results["user"] == "name@spectrum.net" assert results["host"] == "" # No hostname defined; it's detected later assert results["smtp_host"] == "mobile.charter.net" assert results["password"] == "password" obj = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(obj, email.NotifyEmail) is True assert len(obj.targets) == 1 assert ("John Doe", "john@yahoo.ca") in obj.targets assert obj.from_addr[0] == obj.app_id assert obj.from_addr[1] == "name@spectrum.net" assert obj.password == "password" assert obj.user == "name@spectrum.net" assert obj.secure is True assert obj.port == 587 assert obj.smtp_host == "mobile.charter.net" assert mock_smtp.call_count == 0 assert mock_smtp_ssl.call_count == 0 assert obj.notify("body", "title") is True assert mock_smtp.call_count == 1 assert mock_smtp_ssl.call_count == 0 assert response.starttls.call_count == 1 assert response.login.call_count == 1 assert response.sendmail.call_count == 1 # Store our Sent Arguments # Syntax is: # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) # [0] [1] [2] from_ = response.sendmail.call_args[0][0] to = response.sendmail.call_args[0][1] msg = response.sendmail.call_args[0][2] assert from_ == "name@spectrum.net" assert isinstance(to, list) assert len(to) == 1 assert to[0] == "john@yahoo.ca" assert msg.split("\n")[-3] == "body" @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_by_ipaddr_1113(mock_smtp, mock_smtp_ssl): """ NotifyEmail() GitHub Issue 1113 https://github.com/caronc/apprise/issues/1113 Email with ip addresses not working """ response = mock.Mock() mock_smtp_ssl.return_value = response mock_smtp.return_value = response results = email.NotifyEmail.parse_url( "mailto://10.0.0.195:25/?to=alerts@example.com&from=sender@example.com" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["host"] == "10.0.0.195" assert results["from_addr"] == "sender@example.com" assert isinstance(results["targets"], list) assert len(results["targets"]) == 1 assert results["targets"][0] == "alerts@example.com" assert results["port"] == 25 email_ = Apprise.instantiate(results, suppress_exceptions=False) assert isinstance(email_, email.NotifyEmail) is True assert len(email_.targets) == 1 assert (False, "alerts@example.com") in email_.targets assert email_.from_addr == (False, "sender@example.com") assert email_.user is None assert email_.password is None assert email_.smtp_host == "10.0.0.195" assert email_.port == 25 assert email_.targets == [(False, "alerts@example.com")] @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_pgp(mock_smtp, mock_smtpssl, tmpdir): """NotifyEmail() PGP Tests.""" # Our mock of our socket action mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True # Create a mock SMTP Object mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket assert utils.pgp.PGP_SUPPORT is True utils.pgp.PGP_SUPPORT = False # Forces to run through section of code that produces a warning there is # no PGP obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=encrypt") # No PGP Support and set enabled assert obj.notify("test body") is False # Return the PGP status for remaining checks utils.pgp.PGP_SUPPORT = True # Initialize our email (no from name) obj = Apprise.instantiate("mailto://user2:pass@nuxref.com?pgp=encrypt") # Nothing to lookup assert obj.pgp.public_keyfile() is None assert obj.pgp.public_key() is None assert obj.pgp.encrypt("message") is False # Keys can not be generated in memory mode assert obj.pgp.keygen() is False # The reason... no location to store data assert obj.store.mode == PersistentStoreMode.MEMORY tmpdir0 = tmpdir.mkdir("tmp00") asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), ) # Prepare PGP obj = Apprise.instantiate( "mailto://pgp:pass@nuxref.com?pgp=yes", asset=asset ) assert obj.store.mode == PersistentStoreMode.FLUSH # Still no public key assert obj.pgp.public_key(autogen=False) is None assert obj.pgp.keygen() is True # Now we'll have a public key assert isinstance(obj.pgp.public_keyfile(), str) # Generate warning by second call assert obj.pgp.keygen() is True # Remove newly generated files os.unlink(os.path.join(obj.store.path, "pgp-pub.asc")) os.unlink(os.path.join(obj.store.path, "pgp-prv.asc")) obj = Apprise.instantiate( "mailto://pgp:pass@nuxref.com?pgp=yes", asset=asset ) assert obj.store.mode == PersistentStoreMode.FLUSH assert obj.pgp.keygen() is True # Prepare PGP while providing it a key obj = Apprise.instantiate( "mailto://pgp:pass@nuxref.com?pgp=yes&" f"pgppub={obj.pgp.public_keyfile()}", asset=asset, ) # keyfile Defined assert obj.pgp.pub_keyfile is not None # Get our key key = obj.pgp.public_key() # In this circumstance we can not generate a new key as the one provided # is immutable assert obj.pgp.keygen() is False # Our key is the same assert key is obj.pgp.public_key() tmpdir0 = tmpdir.mkdir("tmp00a") asset0 = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), ) # Prepare Invalid PGP Key obj = Apprise.instantiate( "mailto://pgpX:pass@nuxref.com?pgp=yes", asset=asset0 ) # No keyfiles assert obj.pgp.pub_keyfile is None # Generate our keys assert obj.pgp.keygen() is True # Second call uses cache assert obj.pgp.keygen() is True # We will find our key key = obj.pgp.public_key() assert key is not None # Utilize force parameter assert obj.pgp.keygen(force=True) is True # Our key is new assert key != obj.pgp.public_key() assert obj.pgp.public_key() is not None # Prepare Invalid PGP Key obj = Apprise.instantiate( "mailto://pgp:pass@nuxref.com?pgp=yes&pgppub=invalid", asset=asset ) # Returns false assert obj.pgp.pub_keyfile is False assert obj.pgp.public_keyfile() is False tmpdir2 = tmpdir.mkdir("tmp02") asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir2), ) obj = Apprise.instantiate( "mailto://chris:pass@nuxref.com?pgp=yes", asset=asset ) assert obj.store.mode == PersistentStoreMode.FLUSH assert obj.pgp.keygen() is True # Second call uses cache assert obj.pgp.keygen() is True # We will find our key assert obj.pgp.public_key() is not None # We do this again but even when we do a requisition for a public key # it will generate a new pair or keys for us once it detects we don't # have any tmpdir3 = tmpdir.mkdir("tmp03") asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir3), ) obj = Apprise.instantiate( "mailto://chris:pass@nuxref.com/user@example.com?pgp=yes", asset=asset ) assert obj.store.mode == PersistentStoreMode.FLUSH # We'll have a public key object to encrypt with assert obj.pgp.public_key() is not None encrypted = obj.pgp.encrypt("hello world") assert encrypted.startswith("-----BEGIN PGP MESSAGE-----") assert encrypted.rstrip().endswith("-----END PGP MESSAGE-----") dir_content = os.listdir(obj.store.path) assert "chris-pub.asc" in dir_content assert "chris-prv.asc" in dir_content assert obj.pgp.public_keyfile().endswith("chris-pub.asc") assert obj.notify("test body") is True # The private key is not needed for sending the encrypted messages os.unlink(os.path.join(obj.store.path, "chris-prv.asc")) os.rename( os.path.join(obj.store.path, "chris-pub.asc"), os.path.join(obj.store.path, "user@example.com-pub.asc"), ) assert obj.pgp.public_keyfile() is None assert obj.pgp.public_keyfile("not-reference@example.com") is None assert obj.pgp.public_keyfile("user@example.com").endswith( "user@example.com-pub.asc" ) assert obj.pgp.public_keyfile("user@example.com").endswith( "user@example.com-pub.asc" ) assert obj.pgp.public_keyfile("User@Example.com").endswith( "user@example.com-pub.asc" ) assert obj.pgp.public_keyfile("unknown") is None shutil.copyfile( os.path.join(obj.store.path, "user@example.com-pub.asc"), os.path.join(obj.store.path, "user-pub.asc"), ) assert obj.pgp.public_keyfile("user@example.com").endswith( "user@example.com-pub.asc" ) assert obj.pgp.public_keyfile("User@Example.com").endswith( "user@example.com-pub.asc" ) # Remove file os.unlink(os.path.join(obj.store.path, "user@example.com-pub.asc")) assert obj.pgp.public_keyfile("user@example.com").endswith("user-pub.asc") shutil.copyfile( os.path.join(obj.store.path, "user-pub.asc"), os.path.join(obj.store.path, "chris-pub.asc"), ) # user-pub.asc still trumps still trumps assert obj.pgp.public_keyfile("user@example.com").endswith("user-pub.asc") shutil.copyfile( os.path.join(obj.store.path, "chris-pub.asc"), os.path.join(obj.store.path, "chris@nuxref.com-pub.asc"), ) # user-pub still trumps assert obj.pgp.public_keyfile("user@example.com").endswith("user-pub.asc") assert obj.pgp.public_keyfile("invalid@example.com").endswith( "chris@nuxref.com-pub.asc" ) # remove this file os.unlink(os.path.join(obj.store.path, "user-pub.asc")) # now we fall back to basic/default configuration assert obj.pgp.public_keyfile("user@example.com").endswith( "chris@nuxref.com-pub.asc" ) os.unlink(os.path.join(obj.store.path, "chris@nuxref.com-pub.asc")) assert obj.pgp.public_keyfile("user@example.com").endswith("chris-pub.asc") # Testing again tmpdir4 = tmpdir.mkdir("tmp04") asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir4), ) obj = Apprise.instantiate( "mailto://chris:pass@nuxref.com/user@example.com?pgp=yes", asset=asset ) with mock.patch("builtins.open", side_effect=FileNotFoundError): # can't open key assert obj.pgp.public_key() is None with mock.patch("builtins.open", side_effect=OSError): # can't open key assert obj.pgp.public_key() is None # Test unlink with mock.patch("os.unlink", side_effect=OSError): assert obj.pgp.public_key() is None # Key Generation will fail assert obj.pgp.keygen() is False with mock.patch("pgpy.PGPKey.new", side_effect=NameError): # Can't Generate keys assert obj.pgp.keygen() is False # can't open key assert obj.pgp.public_key() is None with mock.patch("pgpy.PGPKey.from_blob", side_effect=FileNotFoundError): # can't open key assert obj.pgp.public_key() is None with mock.patch("pgpy.PGPKey.from_blob", side_effect=OSError): # can't open key assert obj.pgp.public_key() is None # Can't encrypt key with mock.patch("pgpy.PGPKey.from_blob", side_effect=NameError): assert obj.pgp.public_key() is None with mock.patch("pgpy.PGPMessage.new", side_effect=NameError): assert obj.pgp.encrypt("message") is None # Attempts to encrypt a message assert obj.notify("test-encrypt") is False # Create new keys assert obj.pgp.keygen() is True with ( mock.patch("os.path.isfile", return_value=False), mock.patch("builtins.open", side_effect=OSError), mock.patch("os.unlink", return_value=None), ): assert obj.pgp.keygen() is False # Testing again tmpdir5 = tmpdir.mkdir("tmp05") asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir5), ) obj = Apprise.instantiate( "mailto://chris:pass@nuxref.com/user@example.com?pgp=yes", asset=asset ) # Catch edge case where we just can't generate the the key with ( mock.patch( "os.path.isfile", side_effect=( # 5x False to skip through pgp.public_keyfile() False, False, False, False, False, False, # 1x True to pass pgp.keygen() True, # 5x False to skip through pgp.public_keyfile() second call False, False, False, False, False, False, ), ), mock.patch("pgpy.PGPKey.from_blob", side_effect=FileNotFoundError), ): assert obj.pgp.public_key() is None # Corrupt Data tmpdir6 = tmpdir.mkdir("tmp06") asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir6), ) obj = Apprise.instantiate( "mailto://chris:pass@nuxref.com/user@example.com?pgp=yes", asset=asset ) shutil.copyfile( os.path.join(TEST_VAR_DIR, "pgp", "corrupt-pub.asc"), os.path.join(obj.store.path, "chris-pub.asc"), ) # Key is corrupted assert obj.notify("test") is False shutil.copyfile( os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(obj.store.path, "chris-pub.asc"), ) # Key is a binary image; definitely not a valid key assert obj.notify("test") is False @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_pgp_mode_param(mock_smtp, mock_smtpssl): """NotifyEmail() pgp_mode parameter and backward-compat use_pgp.""" mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket # pgp= absent -> pgp_mode defaults to 'no' obj = Apprise.instantiate("mailto://user:pass@nuxref.com") assert obj is not None assert obj.pgp_mode == "no" assert obj.use_wkd is False # pgp=encrypt -> pgp_mode = 'encrypt' obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=encrypt") assert obj.pgp_mode == "encrypt" # pgp=e (prefix shorthand) -> pgp_mode = 'encrypt' obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=e") assert obj.pgp_mode == "encrypt" # pgp=none -> no mode match; falls to default 'no' (backward compat) obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=none") assert obj.pgp_mode == "no" # pgp=n -> prefix matches 'no' obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=n") assert obj.pgp_mode == "no" # pgp=no -> direct mode match; canonical URL form obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=no") assert obj.pgp_mode == "no" # pgp=false -> legacy bool path; no deprecation warning -> default 'no' obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=false") assert obj.pgp_mode == "no" # pgp=yes -> parse_bool -> True -> 'encrypt' (deprecated path) obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=yes") assert obj.pgp_mode == "encrypt" # pgp=true -> same deprecated path obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=true") assert obj.pgp_mode == "encrypt" # url() emits 'pgp=encrypt' when mode is encrypt, and nothing when no obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=encrypt") assert "pgp=encrypt" in obj.url() assert "pgp=no" not in obj.url() obj = Apprise.instantiate("mailto://user:pass@nuxref.com") assert "pgp=" not in obj.url() # use_pgp=True deprecated kwarg maps to pgp_mode='encrypt' obj = email.NotifyEmail( user="user", password="pass", host="nuxref.com", use_pgp=True, ) assert obj.pgp_mode == "encrypt" # use_pgp=False deprecated kwarg maps to pgp_mode='no' obj = email.NotifyEmail( user="user", password="pass", host="nuxref.com", use_pgp=False, ) assert obj.pgp_mode == "no" # pgp_mode wins over use_pgp when both are supplied obj = email.NotifyEmail( user="user", password="pass", host="nuxref.com", pgp_mode="encrypt", use_pgp=False, ) assert obj.pgp_mode == "encrypt" # pgppub= path is masked when privacy=True obj = Apprise.instantiate( "mailto://user:pass@nuxref.com?pgp=encrypt&pgppub=/path/to/my-pub.asc" ) assert obj is not None assert obj.pgp_key == "/path/to/my-pub.asc" # Full URL includes pgppub= (path may be percent-encoded in the query # string); privacy URL must replace the value with literal **** assert "pgppub=" in obj.url(privacy=False) assert "pgppub=****" in obj.url(privacy=True) assert "my-pub.asc" not in obj.url(privacy=True) @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_wkd_param(mock_smtp, mock_smtpssl): """NotifyEmail() wkd= URL parameter and url() round-trip.""" mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket # wkd=yes -> use_wkd=True; a WKD controller is wired into pgp obj = Apprise.instantiate("mailto://user:pass@nuxref.com?wkd=yes") assert obj.use_wkd is True assert obj.pgp.wkd is not None # wkd=yes without pgp= implies pgp=encrypt assert obj.pgp_mode == "encrypt" # wkd=no -> use_wkd=False obj = Apprise.instantiate("mailto://user:pass@nuxref.com?wkd=no") assert obj.use_wkd is False assert obj.pgp.wkd is None # url() includes wkd=yes when enabled; pgp=encrypt is implied obj = Apprise.instantiate("mailto://user:pass@nuxref.com?wkd=yes") generated = obj.url() assert "wkd=yes" in generated assert "pgp=encrypt" in generated # wkd=yes alongside explicit pgp=no keeps no (explicit wins) obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=no&wkd=yes") assert obj.pgp_mode == "no" # url() must emit pgp=no when wkd=yes and pgp=no is explicit so that # the round-trip URL does not re-apply the wkd=yes->encrypt implication generated = obj.url() assert "pgp=no" in generated assert "wkd=yes" in generated # Parsing the round-trip URL must preserve pgp=no (not re-imply encrypt) obj2 = Apprise.instantiate(generated) assert obj2 is not None assert obj2.pgp_mode == "no" assert obj2.use_wkd is True # use_wkd as a string 'no' must disable WKD (bool('no') would be True) obj = email.NotifyEmail( user="user", password="pass", host="nuxref.com", use_wkd="no" ) assert obj.use_wkd is False # Direct __init__ call with use_wkd=True and no pgp_mode obj = email.NotifyEmail( user="user", password="pass", host="nuxref.com", use_wkd=True ) assert obj.use_wkd is True assert obj.pgp_mode == "encrypt" # url() omits wkd= when disabled (it is the default) obj = Apprise.instantiate("mailto://user:pass@nuxref.com") assert "wkd=" not in obj.url() @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_wkd_key_discovery(mock_smtp, mock_smtpssl, tmpdir): """Email send uses the WKD-fetched key when no local key exists.""" import pgpy mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket # Generate a real key pair to serve as the WKD response key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Chris", email="chris@nuxref.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir), ) obj = Apprise.instantiate( "mailto://chris:pass@nuxref.com?pgp=encrypt&wkd=yes", asset=asset, ) assert obj.use_wkd is True # Patch WKD fetch to return our generated public key with mock.patch.object(obj.pgp.wkd, "fetch", return_value=pub_bytes): assert obj.notify("test body") is True # Clear the parsed-key cache so the second block cannot reuse the # key loaded above -- we need the fetch mock to be the sole source obj.pgp._ApprisePGPController__key_lookup.clear() # WKD returning None with autogen disabled falls through gracefully asset.pgp_autogen = False with mock.patch.object(obj.pgp.wkd, "fetch", return_value=None): assert obj.notify("test body") is False @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_pgp_sign_mode_param(mock_smtp, mock_smtpssl): """NotifyEmail() pgp=sign mode round-trip and attribute checks.""" mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket # pgp=sign sets pgp_mode to 'sign' obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=sign") assert obj is not None assert obj.pgp_mode == "sign" # pgp=s (prefix shorthand) also resolves to 'sign' obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=s") assert obj is not None assert obj.pgp_mode == "sign" # url() round-trip preserves pgp=sign obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=sign") generated = obj.url() assert "pgp=sign" in generated obj2 = Apprise.instantiate(generated) assert obj2 is not None assert obj2.pgp_mode == "sign" # pgp=sign + wkd=yes: sign mode is respected; wkd implication does NOT # override an explicit pgp=sign to pgp=encrypt obj = Apprise.instantiate("mailto://user:pass@nuxref.com?pgp=sign&wkd=yes") assert obj.pgp_mode == "sign" assert obj.use_wkd is True @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_pgp_privkey_param(mock_smtp, mock_smtpssl, tmpdir): """NotifyEmail() pgpprv= URL parameter round-trip.""" mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket prv_path = os.path.join( os.path.dirname(__file__), "var", "pgp", "valid-prv.asc" ) # pgpprv= is stored on the instance obj = Apprise.instantiate( f"mailto://user:pass@nuxref.com?pgp=sign&pgpprv={prv_path}" ) assert obj is not None assert obj.pgp_privkey == prv_path # url() includes pgpprv= in plain mode, masks it in privacy mode plain = obj.url(privacy=False) priv = obj.url(privacy=True) assert "pgpprv=" in plain assert prv_path in plain or "valid-prv.asc" in plain assert "pgpprv=****" in priv # Round-trip: parse the generated URL and verify pgp_privkey is preserved parsed = email.NotifyEmail.parse_url(plain) assert parsed is not None assert "pgp_privkey" in parsed assert "valid-prv.asc" in parsed["pgp_privkey"] @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_pgp_sign_send(mock_smtp, mock_smtpssl, tmpdir): """pgp=sign mode produces a multipart/signed email on send.""" mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket prv_path = os.path.join( os.path.dirname(__file__), "var", "pgp", "valid-prv.asc" ) asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir), ) obj = Apprise.instantiate( f"mailto://user:pass@nuxref.com?pgp=sign&pgpprv={prv_path}", asset=asset, ) assert obj is not None # Notification should succeed and sendmail should be called once assert obj.notify("test body") is True assert mock_socket.sendmail.call_count == 1 # The emitted email must contain the multipart/signed structure raw = mock_socket.sendmail.call_args[0][2] assert "multipart/signed" in raw assert "application/pgp-signature" in raw @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_plugin_email_pgp_sign_crlf_roundtrip(tmpdir): """PGP signature must verify against CRLF content, not bare LF. This test verifies we are following RFC 3156's requirement """ import pgpy as _pgpy prv_path = os.path.join(TEST_VAR_DIR, "pgp", "valid-prv.asc") # Build a controller that uses the test fixture private key ctrl = utils.pgp.ApprisePGPController( path=str(tmpdir), prv_keyfile=prv_path ) # Obtain the corresponding public key so we can verify independently; # the private key object exposes its own public sub-key via .pubkey prv_key = ctrl.private_key() assert prv_key is not None pub_key = prv_key.pubkey # Simulate a MIME body with bare LF endings (as Python produces on Linux) raw_body = "Content-Type: text/plain\r\n\r\nHello RFC 3156\n" # Build the CRLF form β€” exactly what the fixed code passes to sign() crlf_body = re.sub(r"(?:\r\n|\n|\r(?!\n))", "\r\n", raw_body) # Build the bare-LF form β€” what the unfixed code would have passed lf_body = re.sub(r"\r\n", "\n", crlf_body) # Sign the CRLF form (as the production code does) result = ctrl.sign(crlf_body) assert result is not None, "sign() must succeed with a valid private key" sig_str, micalg = result assert micalg.startswith("pgp-") # Parse the returned armored signature back to a PGPSignature object sig = _pgpy.PGPSignature.from_blob(sig_str) # The CRLF body must verify -- this is what the recipient receives. # pgpy.verify() returns a SignatureVerification object (truthy on # success, falsy on failure) rather than raising PGPError for a bad # signature; check the return value directly. crlf_ok = bool(pub_key.verify(crlf_body, sig)) assert crlf_ok, ( "Signature did not verify against CRLF content -- " "the signed body must use CRLF line endings (RFC 3156)" ) # The bare-LF form must NOT verify (regression guard: CRLF # normalisation before pgp.sign() must not be removed). lf_ok = bool(pub_key.verify(lf_body, sig)) assert not lf_ok, ( "Signature verified against bare-LF content -- CRLF normalisation " "before pgp.sign() may have been removed (regression)" ) @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_plugin_email_pgp_sign_wire_content_crlf( mock_smtp, mock_smtpssl, tmpdir ): """Content passed to pgp.sign() must have no bare LF line endings. Further verification ensureing RFC 3156 requirements are met """ mock_socket = mock.Mock() mock_socket.starttls.return_value = True mock_socket.login.return_value = True mock_smtp.return_value = mock_socket mock_smtpssl.return_value = mock_socket prv_path = os.path.join(TEST_VAR_DIR, "pgp", "valid-prv.asc") # Capture the exact content forwarded to pgp.sign() during send signed_content = {} original_sign = utils.pgp.ApprisePGPController.sign def capturing_sign(self, message): # Record the message that base.py passes to the signer signed_content["message"] = message return original_sign(self, message) asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir), ) obj = Apprise.instantiate( f"mailto://user:pass@nuxref.com?pgp=sign&pgpprv={prv_path}", asset=asset, ) assert obj is not None # Send while intercepting sign() to record the input content with mock.patch.object( utils.pgp.ApprisePGPController, "sign", capturing_sign ): assert obj.notify("test body") is True # Confirm send actually happened and our interceptor was triggered assert mock_socket.sendmail.call_count == 1 assert "message" in signed_content, ( "pgp.sign() was not called during the send β€” " "the interception wrapper was not reached" ) # Verify every line ending in the signed content must be CRLF assert not re.search(r"(?hello

    ", title="test", notify_type=NotifyType.INFO, attach=img, ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # Standard mode: mixed wrapper, attachment disposition, no Content-ID assert "multipart/mixed" in msg assert "Content-Disposition: attachment" in msg assert "Content-ID" not in msg obj = Apprise.instantiate( "mailto://user:pass@gmail.com?inline=yes", suppress_exceptions=False ) assert isinstance(obj, email.NotifyEmail) assert obj.inline is True # inline=True, HTML, image with no pre-existing cid: ref: # a
    anchor is appended to the body and # the attachment is embedded inline sent_messages.clear() assert ( obj.notify( body="

    no cid ref here

    ", title="test", notify_type=NotifyType.INFO, attach=img, ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # Image inlined regardless -- anchor appended automatically assert "multipart/related" in msg assert "Content-Disposition: inline" in msg assert "Content-ID:" in msg assert img_name in msg # inline=True, HTML, image WITH pre-existing cid: ref: # no duplicate anchor is added; attachment still inlined html_with_ref = f'' sent_messages.clear() assert ( obj.notify( body=html_with_ref, title="test", notify_type=NotifyType.INFO, attach=img, ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # Still related + inline; body should not have a second
    assert "multipart/related" in msg assert "Content-Disposition: inline" in msg assert "Content-ID:" in msg # Only one cid: anchor expected in the body part assert msg.count(f"cid:{img_name}") == 1 # inline=True, HTML, two images: both inlined, body gets an # anchor appended for the one not already referenced html_one_ref = f'' sent_messages.clear() assert ( obj.notify( body=html_one_ref, title="test", notify_type=NotifyType.INFO, attach=AppriseAttachment([img, img2]), ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # Both images are inline; second one had its anchor appended assert "multipart/related" in msg assert msg.count("Content-Disposition: inline") == 2 assert msg.count("Content-ID:") == 2 assert img_name in msg assert img2_name in msg # inline=True, HTML, non-image attachment: stays as regular # download regardless of inline flag img_jpeg = os.path.join(TEST_VAR_DIR, "apprise-test.jpeg") from apprise.attachment.file import AttachFile pdf_attach = AttachFile(img_jpeg, mimetype="application/pdf") aa = AppriseAttachment() aa.add(pdf_attach) sent_messages.clear() assert ( obj.notify( body="

    body

    ", title="test", notify_type=NotifyType.INFO, attach=aa, ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # No image -> no related wrapper, no inline, no Content-ID assert "multipart/mixed" in msg assert "Content-Disposition: attachment" in msg assert "Content-ID" not in msg # inline=True, TEXT format: [Image: name] is appended to the # text body; attachment stays as a regular download obj_txt = Apprise.instantiate( "mailto://user:pass@gmail.com?inline=yes&format=text", suppress_exceptions=False, ) assert isinstance(obj_txt, email.NotifyEmail) assert obj_txt.inline is True sent_messages.clear() assert ( obj_txt.notify( body="plain text body", title="test", notify_type=NotifyType.INFO, attach=img, ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # Text mode: mixed wrapper, attachment disposition, placeholder in body assert "multipart/mixed" in msg assert "Content-Disposition: attachment" in msg assert "Content-ID" not in msg assert f"[Image: {img_name}]" in msg # inline=True, TEXT format, non-image attachment sent_messages.clear() assert ( obj_txt.notify( body="plain text body", title="test", notify_type=NotifyType.INFO, attach=aa, # application/pdf override -- not an image ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # Non-image in text inline mode: body unchanged, no [Image:] annotation assert "Content-Disposition: attachment" in msg assert "[Image:" not in msg # inline=True, HTML, image attachment with uppercase mimetype upper_attach = AttachFile(img) upper_attach.detected_mimetype = "Image/GIF" aa_upper = AppriseAttachment() aa_upper.add(upper_attach) sent_messages.clear() assert ( obj.notify( body="

    no cid

    ", title="test", notify_type=NotifyType.INFO, attach=aa_upper, ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # Uppercase mimetype must still be treated as an image assert "multipart/related" in msg assert "Content-Disposition: inline" in msg # inline=True, TEXT format, uppercase mimetype: [Image: name] marker must # also appear, proving the case-insensitive check works in the text path sent_messages.clear() assert ( obj_txt.notify( body="plain body", title="test", notify_type=NotifyType.INFO, attach=aa_upper, ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] assert f"[Image: {img_name}]" in msg # inline=True, HTML, cid: ref in body with no matching attachment: # warning is logged so the user can debug the mismatch with mock.patch("apprise.plugins.email.base.logger") as mock_logger: assert ( obj.notify( body='', title="test", notify_type=NotifyType.INFO, attach=img, ) is True ) # Warning fired for the unmatched ref warning_texts = " ".join( str(c) for c in mock_logger.warning.call_args_list ) assert "missing-file.jpg" in warning_texts # inline=True, HTML, only unmatched cid: ref with a non-image attachment: # the unmatched ref is dropped after the warning so cid_refs is empty # when the wrapper type is chosen -- wrapper must be multipart/mixed sent_messages.clear() with mock.patch("apprise.plugins.email.base.logger"): assert ( obj.notify( body='

    body

    ', title="test", notify_type=NotifyType.INFO, # application/pdf -- not an image, not named ghost.jpg attach=aa, ) is True ) assert len(sent_messages) == 1 msg = sent_messages[0] # No inline parts exist: wrapper must be mixed, not related assert "multipart/mixed" in msg assert "multipart/related" not in msg assert "Content-ID" not in msg # inline=True, HTML, explicit cid: ref whose filename MATCHES a # non-image attachment (PDF): a cid: URI can only resolve within # the same MIME package, so if the caller wrote it they attached # the file deliberately. The ref is honored regardless of type -- # wrapper becomes multipart/related and the PDF is inlined. sent_messages.clear() with mock.patch("apprise.plugins.email.base.logger") as mock_logger: assert ( obj.notify( body=f'

    body

    ', title="test", notify_type=NotifyType.INFO, # same PDF attachment as earlier tests attach=aa, ) is True ) # The body ref matched by name, so no "no attachment matches" warning for call in mock_logger.warning.call_args_list: assert "apprise-test" not in str(call) assert len(sent_messages) == 1 msg = sent_messages[0] # Explicit cid: ref to PDF must be honored: related wrapper, inline assert "multipart/related" in msg assert "Content-Disposition: inline" in msg assert "Content-ID" in msg # inline=True, HTML, cid: ref encoded with %20 for a file whose actual # name contains a space: the decoded ref matches the attachment so no # spurious warning is fired and the image is inlined exactly once spaced_attach = AttachFile(img, name="my photo.gif") aa_spaced = AppriseAttachment() aa_spaced.add(spaced_attach) sent_messages.clear() with mock.patch("apprise.plugins.email.base.logger") as mock_logger: assert ( obj.notify( body='', title="test", notify_type=NotifyType.INFO, attach=aa_spaced, ) is True ) # No warning about an unmatched ref -- %20 decoded to space matched it for call in mock_logger.warning.call_args_list: assert "my" not in str(call), ( "Spurious warning fired for a cid: ref that should have " "matched after %20 normalisation" ) assert len(sent_messages) == 1 msg = sent_messages[0] # Image is inlined exactly once (no duplicate anchor) assert msg.count("cid:my%20photo.gif") == 1 assert "Content-Disposition: inline" in msg assert "Content-ID" in msg # inline=True, HTML, inaccessible attachment bad_attach = AttachFile("/nonexistent/path/photo.jpg") aa_bad = AppriseAttachment() aa_bad.add(bad_attach) sent_messages.clear() assert ( obj.notify( body="

    body

    ", title="test", notify_type=NotifyType.INFO, attach=aa_bad, ) is False # bad attachment detected by the main loop ) # No sendmail call -- the bad attachment caused an early failure assert len(sent_messages) == 0 # inline=True, TEXT format, inaccessible attachment sent_messages.clear() assert ( obj_txt.notify( body="plain body", title="test", notify_type=NotifyType.INFO, attach=aa_bad, ) is False ) assert len(sent_messages) == 0 # URL round-trip: inline=yes survives url() -> parse_url() assert "inline=yes" in obj.url() parsed = email.NotifyEmail.parse_url(obj.url()) assert parsed is not None assert parsed.get("inline") is True # inline=no parses as False obj2 = Apprise.instantiate( "mailto://user:pass@gmail.com?inline=no", suppress_exceptions=False ) assert isinstance(obj2, email.NotifyEmail) assert obj2.inline is False assert "inline" not in obj2.url() caronc-apprise-182f859/tests/test_plugin_emby.py000066400000000000000000000345721524161175200220140ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise from apprise.plugins.emby import NotifyEmby logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( # Insecure Request; no hostname specified ( "emby://", { "instance": None, }, ), # Secure Emby Request; no hostname specified ( "embys://", { "instance": None, }, ), # No user specified ( "emby://localhost", { # Missing a username "instance": TypeError, }, ), ( "emby://:@/", { "instance": None, }, ), # Valid Authentication ( "emby://l2g@localhost", { "instance": NotifyEmby, # our response will be False because our authentication can't be # tested very well using this matrix. "response": False, }, ), ( "embys://l2g:password@localhost", { "instance": NotifyEmby, # our response will be False because our authentication can't be # tested very well using this matrix. "response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "embys://l2g:****@localhost", }, ), ) def test_plugin_template_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("apprise.plugins.emby.NotifyEmby.sessions") @mock.patch("apprise.plugins.emby.NotifyEmby.login") @mock.patch("apprise.plugins.emby.NotifyEmby.logout") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_emby_general( mock_post, mock_get, mock_logout, mock_login, mock_sessions ): """NotifyEmby General Tests.""" req = requests.Request() req.status_code = requests.codes.ok req.content = "" mock_get.return_value = req mock_post.return_value = req # This is done so we don't obstruct our access_token and user_id values mock_login.return_value = True mock_logout.return_value = True mock_sessions.return_value = {"abcd": {}} obj = Apprise.instantiate("emby://l2g:l2gpass@localhost?modal=False") assert isinstance(obj, NotifyEmby) assert obj.notify("title", "body", "info") is True obj.access_token = "abc" obj.user_id = "123" # Test Modal support obj = Apprise.instantiate("emby://l2g:l2gpass@localhost?modal=True") assert isinstance(obj, NotifyEmby) assert obj.notify("title", "body", "info") is True obj.access_token = "abc" obj.user_id = "123" # Test our exception handling for exception in AppriseURLTester.req_exceptions: mock_post.side_effect = exception mock_get.side_effect = exception # We'll fail to log in each time assert obj.notify("title", "body", "info") is False # Disable Exceptions mock_post.side_effect = None mock_get.side_effect = None # Our login flat out fails if we don't have proper parseable content mock_post.return_value.content = "" mock_get.return_value.content = mock_post.return_value.content # KeyError handling mock_post.return_value.status_code = 999 mock_get.return_value.status_code = 999 assert obj.notify("title", "body", "info") is False # General Internal Server Error mock_post.return_value.status_code = requests.codes.internal_server_error mock_get.return_value.status_code = requests.codes.internal_server_error assert obj.notify("title", "body", "info") is False mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.content = mock_post.return_value.content # Disable the port completely obj.port = None assert obj.notify("title", "body", "info") is True # An Empty return set (no query is made, but notification will still # succeed mock_sessions.return_value = {} assert obj.notify("title", "body", "info") is True # Tidy our object del obj @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_emby_login(mock_post, mock_get): """NotifyEmby() login()""" # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() obj = Apprise.instantiate("emby://l2g:l2gpass@localhost") assert isinstance(obj, NotifyEmby) # Test our exception handling for exception in AppriseURLTester.req_exceptions: mock_post.side_effect = exception mock_get.side_effect = exception # We'll fail to log in each time assert obj.login() is False # Disable Exceptions mock_post.side_effect = None mock_get.side_effect = None # Our login flat out fails if we don't have proper parseable content mock_post.return_value.content = "" mock_get.return_value.content = mock_post.return_value.content # KeyError handling mock_post.return_value.status_code = 999 mock_get.return_value.status_code = 999 assert obj.login() is False # General Internal Server Error mock_post.return_value.status_code = requests.codes.internal_server_error mock_get.return_value.status_code = requests.codes.internal_server_error assert obj.login() is False mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok obj = Apprise.instantiate("emby://l2g:l2gpass@localhost:1234") # Set a different port (outside of default) assert isinstance(obj, NotifyEmby) assert obj.port == 1234 # The login will fail because '' is not a parseable JSON response assert obj.login() is False # Disable the port completely obj.port = None assert obj.login() is False # Default port assignments obj = Apprise.instantiate("emby://l2g:l2gpass@localhost") assert isinstance(obj, NotifyEmby) assert obj.port == 8096 # The login will (still) fail because '' is not a parseable JSON response assert obj.login() is False # Our login flat out fails if we don't have proper parseable content mock_post.return_value.content = dumps( { "AccessToken": "0000-0000-0000-0000", } ) mock_get.return_value.content = mock_post.return_value.content obj = Apprise.instantiate("emby://l2g:l2gpass@localhost") assert isinstance(obj, NotifyEmby) # The login will fail because the 'User' or 'Id' field wasn't parsed assert obj.login() is False # Our text content (we intentionally reverse the 2 locations # that store the same thing; we do this so we can test which # one it defaults to if both are present mock_post.return_value.content = dumps( { "User": { "Id": "abcd123", }, "Id": "123abc", "AccessToken": "0000-0000-0000-0000", } ) mock_get.return_value.content = mock_post.return_value.content obj = Apprise.instantiate("emby://l2g:l2gpass@localhost") assert isinstance(obj, NotifyEmby) # Login assert obj.login() is True assert obj.user_id == "123abc" assert obj.access_token == "0000-0000-0000-0000" # We're going to log in a second time which checks that we logout # first before logging in again. But this time we'll scrap the # 'Id' area and use the one found in the User area if detected mock_post.return_value.content = dumps( { "User": { "Id": "abcd123", }, "AccessToken": "0000-0000-0000-0000", } ) mock_get.return_value.content = mock_post.return_value.content # Login assert obj.login() is True assert obj.user_id == "abcd123" assert obj.access_token == "0000-0000-0000-0000" @mock.patch("apprise.plugins.emby.NotifyEmby.login") @mock.patch("apprise.plugins.emby.NotifyEmby.logout") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_emby_sessions(mock_post, mock_get, mock_logout, mock_login): """NotifyEmby() sessions()""" # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() # This is done so we don't obstruct our access_token and user_id values mock_login.return_value = True mock_logout.return_value = True obj = Apprise.instantiate("emby://l2g:l2gpass@localhost") assert isinstance(obj, NotifyEmby) obj.access_token = "abc" obj.user_id = "123" # Test our exception handling for exception in AppriseURLTester.req_exceptions: mock_post.side_effect = exception mock_get.side_effect = exception # We'll fail to log in each time sessions = obj.sessions() assert isinstance(sessions, dict) is True assert len(sessions) == 0 # Disable Exceptions mock_post.side_effect = None mock_get.side_effect = None # Our login flat out fails if we don't have proper parseable content mock_post.return_value.content = "" mock_get.return_value.content = mock_post.return_value.content # KeyError handling mock_post.return_value.status_code = 999 mock_get.return_value.status_code = 999 sessions = obj.sessions() assert isinstance(sessions, dict) is True assert len(sessions) == 0 # General Internal Server Error mock_post.return_value.status_code = requests.codes.internal_server_error mock_get.return_value.status_code = requests.codes.internal_server_error sessions = obj.sessions() assert isinstance(sessions, dict) is True assert len(sessions) == 0 mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.content = mock_post.return_value.content # Disable the port completely obj.port = None sessions = obj.sessions() assert isinstance(sessions, dict) is True assert len(sessions) == 0 # Let's get some results mock_post.return_value.content = dumps( [ { "Id": "abc123", }, { "Id": "def456", }, { "InvalidEntry": None, }, ] ) mock_get.return_value.content = mock_post.return_value.content sessions = obj.sessions(user_controlled=True) assert isinstance(sessions, dict) is True assert len(sessions) == 2 # Test it without setting user-controlled sessions sessions = obj.sessions(user_controlled=False) assert isinstance(sessions, dict) is True assert len(sessions) == 2 # Triggers an authentication failure obj.user_id = None mock_login.return_value = False sessions = obj.sessions() assert isinstance(sessions, dict) is True assert len(sessions) == 0 @mock.patch("apprise.plugins.emby.NotifyEmby.login") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_emby_logout(mock_post, mock_get, mock_login): """NotifyEmby() logout()""" # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() # This is done so we don't obstruct our access_token and user_id values mock_login.return_value = True obj = Apprise.instantiate("emby://l2g:l2gpass@localhost") assert isinstance(obj, NotifyEmby) obj.access_token = "abc" obj.user_id = "123" # Test our exception handling for exception in AppriseURLTester.req_exceptions: mock_post.side_effect = exception mock_get.side_effect = exception # We'll fail to log in each time obj.logout() obj.access_token = "abc" obj.user_id = "123" # Disable Exceptions mock_post.side_effect = None mock_get.side_effect = None # Our login flat out fails if we don't have proper parseable content mock_post.return_value.content = "" mock_get.return_value.content = mock_post.return_value.content # KeyError handling mock_post.return_value.status_code = 999 mock_get.return_value.status_code = 999 obj.logout() obj.access_token = "abc" obj.user_id = "123" # General Internal Server Error mock_post.return_value.status_code = requests.codes.internal_server_error mock_get.return_value.status_code = requests.codes.internal_server_error obj.logout() obj.access_token = "abc" obj.user_id = "123" mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.content = mock_post.return_value.content # Disable the port completely obj.port = None # Perform logout obj.logout() # Calling logout on an object already logged out obj.logout() # Test Python v3.5 LookupError Bug: https://bugs.python.org/issue29288 mock_post.side_effect = LookupError() mock_get.side_effect = LookupError() obj.access_token = "abc" obj.user_id = "123" # Tidy object del obj caronc-apprise-182f859/tests/test_plugin_enigma2.py000066400000000000000000000145631524161175200224000ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.enigma2 import NotifyEnigma2 logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "enigma2://:@/", { "instance": None, }, ), ( "enigma2://", { "instance": None, }, ), ( "enigma2s://", { "instance": None, }, ), ( "enigma2://localhost", { "instance": NotifyEnigma2, # This will fail because we're also expecting a server # acknowledgement "notify_response": False, }, ), ( "enigma2://localhost", { "instance": NotifyEnigma2, # invalid JSON response "requests_response_text": "{", "notify_response": False, }, ), ( "enigma2://localhost", { "instance": NotifyEnigma2, # False is returned "requests_response_text": {"result": False}, "notify_response": False, }, ), ( "enigma2://localhost", { "instance": NotifyEnigma2, # With the right content, this will succeed "requests_response_text": {"result": True}, }, ), ( "enigma2://user@localhost", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), # Set timeout ( "enigma2://user@localhost?timeout=-1", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), # Set timeout ( "enigma2://user@localhost?timeout=-1000", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), # Set invalid timeout (defaults to a set value) ( "enigma2://user@localhost?timeout=invalid", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), ( "enigma2://user:pass@localhost", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, # Our expected url(privacy=True) startswith() response: "privacy_url": "enigma2://user:****@localhost", }, ), ( "enigma2://localhost:8080", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), ( "enigma2://user:pass@localhost:8080", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), ( "enigma2s://localhost", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), ( "enigma2s://user:pass@localhost", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, # Our expected url(privacy=True) startswith() response: "privacy_url": "enigma2s://user:****@localhost", }, ), ( "enigma2s://localhost:8080/path/", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, # Our expected url(privacy=True) startswith() response: "privacy_url": "enigma2s://localhost:8080/path/", }, ), ( "enigma2s://user:pass@localhost:8080", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), ( "enigma2://localhost:8080/path?+HeaderKey=HeaderValue", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, }, ), ( "enigma2://user:pass@localhost:8081", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "enigma2://user:pass@localhost:8082", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "enigma2://user:pass@localhost:8083", { "instance": NotifyEnigma2, "requests_response_text": {"result": True}, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_enigma2_urls(): """NotifyEnigma2() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_evolution.py000066400000000000000000000333531524161175200231000ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyFormat from apprise.plugins.evolution import NotifyEvolution logging.disable(logging.CRITICAL) # --------------------------------------------------------------------------- # URL-based tests β€” exercised by AppriseURLTester # --------------------------------------------------------------------------- apprise_url_tests = ( # No host β€” parse_url returns None ( "evolution://", { "instance": None, }, ), # No host, explicit empty credentials ( "evolution://:@/", { "instance": None, }, ), # Missing apikey (no user field) ( "evolution://hostname/myinstance/5511999999999", { "instance": TypeError, }, ), # Missing instance (single path entry treated as instance; no phone left) ( "evolution://myapikey@hostname/5511999999999", { "instance": TypeError, }, ), # Missing phone number ( "evolution://myapikey@hostname/myinstance", { "instance": TypeError, }, ), # Invalid phone number ( "evolution://myapikey@hostname/myinstance/notaphone", { "instance": TypeError, }, ), # Valid HTTP β€” minimal ( "evolution://myapikey@hostname/myinstance/5511999999999", { "instance": NotifyEvolution, "privacy_url": ( "evolution://m...y@hostname/myinstance/5511999999999" ), }, ), # Valid HTTPS ( "evolutions://myapikey@hostname/myinstance/5511999999999", { "instance": NotifyEvolution, "privacy_url": ( "evolutions://m...y@hostname/myinstance/5511999999999" ), }, ), # Custom port ( "evolution://myapikey@hostname:8080/myinstance/5511999999999", { "instance": NotifyEvolution, "privacy_url": ( "evolution://m...y@hostname:8080/myinstance/5511999999999" ), }, ), # Multiple targets ( "evolution://myapikey@hostname/myinstance/5511999999999/5521888888888", { "instance": NotifyEvolution, }, ), # Target via ?to= ( "evolution://myapikey@hostname/myinstance/5511999999999" "?to=5521888888888", { "instance": NotifyEvolution, }, ), # Force HTTP error ( "evolution://myapikey@hostname/myinstance/5511999999999", { "instance": NotifyEvolution, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), # Unknown HTTP response code ( "evolution://myapikey@hostname/myinstance/5511999999999", { "instance": NotifyEvolution, "response": False, "requests_response_code": 999, }, ), # Connection exceptions ( "evolution://myapikey@hostname/myinstance/5511999999999", { "instance": NotifyEvolution, "test_requests_exceptions": True, }, ), ) def test_plugin_evolution_urls(): """NotifyEvolution() Apprise URLs.""" AppriseURLTester(tests=apprise_url_tests).run_all() # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- def test_plugin_evolution_parse_url_no_path(): """NotifyEvolution.parse_url() returns instance=None when path is empty.""" result = NotifyEvolution.parse_url("evolution://key@host") assert result is not None assert result["instance"] is None def test_plugin_evolution_edge_cases(): """NotifyEvolution() direct instantiation edge cases.""" # No apikey with pytest.raises(TypeError): NotifyEvolution( apikey=None, instance="inst", targets=["5511999999999"] ) # Blank apikey with pytest.raises(TypeError): NotifyEvolution( apikey=" ", instance="inst", targets=["5511999999999"] ) # No instance with pytest.raises(TypeError): NotifyEvolution(apikey="key", instance=None, targets=["5511999999999"]) # No targets with pytest.raises(TypeError): NotifyEvolution(apikey="key", instance="inst", targets=[]) # All targets invalid with pytest.raises(TypeError): NotifyEvolution( apikey="key", instance="inst", targets=["notaphone", "xx"] ) # --------------------------------------------------------------------------- # send() payload and header verification # --------------------------------------------------------------------------- @mock.patch("requests.post") def test_plugin_evolution_send(mock_post): """NotifyEvolution() verifies POST payload and headers.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok obj = Apprise.instantiate( "evolution://MYAPIKEY@localhost:8080/myinstance/5511999999999" ) assert isinstance(obj, NotifyEvolution) assert obj.notify(body="Hello World", title="Test") is True assert mock_post.call_count == 1 # Verify URL call = mock_post.call_args assert "localhost:8080" in call[0][0] assert "/message/sendText/myinstance" in call[0][0] # Verify headers headers = call[1]["headers"] assert headers["apikey"] == "MYAPIKEY" assert headers["Content-Type"] == "application/json" # Verify payload payload = loads(call[1]["data"]) assert payload["number"] == "5511999999999" assert "Hello World" in payload["text"] @mock.patch("requests.post") def test_plugin_evolution_multiple_targets(mock_post): """NotifyEvolution() sends one request per target.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok obj = Apprise.instantiate( "evolution://key@host/inst/5511111111111/5522222222222/5533333333333" ) assert isinstance(obj, NotifyEvolution) assert len(obj) == 3 assert obj.notify(body="msg") is True assert mock_post.call_count == 3 numbers = [loads(c[1]["data"])["number"] for c in mock_post.call_args_list] assert "5511111111111" in numbers assert "5522222222222" in numbers assert "5533333333333" in numbers @mock.patch("requests.post") def test_plugin_evolution_partial_failure(mock_post): """NotifyEvolution() returns False if any target fails.""" r_ok = requests.Request() r_ok.status_code = requests.codes.ok r_ok.content = b"" r_err = requests.Request() r_err.status_code = requests.codes.internal_server_error r_err.content = b"error" mock_post.side_effect = [r_ok, r_err] obj = Apprise.instantiate( "evolution://key@host/inst/5511111111111/5522222222222" ) assert obj.notify(body="msg") is False assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_evolution_title_in_body(mock_post): """NotifyEvolution() merges the title into the body via Apprise.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok obj = Apprise.instantiate("evolution://key@host/inst/5511999999999") assert obj.notify(body="body text", title="My Title") is True payload = loads(mock_post.call_args[1]["data"]) assert "My Title" in payload["text"] assert "body text" in payload["text"] @mock.patch("requests.post") def test_plugin_evolution_https(mock_post): """NotifyEvolution() uses HTTPS when evolutions:// schema is given.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok obj = Apprise.instantiate( "evolutions://key@secure.host/inst/5511999999999" ) assert isinstance(obj, NotifyEvolution) assert obj.secure is True assert obj.notify(body="secure msg") is True url = mock_post.call_args[0][0] assert url.startswith("https://") @mock.patch("requests.post") def test_plugin_evolution_html_to_markdown_hardening(mock_post): """Test edge cases in the CommonMark-to-WhatsApp dialect adaptation.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"{}" mock_post.return_value.text = "{}" def notify(body): aobj = Apprise() assert aobj.add("evolutions://key@host/inst/5511999999999") assert aobj.notify(body=body, body_format=NotifyFormat.HTML) is True payload = loads(mock_post.call_args_list[-1][1]["data"]) mock_post.reset_mock() return payload["text"] # Convert CommonMark emphasis to WhatsApp delimiters. assert notify("hello world") == "*hello* _world_" # Preserve adjacent nested emphasis. assert notify("x") == "*_x_*" # Non-adjacent nesting and sibling spans are unaffected. assert notify("bold italic still bold") == ( "*bold _italic_ still bold*" ) assert notify("AB") == "*A**B*" # Map inline and fenced code to WhatsApp's monospace syntax. assert notify("inline") == "```inline```" assert notify("
    block code
    ") == ( "```\nblock code\n```" ) # Preserve link labels beside WhatsApp's auto-linked URL. assert ( notify('click here') == "click here (https://example.com/x)" ) # Direct WhatsApp Markdown remains unchanged. aobj = Apprise() assert aobj.add("evolutions://key@host/inst/5511999999999") assert aobj.notify(body="*already* whatsapp-bound markdown") is True payload = loads(mock_post.call_args_list[-1][1]["data"]) assert payload["text"] == "*already* whatsapp-bound markdown" mock_post.reset_mock() # Drop CommonMark escapes unsupported by WhatsApp. assert notify("

    literal * asterisk

    ") == "literal * asterisk" f = NotifyEvolution._commonmark_to_whatsapp # Preserve unmatched backticks as literal text. assert f("text ``unterminated") == "text ``unterminated" # Collapse runs of three or more backticks without dropping spaces. # Three backticks collapse to two. assert f("`` code```here ``") == "``` code``here ```" # Longer backtick runs also collapse to two in one substitution. assert f("````` content```` end `````") == "``` content`` end ```" # Collapse empty entities without affecting following content. assert f("****x") == "x" # Close nonempty unterminated emphasis and drop empty spans. assert f("**unterminated") == "*unterminated*" assert f("**") == "" # Preserve an incomplete link as literal text. assert f("[text](y>)") == "click (https://e/x>y)" # A link with no text at all -- the bare URL is kept on its own # rather than emitting a dangling " (url)". assert f("[]()") == "https://example.com/x" # HTML body with a title: title is merged into the body as a heading # before the WhatsApp dialect conversion runs (covers _build_send_calls # title-merge branch). aobj = Apprise() assert aobj.add("evolutions://key@host/inst/5511999999999") assert ( aobj.notify( body="hello", title="My Title", body_format=NotifyFormat.HTML, ) is True ) payload = loads(mock_post.call_args_list[-1][1]["data"]) assert payload["text"] == "*My Title*\n*hello*" mock_post.reset_mock() # Title that reduces to an empty string after stripping leading heading # and list characters (html_to_markdown converts " - " to "-"). # The heading is skipped and the title field is cleared regardless. assert ( aobj.notify( body="hello", title=" - ", body_format=NotifyFormat.HTML, ) is True ) payload = loads(mock_post.call_args_list[-1][1]["data"]) assert payload["text"] == "*hello*" mock_post.reset_mock() caronc-apprise-182f859/tests/test_plugin_exotel.py000066400000000000000000000335221524161175200223520ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.plugins.exotel import NotifyExotel logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "exotel://", { # No Account SID specified "instance": TypeError, }, ), ( "exotel://:@/", { # invalid Auth token "instance": TypeError, }, ), ( "exotel://{}@12345678".format("a" * 32), { # Just sid provided "instance": TypeError, }, ), ( "exotel://{}:{}@_".format("a" * 32, "b" * 32), { # sid and token provided but invalid from "instance": TypeError, }, ), ( "exotel://{}:{}@/%20".format("a" * 32, "b" * 32), { # sid and token provided but no from "instance": TypeError, }, ), ( "exotel://{}:{}@{}".format("a" * 32, "b" * 32, "3" * 8), { # sid and token provided and from but invalid from no "instance": TypeError, }, ), ( "exotel://{}:{}@{}".format("a" * 32, "b" * 32, "3" * 9), { # sid and token provided and from "instance": NotifyExotel, }, ), ( "exotel://{}:{}@EXOTEL/{}".format("a" * 32, "b" * 32, "3" * 9), { # sid and token provided with a sender ID source "instance": NotifyExotel, }, ), ( "exotel://{}:{}@600123/{}".format("a" * 32, "b" * 32, "3" * 9), { # sid and token provided with a numeric sender ID source "instance": NotifyExotel, }, ), ( "exotel://{}:{}@{}/123/{}/abcd/".format( "a" * 32, "b" * 32, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifyExotel, # Since the targets are invalid, we'll fail to send() "notify_response": False, }, ), ( "exotel://{}:{}@12345/{}".format("a" * 32, "b" * 32, "4" * 11), { # using short-code (5 characters) is not supported "instance": TypeError, }, ), ( "exotel://{}:{}@{}".format("a" * 32, "b" * 32, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifyExotel, # Our expected url(privacy=True) startswith() response: "privacy_url": "exotel://****:b...b@55555555555/", }, ), ( "exotel://{}:{}@{}?batch=yes".format("a" * 32, "b" * 32, "5" * 11), { # Test batch flag "instance": NotifyExotel, }, ), ( "exotel://{}:{}@{}?batch=no".format("a" * 32, "b" * 32, "5" * 11), { # Test batch flag "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}".format( "a" * 32, "b" * 32, "5" * 11 ), { # use get args to accomplish the same thing "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}&unicode=Yes".format( "a" * 32, "b" * 32, "5" * 11 ), { # Test unicode flag "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}&unicode=no".format( "a" * 32, "b" * 32, "5" * 11 ), { # Test unicode flag "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}®ion=us".format( "a" * 32, "b" * 32, "5" * 11 ), { # Test region flag (Us) "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}®ion=in".format( "a" * 32, "b" * 32, "5" * 11 ), { # Test region flag (India) "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}®ion=invalid".format( "a" * 32, "b" * 32, "5" * 11 ), { # Test region flag Invalid "instance": TypeError, }, ), ( "exotel://_?sid={}&token={}&from={}&priority=normal".format( "a" * 32, "b" * 32, "5" * 11 ), { # Test region flag (Us) "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}&priority=high".format( "a" * 32, "b" * 32, "5" * 11 ), { # Test region flag (India) "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}&priority=invalid".format( "a" * 32, "b" * 32, "5" * 11 ), { # Test region flag Invalid "instance": TypeError, }, ), ( "exotel://_?sid={}&token={}&source={}".format( "a" * 32, "b" * 32, "5" * 11 ), { # use get args to accomplish the same thing (use source # instead of from) "instance": NotifyExotel, }, ), ( "exotel://_?sid={}&token={}&from={}&to={}".format( "a" * 32, "b" * 32, "5" * 11, "7" * 13 ), { # use to= "instance": NotifyExotel, }, ), ( "exotel://{}:{}@{}".format("a" * 32, "b" * 32, "6" * 11), { "instance": NotifyExotel, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "exotel://{}:{}@{}".format("a" * 32, "b" * 32, "6" * 11), { "instance": NotifyExotel, # Throws a series of connection and transfer exceptions when # this flag is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_exotel_urls(): """NotifyExotel() Apprise URLs.""" assert NotifyExotel.setup_url == "https://appriseit.com/services/exotel/" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_exotel_edge_cases(mock_post): """NotifyExotel() Edge Cases.""" sid = "a" * 32 apikey = "api-key" token = "b" * 32 source = "LM-EXOTEL" targets = ("6" * 11, "7" * 11) response = requests.Request() response.status_code = requests.codes.ok mock_post.return_value = response with pytest.raises(TypeError): NotifyExotel(sid=sid, token=token, source=" ") with pytest.raises(TypeError): NotifyExotel(sid=sid, token=token, source=source, apikey=" ") with pytest.raises(TypeError): NotifyExotel(sid=sid, token=token, source=source, region_name=" ") with pytest.raises(TypeError): NotifyExotel(sid=sid, token=token, source=source, priority=" ") # Programmatic priority shorthand is supported as an Apprise convenience. obj = NotifyExotel(sid=sid, token=token, source="5" * 11, priority="+") assert obj.priority == "high" assert obj.targets == ["5" * 11] mock_post.reset_mock() assert obj.notify(body="body", title="title", notify_type=NotifyType.INFO) assert mock_post.call_count == 1 payload = mock_post.call_args_list[0][1]["data"] assert payload["From"] == "5" * 11 assert payload["To"] == "5" * 11 assert payload["Priority"] == "high" # When a Sender ID is provided without targets, pass it upstream as the # implicit recipient and let Exotel decide whether it is deliverable. obj = NotifyExotel(sid=sid, token=token, source=source) assert obj.targets == [source] assert len(obj) == 1 mock_post.reset_mock() assert obj.notify(body="body", title="title", notify_type=NotifyType.INFO) assert mock_post.call_count == 1 payload = mock_post.call_args_list[0][1]["data"] assert payload["From"] == source assert payload["To"] == source obj = Apprise.instantiate( "exotel://{}:{}@{}/{}?apikey={}®ion=in&unicode=no" "&priority=high".format(sid, token, source, "/".join(targets), apikey) ) assert isinstance(obj, NotifyExotel) assert len(obj) == len(targets) assert set(obj.targets) == set(targets) mock_post.reset_mock() assert obj.notify(body="body", title="title", notify_type=NotifyType.INFO) assert mock_post.call_count == len(targets) first_call = mock_post.call_args_list[0] assert first_call[0][0] == ( "https://api.in.exotel.com/v1/Accounts/{}/Sms/send".format(sid) ) assert first_call[1]["auth"] == (apikey, token) first_payload = first_call[1]["data"] assert first_payload["From"] == source assert first_payload["To"] == obj.targets[0] assert first_payload["Body"] == "title\r\nbody" assert first_payload["EncodingType"] == "plain" assert first_payload["Priority"] == "high" assert "StatusCallback" not in first_payload second_call = mock_post.call_args_list[1] assert second_call[0][0] == ( "https://api.in.exotel.com/v1/Accounts/{}/Sms/send".format(sid) ) assert second_call[1]["auth"] == (apikey, token) second_payload = second_call[1]["data"] assert second_payload["From"] == source assert second_payload["To"] == obj.targets[1] assert second_payload["Body"] == "title\r\nbody" assert second_payload["EncodingType"] == "plain" assert second_payload["Priority"] == "high" assert "StatusCallback" not in second_payload obj = Apprise.instantiate( "exotel://{}:{}@{}/{}?apikey={}®ion=in&unicode=no" "&priority=high&batch=yes".format( sid, token, source, "/".join(targets), apikey ) ) assert isinstance(obj, NotifyExotel) assert len(obj) == 1 assert set(obj.targets) == set(targets) mock_post.reset_mock() assert obj.notify(body="body", title="title", notify_type=NotifyType.INFO) assert mock_post.call_count == 1 first_call = mock_post.call_args_list[0] assert first_call[0][0] == ( "https://api.in.exotel.com/v1/Accounts/{}/Sms/send".format(sid) ) assert first_call[1]["auth"] == (apikey, token) payload = first_call[1]["data"] assert payload["From"] == source assert payload["To"] == obj.targets assert payload["Body"] == "title\r\nbody" assert payload["EncodingType"] == "plain" assert payload["Priority"] == "high" assert "StatusCallback" not in payload assert "batch=yes" in obj.url() batch_targets = tuple("6{:010d}".format(i) for i in range(101)) batch_obj = NotifyExotel( sid=sid, token=token, source=source, targets=batch_targets, batch=True, ) assert len(batch_obj) == 2 mock_post.reset_mock() assert batch_obj.notify( body="body", title="title", notify_type=NotifyType.INFO ) assert mock_post.call_count == 2 first_payload = mock_post.call_args_list[0][1]["data"] assert first_payload["To"] == list(batch_targets[:100]) second_payload = mock_post.call_args_list[1][1]["data"] assert second_payload["To"] == list(batch_targets[100:]) # Targets must not be part of the URL identifier. obj_cmp = Apprise.instantiate( "exotel://{}:{}@{}/{}?region=in".format(sid, token, source, "8" * 11) ) assert obj.url_id() != obj_cmp.url_id() obj_cmp = Apprise.instantiate( "exotel://{}:{}@{}/{}?apikey={}®ion=in".format( sid, token, source, "8" * 11, apikey ) ) assert obj.url_id() == obj_cmp.url_id() obj_cmp = Apprise.instantiate( "exotel://{}:{}@{}/{}?key={}®ion=in".format( sid, token, source, "8" * 11, apikey ) ) assert obj.url_id() == obj_cmp.url_id() # Region changes the upstream API host and should alter the identifier. obj_cmp = Apprise.instantiate( "exotel://{}:{}@{}/{}?apikey={}®ion=us".format( sid, token, source, "8" * 11, apikey ) ) assert obj.url_id() != obj_cmp.url_id() # Even if all provided targets are invalid, Apprise treats the notifier # as one logical request target for length purposes. obj = Apprise.instantiate(f"exotel://{sid}:{token}@{source}/abcd") assert isinstance(obj, NotifyExotel) assert len(obj) == 1 caronc-apprise-182f859/tests/test_plugin_fcm.py000066400000000000000000000772421524161175200216260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # Great Resources: # - Dev/Legacy API: # https://firebase.google.com/docs/cloud-messaging/http-server-ref # - Legacy API (v1) -> OAuth # - https://firebase.google.com/docs/cloud-messaging/migrate-v1 import json import os import sys from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.plugins.fcm import NotifyFCM try: from cryptography.exceptions import UnsupportedAlgorithm from apprise.plugins.fcm.color import FCMColorManager from apprise.plugins.fcm.common import FCM_MODES from apprise.plugins.fcm.oauth import GoogleOAuth from apprise.plugins.fcm.priority import FCM_PRIORITIES, FCMPriorityManager except ImportError: # No problem; there is no cryptography support pass # Disable logging for a cleaner testing output import logging logging.disable(logging.CRITICAL) # Test files for KeyFile Directory PRIVATE_KEYFILE_DIR = os.path.join(os.path.dirname(__file__), "var", "fcm") FCM_KEYFILE = os.path.join(PRIVATE_KEYFILE_DIR, "service_account.json") # Our Testing URLs apprise_url_tests = ( ( "fcm://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "fcm://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "fcm://project@%20%20/", { # invalid apikey "instance": TypeError, }, ), ( "fcm://apikey/", { # no project id specified so we operate in legacy mode "instance": NotifyFCM, # but there are no targets specified so we return False "notify_response": False, }, ), ( "fcm://apikey/device", { # Valid device "instance": NotifyFCM, "privacy_url": "fcm://a...y/device", }, ), ( "fcm://apikey/#topic", { # Valid topic "instance": NotifyFCM, "privacy_url": "fcm://a...y/%23topic", }, ), ( "fcm://apikey/device?mode=invalid", { # Valid device, invalid mode "instance": TypeError, }, ), ( "fcm://apikey/#topic1/device/%20/", { # Valid topic, valid device, and invalid entry "instance": NotifyFCM, }, ), ( "fcm://apikey?to=#topic1,device", { # Test to= "instance": NotifyFCM, }, ), ( "fcm://?apikey=abc123&to=device", { # Test apikey= to= "instance": NotifyFCM, }, ), ( "fcm://?apikey=abc123&to=device&image=yes", { # Test image boolean "instance": NotifyFCM, }, ), ( "fcm://?apikey=abc123&to=device&color=no", { # Disable colors "instance": NotifyFCM, }, ), ( "fcm://?apikey=abc123&to=device&color=aabbcc", { # custom colors "instance": NotifyFCM, }, ), ( ( "fcm://?apikey=abc123&to=device" "&image_url=http://example.com/interesting.jpg" ), { # Test image_url "instance": NotifyFCM }, ), ( ( "fcm://?apikey=abc123&to=device" "&image_url=http://example.com/interesting.jpg&image=no" ), { # Test image_url but set to no "instance": NotifyFCM }, ), ( "fcm://?apikey=abc123&to=device&+key=value&+key2=value2", { # Test apikey= to= and data arguments "instance": NotifyFCM, }, ), ( "fcm://%20?to=device&keyfile=/invalid/path", { # invalid Project ID "instance": TypeError, }, ), ( "fcm://project_id?to=device&keyfile=/invalid/path", { # Test to= and auto-detection of OAuth mode "instance": NotifyFCM, # we'll fail to send our notification as a result "response": False, }, ), ( "fcm://?to=device&project=project_id&keyfile=/invalid/path", { # Test project= & to= and auto detection of OAuth mode "instance": NotifyFCM, # we'll fail to send our notification as a result "response": False, }, ), ( "fcm://project_id?to=device&mode=oauth2", { # no keyfile was specified "instance": TypeError, }, ), ( "fcm://project_id?to=device&mode=oauth2&keyfile=/invalid/path", { # Same test as above except we explicitly set our oauth2 mode # Test to= and auto-detection of OAuth mode "instance": NotifyFCM, # we'll fail to send our notification as a result "response": False, }, ), ( "fcm://apikey/#topic1/device/?mode=legacy", { "instance": NotifyFCM, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "fcm://apikey/#topic1/device/?mode=legacy", { "instance": NotifyFCM, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "fcm://project/#topic1/device/?mode=oauth2&keyfile=file://{}".format( os.path.join( os.path.dirname(__file__), "var", "fcm", "service_account.json" ) ), { "instance": NotifyFCM, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "fcm://projectid/#topic1/device/?mode=oauth2&keyfile=file://{}".format( os.path.join( os.path.dirname(__file__), "var", "fcm", "service_account.json" ) ), { "instance": NotifyFCM, # Throws a series of connection and transfer exceptions when # this flag is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) @pytest.fixture def mock_post(mocker): """Prepare a good OAuth mock response.""" mock_thing = mocker.patch("requests.post") response = mock.Mock() response.content = json.dumps( { "access_token": "ya29.c.abcd", "expires_in": 3599, "token_type": "Bearer", } ) response.status_code = requests.codes.ok mock_thing.return_value = response return mock_thing @pytest.fixture def mock_post_legacy(mocker): """Prepare a good legacy mock response.""" mock_thing = mocker.patch("requests.post") response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_thing.return_value = response return mock_thing @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_urls(): """NotifyFCM() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_legacy_default(mock_post_legacy): """NotifyFCM() Legacy/APIKey default checks.""" # A valid Legacy URL obj = Apprise.instantiate( "fcm://abc123/device/" "?+key=value&+key2=value2" "&image_url=https://example.com/interesting.png" ) # Send our notification assert obj.notify("test") is True # Test our call count assert mock_post_legacy.call_count == 1 assert ( mock_post_legacy.call_args_list[0][0][0] == "https://fcm.googleapis.com/fcm/send" ) payload = mock_post_legacy.mock_calls[0][2] data = json.loads(payload["data"]) assert "data" in data assert isinstance(data, dict) assert "key" in data["data"] assert data["data"]["key"] == "value" assert "key2" in data["data"] assert data["data"]["key2"] == "value2" assert "notification" in data assert isinstance(data["notification"], dict) assert "notification" in data["notification"] assert isinstance(data["notification"]["notification"], dict) assert "image" in data["notification"]["notification"] assert ( data["notification"]["notification"]["image"] == "https://example.com/interesting.png" ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_legacy_priorities(mock_post_legacy): """NotifyFCM() Legacy/APIKey priorities checks.""" obj = Apprise.instantiate("fcm://abc123/device/?priority=low") assert mock_post_legacy.call_count == 0 # Send our notification assert obj.notify(title="title", body="body") is True # Test our call count assert mock_post_legacy.call_count == 1 assert ( mock_post_legacy.call_args_list[0][0][0] == "https://fcm.googleapis.com/fcm/send" ) payload = mock_post_legacy.mock_calls[0][2] data = json.loads(payload["data"]) assert "data" not in data assert "notification" in data assert isinstance(data["notification"], dict) assert "notification" in data["notification"] assert isinstance(data["notification"]["notification"], dict) assert "image" not in data["notification"]["notification"] assert "priority" in data # legacy can only switch between high/low assert data["priority"] == "normal" @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_legacy_no_colors(mock_post_legacy): """NotifyFCM() Legacy/APIKey `color=no` checks.""" obj = Apprise.instantiate("fcm://abc123/device/?color=no") assert mock_post_legacy.call_count == 0 # Send our notification assert obj.notify(title="title", body="body") is True # Test our call count assert mock_post_legacy.call_count == 1 assert ( mock_post_legacy.call_args_list[0][0][0] == "https://fcm.googleapis.com/fcm/send" ) payload = mock_post_legacy.mock_calls[0][2] data = json.loads(payload["data"]) assert "data" not in data assert "notification" in data assert isinstance(data["notification"], dict) assert "notification" in data["notification"] assert isinstance(data["notification"]["notification"], dict) assert "image" not in data["notification"]["notification"] assert "color" not in data["notification"]["notification"] @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_legacy_colors(mock_post_legacy): """NotifyFCM() Legacy/APIKey colors checks.""" obj = Apprise.instantiate("fcm://abc123/device/?color=AA001b") assert mock_post_legacy.call_count == 0 # Send our notification assert obj.notify(title="title", body="body") is True # Test our call count assert mock_post_legacy.call_count == 1 assert ( mock_post_legacy.call_args_list[0][0][0] == "https://fcm.googleapis.com/fcm/send" ) payload = mock_post_legacy.mock_calls[0][2] data = json.loads(payload["data"]) assert "data" not in data assert "notification" in data assert isinstance(data["notification"], dict) assert "notification" in data["notification"] assert isinstance(data["notification"]["notification"], dict) assert "image" not in data["notification"]["notification"] assert "color" in data["notification"]["notification"] assert data["notification"]["notification"]["color"] == "#aa001b" @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_oauth_default(mock_post): """ NotifyFCM() general OAuth checks - success. Test using a valid Project ID and key file. """ obj = Apprise.instantiate( f"fcm://mock-project-id/device/#topic/?keyfile={FCM_KEYFILE}" ) # send our notification assert obj.notify("test") is True # Test our call count assert mock_post.call_count == 3 assert ( mock_post.call_args_list[0][0][0] == "https://accounts.google.com/o/oauth2/token" ) assert ( mock_post.call_args_list[1][0][0] == "https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send" ) assert ( mock_post.call_args_list[2][0][0] == "https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send" ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_oauth_invalid_project_id(mock_post): """NotifyFCM() OAuth checks, with invalid project id.""" # Test having a valid keyfile, but not a valid project id match. obj = Apprise.instantiate( f"fcm://invalid_project_id/device/?keyfile={FCM_KEYFILE}" ) # we'll fail as a result assert obj.notify("test") is False # Test our call count assert mock_post.call_count == 0 @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_oauth_keyfile_error(mock_post): """NotifyFCM() OAuth checks, while unable to read key file.""" # Now we test using a valid Project ID but we can't open our file obj = Apprise.instantiate( f"fcm://mock-project-id/device/?keyfile={FCM_KEYFILE}" ) with mock.patch("builtins.open", side_effect=OSError): # we'll fail as a result assert obj.notify("test") is False # Test our call count assert mock_post.call_count == 0 @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_oauth_data_parameters(mock_post): """NotifyFCM() OAuth checks, success. Test using a valid Project ID and data parameters. """ obj = Apprise.instantiate( f"fcm://mock-project-id/device/#topic/?keyfile={FCM_KEYFILE}" "&+key=value&+key2=value2" "&image_url=https://example.com/interesting.png" ) assert mock_post.call_count == 0 # send our notification assert obj.notify("test") is True # Test our call count assert mock_post.call_count == 3 assert ( mock_post.call_args_list[0][0][0] == "https://accounts.google.com/o/oauth2/token" ) assert ( mock_post.call_args_list[1][0][0] == "https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send" ) payload = mock_post.mock_calls[1][2] data = json.loads(payload["data"]) assert "message" in data assert isinstance(data["message"], dict) assert "data" in data["message"] assert isinstance(data["message"]["data"], dict) assert "key" in data["message"]["data"] assert data["message"]["data"]["key"] == "value" assert "key2" in data["message"]["data"] assert data["message"]["data"]["key2"] == "value2" assert "notification" in data["message"] assert isinstance(data["message"]["notification"], dict) assert "image" in data["message"]["notification"] assert ( data["message"]["notification"]["image"] == "https://example.com/interesting.png" ) assert ( mock_post.call_args_list[2][0][0] == "https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send" ) payload = mock_post.mock_calls[2][2] data = json.loads(payload["data"]) assert "message" in data assert isinstance(data["message"], dict) assert "data" in data["message"] assert isinstance(data["message"]["data"], dict) assert "key" in data["message"]["data"] assert data["message"]["data"]["key"] == "value" assert "key2" in data["message"]["data"] assert data["message"]["data"]["key2"] == "value2" assert "notification" in data["message"] assert isinstance(data["message"]["notification"], dict) assert "image" in data["message"]["notification"] assert ( data["message"]["notification"]["image"] == "https://example.com/interesting.png" ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_oauth_priorities(mock_post): """Verify priorities work as intended.""" obj = Apprise.instantiate( f"fcm://mock-project-id/device/?keyfile={FCM_KEYFILE}&priority=high" ) assert mock_post.call_count == 0 # Send our notification assert obj.notify(title="title", body="body") is True # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://accounts.google.com/o/oauth2/token" ) assert ( mock_post.call_args_list[1][0][0] == "https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send" ) payload = mock_post.mock_calls[1][2] data = json.loads(payload["data"]) assert "message" in data assert isinstance(data["message"], dict) assert "data" not in data["message"] assert "notification" in data["message"] assert isinstance(data["message"]["notification"], dict) assert "image" not in data["message"]["notification"] assert data["message"]["apns"]["headers"]["apns-priority"] == "10" assert data["message"]["webpush"]["headers"]["Urgency"] == "high" assert data["message"]["android"]["priority"] == "HIGH" @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_oauth_no_colors(mock_post): """Verify `color=no` work as intended.""" obj = Apprise.instantiate( f"fcm://mock-project-id/device/?keyfile={FCM_KEYFILE}&color=no" ) assert mock_post.call_count == 0 # Send our notification assert obj.notify(title="title", body="body") is True # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://accounts.google.com/o/oauth2/token" ) assert ( mock_post.call_args_list[1][0][0] == "https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send" ) payload = mock_post.mock_calls[1][2] data = json.loads(payload["data"]) assert "message" in data assert isinstance(data["message"], dict) assert "data" not in data["message"] assert "notification" in data["message"] assert isinstance(data["message"]["notification"], dict) assert "color" not in data["message"]["notification"] @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_oauth_colors(mock_post): """Verify colors work as intended.""" obj = Apprise.instantiate( f"fcm://mock-project-id/device/?keyfile={FCM_KEYFILE}&color=#12AAbb" ) assert mock_post.call_count == 0 # Send our notification assert obj.notify(title="title", body="body") is True # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://accounts.google.com/o/oauth2/token" ) assert ( mock_post.call_args_list[1][0][0] == "https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send" ) payload = mock_post.mock_calls[1][2] data = json.loads(payload["data"]) assert "message" in data assert isinstance(data["message"], dict) assert "data" not in data["message"] assert "notification" in data["message"] assert isinstance(data["message"]["notification"], dict) assert "color" in data["message"]["android"]["notification"] assert data["message"]["android"]["notification"]["color"] == "#12aabb" @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_keyfile_parse_default(mock_post): """NotifyFCM() KeyFile Tests.""" oauth = GoogleOAuth() # We can not get an Access Token without content loaded assert oauth.access_token is None # Load our content assert oauth.load(FCM_KEYFILE) is True assert oauth.access_token is not None # Test our call count assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://accounts.google.com/o/oauth2/token" ) mock_post.reset_mock() # a second call uses cache since our token hasn't expired yet assert oauth.access_token is not None assert mock_post.call_count == 0 @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_keyfile_parse_no_expiry(mock_post): """Test case without `expires_in` entry.""" mock_post.return_value.content = json.dumps( { "access_token": "ya29.c.abcd", "token_type": "Bearer", } ) oauth = GoogleOAuth() assert oauth.load(FCM_KEYFILE) is True assert oauth.access_token is not None # Test our call count assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://accounts.google.com/o/oauth2/token" ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_keyfile_parse_user_agent(mock_post): """Test case with `user-agent` override.""" oauth = GoogleOAuth(user_agent="test-agent-override") assert oauth.load(FCM_KEYFILE) is True assert oauth.access_token is not None assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://accounts.google.com/o/oauth2/token" ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_keyfile_parse_keyfile_failures(mock_post: mock.Mock): """Test some errors that can get thrown when trying to handle the `service_account.json` file.""" # Now we test a case where we can't access the file we've been pointed to: oauth = GoogleOAuth() with mock.patch("builtins.open", side_effect=OSError): # We will fail to retrieve our Access Token assert oauth.load(FCM_KEYFILE) is False assert oauth.access_token is None oauth = GoogleOAuth() with mock.patch("json.loads", side_effect=([],)): # We will fail to retrieve our Access Token since we did not parse # a dictionary assert oauth.load(FCM_KEYFILE) is False assert oauth.access_token is None # Case where we can't load the PEM key: oauth = GoogleOAuth() with mock.patch( "cryptography.hazmat.primitives.serialization.load_pem_private_key", side_effect=ValueError(""), ): assert oauth.load(FCM_KEYFILE) is False assert oauth.access_token is None # Case where we can't load the PEM key: oauth = GoogleOAuth() with mock.patch( "cryptography.hazmat.primitives.serialization.load_pem_private_key", side_effect=TypeError(""), ): assert oauth.load(FCM_KEYFILE) is False assert oauth.access_token is None # Case where we can't load the PEM key: oauth = GoogleOAuth() with mock.patch( "cryptography.hazmat.primitives.serialization.load_pem_private_key", side_effect=UnsupportedAlgorithm(""), ): # Note: This test should be te assert oauth.load(FCM_KEYFILE) is False assert oauth.access_token is None # Verify that not a single call to the web escaped the test harness. assert mock_post.mock_calls == [] @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_keyfile_parse_token_failures(mock_post): """Test some web errors that can occur when speaking upstream with Google to get our token generated.""" mock_post.return_value.status_code = requests.codes.internal_server_error mock_post.return_value.content = b"" oauth = GoogleOAuth() assert oauth.load(FCM_KEYFILE) is True # We'll fail due to an bad web response assert oauth.access_token is None # Return our status code to how it was mock_post.return_value.status_code = requests.codes.ok # No access token bad_response_1 = mock.Mock() bad_response_1.content = json.dumps( { "expires_in": 3599, "token_type": "Bearer", } ) # Invalid JSON bad_response_2 = mock.Mock() bad_response_2.content = "{" mock_post.return_value = None # Throw an exception on the first call to requests.post() for side_effect in ( requests.RequestException(), [bad_response_1], [bad_response_2], ): mock_post.side_effect = side_effect # Test all of our bad side effects oauth = GoogleOAuth() assert oauth.load(FCM_KEYFILE) is True # We'll fail due to an bad web response assert oauth.access_token is None @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_bad_keyfile_parse(): """NotifyFCM() KeyFile Bad Service Account Type Tests.""" path = os.path.join(PRIVATE_KEYFILE_DIR, "service_account-bad-type.json") oauth = GoogleOAuth() assert oauth.load(path) is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_keyfile_missing_entries_parse(tmpdir): """NotifyFCM() KeyFile Missing Entries Test.""" # Prepare a base keyfile reference to use path = os.path.join(PRIVATE_KEYFILE_DIR, "service_account.json") with open(path, encoding="utf-8") as fp: content = json.loads(fp.read()) path = tmpdir.join("fcm_keyfile.json") # Test that we fail to load if the following keys are missing: for entry in ( "client_email", "private_key_id", "private_key", "type", "project_id", ): # Ensure the key actually exists in our file assert entry in content # Create a copy of our content content_copy = content.copy() # Remove our entry we expect to validate against del content_copy[entry] assert entry not in content_copy path.write(json.dumps(content_copy)) oauth = GoogleOAuth() assert oauth.load(str(path)) is False # Now write ourselves a bad JSON file path.write("{") oauth = GoogleOAuth() assert oauth.load(str(path)) is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_priority_manager(): """NotifyFCM() FCMPriorityManager() Testing.""" for mode in FCM_MODES: for priority in FCM_PRIORITIES: instance = FCMPriorityManager(mode, priority) assert isinstance(instance.payload(), dict) # Verify it's not empty assert bool(instance) assert instance.payload() assert str(instance) == priority # We do not have to set a priority instance = FCMPriorityManager(mode) assert isinstance(instance.payload(), dict) # Dictionary is empty though assert not bool(instance) assert not instance.payload() assert str(instance) == "" with pytest.raises(TypeError): instance = FCMPriorityManager(mode, "invalid") with pytest.raises(TypeError): instance = FCMPriorityManager("invald", "high") # mode validation is done at the higher NotifyFCM() level so # it is not tested here (not required) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_fcm_color_manager(): """NotifyFCM() FCMColorManager() Testing.""" # No colors instance = FCMColorManager("no") assert bool(instance) is False assert instance.get() is None # We'll return that we are not defined assert str(instance) == "no" # Asset colors instance = FCMColorManager("yes") assert isinstance(instance.get(), str) # Output: #rrggbb assert len(instance.get()) == 7 # Starts with has symbol assert instance.get()[0] == "#" # We'll return that we are defined but using default configuration assert str(instance) == "yes" # We will be `true` because we can acquire a color based on what was # passed in assert bool(instance) # Custom color instance = FCMColorManager("#A2B3A4") assert isinstance(instance.get(), str) assert instance.get() == "#a2b3a4" assert bool(instance) # str() response does not include hashtag assert str(instance) == "a2b3a4" # Custom color (no hashtag) instance = FCMColorManager("A2B3A4") assert isinstance(instance.get(), str) # Hashtag is always part of output assert instance.get() == "#a2b3a4" assert bool(instance) # str() response does not include hashtag assert str(instance) == "a2b3a4" # Custom color (no hashtag) but only using 3 letter rgb values instance = FCMColorManager("AC4") assert isinstance(instance.get(), str) # Hashtag is always part of output assert instance.get() == "#aacc44" assert bool(instance) # str() response does not include hashtag assert str(instance) == "aacc44" @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_plugin_fcm_cryptography_import_error(): """NotifyFCM Cryptography loading failure.""" # Prepare a base keyfile reference to use path = os.path.join(PRIVATE_KEYFILE_DIR, "service_account.json") # Attempt to instantiate our object obj = Apprise.instantiate( f"fcm://mock-project-id/device/#topic/?keyfile={path!s}" ) # It's not possible because our cryptography depedancy is missing assert obj is None @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_fcm_edge_cases(mock_post): """NotifyFCM() Edge Cases.""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response # this tests an edge case where verify if the data_kwargs is a dictionary # or not. Below, we don't even define it, so it will be None (causing # the check to go). We'll still correctly instantiate a plugin: obj = NotifyFCM("project", "api:123", targets="device") assert obj is not None caronc-apprise-182f859/tests/test_plugin_feishu.py000066400000000000000000000060221524161175200223300ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.feishu import NotifyFeishu logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "feishu://", { "instance": TypeError, }, ), ( "feishu://:@/", { "instance": TypeError, }, ), ( "feishu://%badtoken%", { "instance": TypeError, }, ), ( "feishu://abc123", { # Test token "instance": NotifyFeishu, }, ), ( "feishu://?token=abc123", { # Test token "instance": NotifyFeishu, }, ), ( "feishu://token", { "instance": NotifyFeishu, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "feishu://token", { "instance": NotifyFeishu, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "feishu://token", { "instance": NotifyFeishu, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_feishu_urls(): """NotifyFeishu() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_flock.py000066400000000000000000000157701524161175200221550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.flock import NotifyFlock logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "flock://", { "instance": TypeError, }, ), # An invalid url ( "flock://:@/", { "instance": TypeError, }, ), # Provide a token ( "flock://%s" % ("t" * 24), { "instance": NotifyFlock, }, ), # Image handling ( "flock://%s?image=True" % ("t" * 24), { "instance": NotifyFlock, # Our expected url(privacy=True) startswith() response: "privacy_url": "flock://t...t", }, ), ( "flock://%s?image=False" % ("t" * 24), { "instance": NotifyFlock, }, ), ( "flock://%s?image=True" % ("t" * 24), { "instance": NotifyFlock, # Run test when image is set to True, but one couldn't actually be # loaded from the Asset Object. "include_image": False, }, ), # Test to= ( "flock://{}?to=u:{}&format=markdown".format("i" * 24, "u" * 12), { "instance": NotifyFlock, }, ), # Provide markdown format ( "flock://%s?format=markdown" % ("i" * 24), { "instance": NotifyFlock, }, ), # Provide text format ( "flock://%s?format=text" % ("i" * 24), { "instance": NotifyFlock, }, ), # Native URL Support, take the slack URL and still build from it ( "https://api.flock.com/hooks/sendMessage/{}/".format("i" * 24), { "instance": NotifyFlock, }, ), # Native URL Support with arguments ( "https://api.flock.com/hooks/sendMessage/{}/?format=markdown".format( "i" * 24 ), { "instance": NotifyFlock, }, ), # Bot API presumed if one or more targets are specified # Provide markdown format ( "flock://{}/u:{}?format=markdown".format("i" * 24, "u" * 12), { "instance": NotifyFlock, }, ), # Bot API presumed if one or more targets are specified # Provide text format ( "flock://{}/u:{}?format=html".format("i" * 24, "u" * 12), { "instance": NotifyFlock, }, ), # Bot API presumed if one or more targets are specified # u: is optional ( "flock://{}/{}?format=text".format("i" * 24, "u" * 12), { "instance": NotifyFlock, }, ), # Bot API presumed if one or more targets are specified # Multi-entries ( "flock://{}/g:{}/u:{}?format=text".format( "i" * 24, "g" * 12, "u" * 12 ), { "instance": NotifyFlock, }, ), # Bot API presumed if one or more targets are specified # Multi-entries using @ for user and # for channel ( "flock://{}/#{}/@{}?format=text".format("i" * 24, "g" * 12, "u" * 12), { "instance": NotifyFlock, }, ), # Bot API presumed if one or more targets are specified # has bad entry ( "flock://{}/g:{}/u:{}?format=text".format( "i" * 24, "g" * 12, "u" * 10 ), { "instance": NotifyFlock, }, ), # Invalid user/group defined ( "flock://%s/g:/u:?format=text" % ("i" * 24), { "instance": TypeError, }, ), # we don't focus on the invalid length of the user/group fields. # As a result, the following will load and pass the data upstream ( "flock://{}/g:{}/u:{}?format=text".format( "i" * 24, "g" * 14, "u" * 10 ), { # We will still instantiate the object "instance": NotifyFlock, }, ), # Error Testing ( "flock://{}/g:{}/u:{}?format=text".format( "i" * 24, "g" * 12, "u" * 10 ), { "instance": NotifyFlock, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "flock://%s/" % ("t" * 24), { "instance": NotifyFlock, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "flock://%s/" % ("t" * 24), { "instance": NotifyFlock, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "flock://%s/" % ("t" * 24), { "instance": NotifyFlock, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_flock_urls(): """NotifyFlock() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_flock_edge_cases(mock_post, mock_get): """NotifyFlock() Edge Cases.""" # Initializes the plugin with an invalid token with pytest.raises(TypeError): NotifyFlock(token=None) # Whitespace also acts as an invalid token value with pytest.raises(TypeError): NotifyFlock(token=" ") caronc-apprise-182f859/tests/test_plugin_flowtriq.py000066400000000000000000000300151524161175200227130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import json import logging from unittest import mock from urllib.parse import urlparse from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.plugins.flowtriq import NotifyFlowtriq logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "flowtriq://", { "instance": None, }, ), # No webhook path specified ( "flowtriq://apikey@hostname", { "instance": TypeError, }, ), # No API key specified ( "flowtriq://hostname/hooks/abc123", { "instance": TypeError, }, ), # Provide a hostname, apikey, and webhook path (insecure) ( "flowtriq://myapikey@hostname/hooks/abc123/", { "instance": NotifyFlowtriq, # Our expected url(privacy=True) startswith() response: "privacy_url": "flowtriq://m...y@hostname/hooks/abc123/", }, ), # Secure variant (flowtriqs://) ( "flowtriqs://myapikey@hostname/hooks/abc123/", { "instance": NotifyFlowtriq, "privacy_url": "flowtriqs://m...y@hostname/hooks/abc123/", }, ), # Provide a hostname with port (insecure) ( "flowtriq://myapikey@hostname:8080/hooks/abc123/", { "instance": NotifyFlowtriq, "privacy_url": "flowtriq://m...y@hostname:8080/hooks/abc123/", }, ), # Secure with non-default port ( "flowtriqs://myapikey@hostname:8443/hooks/abc123/", { "instance": NotifyFlowtriq, "privacy_url": "flowtriqs://m...y@hostname:8443/hooks/abc123/", }, ), # Multi-segment webhook path ( "flowtriqs://myapikey@flowtriq.com/api/v1/webhook/xyz/", { "instance": NotifyFlowtriq, }, ), # An invalid url ( "flowtriq://:@/", { "instance": None, }, ), # Test failure cases ( "flowtriq://myapikey@hostname/hooks/abc123/", { "instance": NotifyFlowtriq, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "flowtriq://myapikey@hostname/hooks/abc123/", { "instance": NotifyFlowtriq, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "flowtriq://myapikey@hostname/hooks/abc123/", { "instance": NotifyFlowtriq, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_flowtriq_urls(): """NotifyFlowtriq() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_flowtriq_edge_cases(): """NotifyFlowtriq() Edge Cases.""" # Initializes the plugin with an invalid API key with pytest.raises(TypeError): NotifyFlowtriq(apikey=None, webhook_path="hooks/abc123") # Whitespace also acts as an invalid API key with pytest.raises(TypeError): NotifyFlowtriq(apikey=" ", webhook_path="hooks/abc123") # Missing webhook path with pytest.raises(TypeError): NotifyFlowtriq(apikey="validkey", webhook_path=None) with pytest.raises(TypeError): NotifyFlowtriq(apikey="validkey", webhook_path=" ") # A webhook path consisting only of slashes strips to empty with pytest.raises(TypeError): NotifyFlowtriq(apikey="validkey", webhook_path="///") # Missing host is caught after super().__init__() with pytest.raises(TypeError): NotifyFlowtriq( apikey="validkey", webhook_path="hooks/abc123", host=None ) @mock.patch("requests.post") def test_plugin_flowtriq_send(mock_post): """NotifyFlowtriq() send path coverage.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = b"{}" return r # ------------------------------------------------------------------ # Successful send β€” 200 # ------------------------------------------------------------------ mock_post.return_value = _mk_resp(requests.codes.ok) obj = NotifyFlowtriq( apikey="ft_key_xxxx", webhook_path="hooks/abc123", host="flowtriq.com", ) assert obj.send(body="Test body", title="Test title") is True assert mock_post.call_count == 1 # Verify the POST landed at the right hostname call_url = mock_post.call_args[0][0] assert urlparse(call_url).hostname == "flowtriq.com" # Verify payload structure and severity mapping for INFO payload = json.loads(mock_post.call_args[1]["data"]) assert payload["body"] == "Test body" assert payload["title"] == "Test title" assert payload["severity"] == "info" assert payload["source"] == "apprise" # Verify the API key header headers = mock_post.call_args[1]["headers"] assert headers["X-API-Key"] == "ft_key_xxxx" assert headers["Content-Type"] == "application/json" mock_post.reset_mock() # ------------------------------------------------------------------ # 201, 202, 204 are also accepted # ------------------------------------------------------------------ for code in ( requests.codes.created, requests.codes.accepted, requests.codes.no_content, ): mock_post.return_value = _mk_resp(code) assert obj.send(body="body") is True mock_post.reset_mock() # ------------------------------------------------------------------ # Severity mapping for all notify types # ------------------------------------------------------------------ severity_cases = [ (NotifyType.INFO, "info"), (NotifyType.SUCCESS, "success"), (NotifyType.WARNING, "warning"), (NotifyType.FAILURE, "critical"), ] mock_post.return_value = _mk_resp() for notify_type, expected_severity in severity_cases: obj.send(body="body", notify_type=notify_type) payload = json.loads(mock_post.call_args[1]["data"]) assert payload["severity"] == expected_severity mock_post.reset_mock() # ------------------------------------------------------------------ # Port is included in the request URL when set # ------------------------------------------------------------------ obj_port = NotifyFlowtriq( apikey="key", webhook_path="hooks/test", host="myhost.example.com", port=8443, ) mock_post.return_value = _mk_resp() assert obj_port.send(body="body") is True call_url = mock_post.call_args[0][0] parsed = urlparse(call_url) assert parsed.hostname == "myhost.example.com" assert parsed.port == 8443 mock_post.reset_mock() # ------------------------------------------------------------------ # HTTP error response returns False # ------------------------------------------------------------------ mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert obj.send(body="body") is False mock_post.reset_mock() # ------------------------------------------------------------------ # Unknown HTTP error code (no entry in lookup table) returns False # ------------------------------------------------------------------ mock_post.return_value = _mk_resp(999) assert obj.send(body="body") is False mock_post.reset_mock() # ------------------------------------------------------------------ # requests.RequestException returns False # ------------------------------------------------------------------ mock_post.side_effect = requests.RequestException("boom") assert obj.send(body="body") is False @mock.patch("requests.post") def test_plugin_flowtriq_url_parsing(mock_post): """NotifyFlowtriq() URL round-trip and parse_url edge cases.""" # Round-trip: parse_url(url()) must recover the same object obj = NotifyFlowtriq( apikey="mykey", webhook_path="hooks/abc123", host="flowtriq.com", ) result = NotifyFlowtriq.parse_url(obj.url()) assert result is not None obj2 = NotifyFlowtriq(**result) assert obj.url_identifier == obj2.url_identifier # Round-trip with non-default port obj_port = NotifyFlowtriq( apikey="mykey", webhook_path="api/v1/wh", host="myhost", port=9000, ) result = NotifyFlowtriq.parse_url(obj_port.url()) assert result is not None obj_port2 = NotifyFlowtriq(**result) assert obj_port.url_identifier == obj_port2.url_identifier # url_identifier via URL parse: flowtriq:// is insecure β†’ port fallback 80 obj_insecure = NotifyFlowtriq.parse_url("flowtriq://key@h/hooks/x") assert obj_insecure is not None uid_ins = NotifyFlowtriq(**obj_insecure).url_identifier assert uid_ins[0] == "flowtriq" assert uid_ins[1] == "h" assert uid_ins[2] == 80 # flowtriqs:// is secure β†’ port fallback 443 obj_secure = NotifyFlowtriq.parse_url("flowtriqs://key@h/hooks/x") assert obj_secure is not None uid_sec = NotifyFlowtriq(**obj_secure).url_identifier assert uid_sec[0] == "flowtriqs" assert uid_sec[1] == "h" assert uid_sec[2] == 443 # parse_url with no user field β†’ apikey is None result = NotifyFlowtriq.parse_url("flowtriq://hostname/hooks/abc123") assert result is not None assert result["apikey"] is None # parse_url with empty path β†’ webhook_path is None result = NotifyFlowtriq.parse_url("flowtriq://key@hostname") assert result is not None assert not result.get("webhook_path") # parse_url returns None for an unparseable URL result = NotifyFlowtriq.parse_url("flowtriq://:@/") assert result is None # privacy_url hides the API key obj = NotifyFlowtriq( apikey="myapikey", webhook_path="hooks/abc123", host="hostname", ) priv = obj.url(privacy=True) assert "myapikey" not in priv assert "m...y" in priv @mock.patch("requests.post") def test_plugin_flowtriq_apprise_integration(mock_post): """NotifyFlowtriq() Apprise integration.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"{}" app = Apprise() assert app.add("flowtriq://mykey@flowtriq.com/hooks/abc123") is True assert app.notify(title="Title", body="Body") is True assert mock_post.call_count == 1 caronc-apprise-182f859/tests/test_plugin_fluxer.py000066400000000000000000001223551524161175200223620ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations from datetime import datetime, timedelta, timezone from json import loads # Disable logging for a cleaner testing output import logging import os from random import choice from string import ascii_uppercase as str_alpha, digits as str_num from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType from apprise.common import OverflowMode from apprise.plugins.fluxer import NotifyFluxer logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") def _tokens() -> tuple[str, str]: """Return tokens that satisfy Fluxer validation.""" # webhook_id: digits, >= 10 # webhook_token: [A-Za-z0-9_-], >= 16 return ("0" * 10, "B" * 40) # Our Testing URLs apprise_url_tests = ( ( "fluxer://", { "instance": TypeError, }, ), # An invalid url ( "fluxer://:@/", { "instance": TypeError, }, ), # No webhook_token specified ( "fluxer://%s" % ("0" * 10), { "instance": TypeError, }, ), # Provide both a webhook id and a webhook token ( "fluxer://{}/{}".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), # Provide a temporary username ( "fluxer://l2g@{}/{}".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), # Mode set to private, but no hostname was fluxers; we toggle to cloud mode ( "fluxer://api.fluxer.app/{}/{}?mode=private".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), # test image= field ( "fluxer://{}/{}?format=markdown&footer=Yes&image=Yes&ping=Joe".format( *_tokens() ), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, # don't include an image by default "include_image": False, }, ), ( "fluxer://{}/{}?format=markdown&footer=Yes&image=No&fields=no".format( *_tokens() ), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( "fluxer://jack@{}/{}?format=markdown&footer=Yes&image=Yes".format( *_tokens() ), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( "fluxer://jack@{}/{}?mode=private&host=example.ca".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( "fluxer://{}/{}?mode=private&host=example.ca&name=jack".format( *_tokens() ), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( "fluxer://example.ca:123/{}/{}".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( # Invalid Mode "fluxer://jack@{}/{}?mode=invalid".format(*_tokens()), { "instance": TypeError, }, ), ( "https://api.fluxer.app/webhooks/{}/{}".format(*_tokens()), { # Native URL Support "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( "https://api.fluxer.app/v1/webhooks/{}/{}?footer=yes".format( *_tokens() ), { # Native URL Support with arguments "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, "privacy_url": "fluxer://0...0/B...B/", }, ), ( "https://api.fluxer.app/v1/webhooks/{}/{}?footer=yes&botname=joe".format( *_tokens() ), { # Native URL Support with arguments "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, "privacy_url": "fluxer://joe@0...0/B...B/", }, ), ( "fluxer://{}/{}?format=markdown&avatar=No&footer=No".format( *_tokens() ), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( "fluxer://{}/{}?flags=1".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( "fluxer://{}/{}?flags=-1".format(*_tokens()), { # invalid flags specified (variation 1) "instance": TypeError, }, ), ( "fluxer://{}/{}?flags=invalid".format(*_tokens()), { # invalid flags specified (variation 2) "instance": TypeError, }, ), # different format support ( "fluxer://{}/{}?format=markdown".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), # Thread ID (forces markdown mode) ( "fluxer://{}/{}?format=markdown&thread=abc123".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), ( "fluxer://{}/{}?format=text".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), # Test with href (title link) ( "fluxer://{}/{}?hmarkdown=true&ref=http://localhost".format( *_tokens() ), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), # Test with url (title link) - Alias of href ( "fluxer://{}/{}?markdown=true&url=http://localhost".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), # Test with avatar URL ( "fluxer://{}/{}?avatar_url=http://localhost/test.jpg".format( *_tokens() ), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, }, ), # Test without image set ( "fluxer://{}/{}".format(*_tokens()), { "instance": NotifyFluxer, "requests_response_code": requests.codes.no_content, # don't include an image by default "include_image": False, }, ), ( "fluxer://{}/{}".format(*_tokens()), { "instance": NotifyFluxer, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "fluxer://{}/{}".format(*_tokens()), { "instance": NotifyFluxer, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "fluxer://{}/{}".format(*_tokens()), { "instance": NotifyFluxer, # Throws a series of i/o exceptions with this flag set and tests # that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_fluxer_urls() -> None: """NotifyFluxer() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_fluxer_notifications(mock_post: mock.MagicMock) -> None: """NotifyFluxer() Notifications/Ping Support.""" webhook_id, webhook_token = _tokens() # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" mock_post.return_value.headers = {} # Test our header parsing when not lead with a header body = """ # Heading @everyone and @admin, wake and meet our new user <@123>; <@&456>" """ results = NotifyFluxer.parse_url( f"fluxer://{webhook_id}/{webhook_token}/?format=markdown" ) assert isinstance(results, dict) assert results["user"] is None assert results["webhook_id"] == webhook_id assert results["webhook_token"] == webhook_token assert results["schema"] == "fluxer" instance = NotifyFluxer(**results) assert isinstance(instance, NotifyFluxer) assert instance.send(body=body) is True assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert ( details[0][0] == f"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}" ) payload = loads(details[1]["data"]) assert isinstance(payload, dict) assert "allow_mentions" in payload assert set(payload["allow_mentions"]["users"]) == {"123"} assert set(payload["allow_mentions"]["roles"]) == {"456"} assert set(payload["allow_mentions"]["parse"]) == {"everyone", "admin"} assert payload["content"].startswith("πŸ‘‰ ") # Reset our object mock_post.reset_mock() results = NotifyFluxer.parse_url( f"fluxer://{webhook_id}/{webhook_token}/?format=text" ) instance = NotifyFluxer(**results) assert isinstance(instance, NotifyFluxer) assert instance.send(body=body) is True payload = loads(mock_post.call_args_list[0][1]["data"]) # text mode does not parse mentions from body assert "allow_mentions" not in payload # Reset our object mock_post.reset_mock() body = " " results = NotifyFluxer.parse_url( # & -> %26 for role otherwise & separates our URL from further parsing f"fluxer://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>" ) instance = NotifyFluxer(**results) assert isinstance(instance, NotifyFluxer) assert instance.send(body=body) is True payload = loads(mock_post.call_args_list[0][1]["data"]) assert "allow_mentions" in payload assert set(payload["allow_mentions"]["users"]) == {"321"} assert set(payload["allow_mentions"]["roles"]) == {"654"} assert set(payload["allow_mentions"]["parse"]) == {"joe"} assert "<@321>" in payload["content"] assert "<@&654>" in payload["content"] assert "@joe" in payload["content"] # Reset our object mock_post.reset_mock() # Test our body in text mode, with ping=set body = """ # Heading @everyone and @admin, wake and meet our new user <@123>; <@&456>" """ results = NotifyFluxer.parse_url( # & -> %26 for role otherwise & separates our URL from further parsing f"fluxer://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>" "&format=text" ) instance = NotifyFluxer(**results) assert isinstance(instance, NotifyFluxer) assert instance.send(body=body) is True payload = loads(mock_post.call_args_list[0][1]["data"]) # Payload only includes elements on ping= line with text mode assert "allow_mentions" in payload assert set(payload["allow_mentions"]["users"]) == {"321"} assert set(payload["allow_mentions"]["roles"]) == {"654"} assert set(payload["allow_mentions"]["parse"]) == {"joe"} @mock.patch("requests.post") @mock.patch("time.sleep") def test_plugin_fluxer_429( mock_sleep: mock.MagicMock, mock_post: mock.MagicMock, ) -> None: """ NotifyFluxer() 429 handling Focus: Fluxer-specific HTTP 429 + Retry-After handling (recursive retry), including header parsing edge cases and retry exhaustion. """ # Prevent throttling side effects mock_sleep.return_value = True webhook_id, webhook_token = _tokens() # Basic construction checks (keep these, they match plugin validation) with pytest.raises(TypeError): NotifyFluxer(webhook_id=None, webhook_token=webhook_token) with pytest.raises(TypeError): NotifyFluxer(webhook_id=" ", webhook_token=webhook_token) with pytest.raises(TypeError): NotifyFluxer(webhook_id=webhook_id, webhook_token=None) with pytest.raises(TypeError): NotifyFluxer(webhook_id=webhook_id, webhook_token=" ") obj = NotifyFluxer( webhook_id=webhook_id, webhook_token=webhook_token, footer=True, include_image=True, ) # url() must always return a string assert isinstance(obj.url(), str) # Helper to build responses def _resp(code: int, headers: dict[str, str] | None = None) -> mock.Mock: r = mock.Mock() r.status_code = code r.content = b"" r.headers = headers or {} return r # Retry-After header missing -> defaults to default_delay_sec (1.0) mock_post.reset_mock() mock_sleep.reset_mock() mock_post.side_effect = [ _resp(requests.codes.too_many_requests, {}), _resp(requests.codes.no_content, {}), ] assert obj.send(body="test") is True assert mock_post.call_count == 2 assert mock_sleep.call_count == 1 assert mock_sleep.call_args_list[-1][0][0] == pytest.approx( 1.0, rel=0, abs=0.05 ) # Retry-After invalid -> falls back to 1.0 mock_post.reset_mock() mock_sleep.reset_mock() mock_post.side_effect = [ _resp(requests.codes.too_many_requests, {"Retry-After": "garbage"}), _resp(requests.codes.no_content, {}), ] assert obj.send(body="test") is True assert mock_post.call_count == 2 assert mock_sleep.call_count >= 1 assert mock_sleep.call_args_list[-1][0][0] == pytest.approx( 1.0, rel=0, abs=0.05 ) # Retry-After < 1.0 -> max(1.0, value) enforces 1.0 mock_post.reset_mock() mock_sleep.reset_mock() mock_post.side_effect = [ _resp(requests.codes.too_many_requests, {"Retry-After": "0.25"}), _resp(requests.codes.no_content, {}), ] assert obj.send(body="test") is True assert mock_post.call_count == 2 assert mock_sleep.call_count >= 1 assert mock_sleep.call_args_list[-1][0][0] == pytest.approx( 1.0, rel=0, abs=0.05 ) # Retry-After valid integer -> sleeps that many seconds mock_post.reset_mock() mock_sleep.reset_mock() mock_post.side_effect = [ _resp(requests.codes.too_many_requests, {"Retry-After": "2"}), _resp(requests.codes.no_content, {}), ] assert obj.send(body="test") is True assert mock_post.call_count == 2 assert mock_sleep.call_count >= 1 assert mock_sleep.call_args_list[-1][0][0] == pytest.approx( 2.0, rel=0, abs=0.05 ) # Retry exhaustion: default send() retries once. # If we get 429 twice, second one is not retried and send fails. mock_post.reset_mock() mock_sleep.reset_mock() mock_post.side_effect = [ _resp(requests.codes.too_many_requests, {"Retry-After": "1"}), _resp(requests.codes.too_many_requests, {"Retry-After": "1"}), ] assert obj.send(body="test") is False assert mock_post.call_count == 2 assert mock_sleep.call_count >= 1 mock_sleep.reset_mock() mock_post.reset_mock() response = mock.Mock() response.status_code = requests.codes.no_content response.content = b"" response.headers = {} mock_post.return_value = response mock_post.side_effect = None # Force the 'now <= ratelimit_reset' path to compute a wait. obj = NotifyFluxer(webhook_id=webhook_id, webhook_token=webhook_token) # Force the rate-limit gate to run obj.ratelimit_remaining = 0.0 now = datetime.now(timezone.utc).replace(tzinfo=None) obj.ratelimit_reset = now - timedelta(seconds=2) with mock.patch.object(obj, "throttle") as m_throttle: assert obj.send(body="test") is True # We expect throttle(wait=~2.0) to be called. assert m_throttle.call_count >= 1 wait = m_throttle.call_args_list[-1][1].get("wait") assert wait is None # Call count assert mock_post.call_count == 1 # Reset our object mock_post.reset_mock() # Force the 'now < ratelimit_reset' path to compute a wait. obj = NotifyFluxer(webhook_id=webhook_id, webhook_token=webhook_token) # Force the rate-limit gate to run obj.ratelimit_remaining = 0.0 # Bracket the call with timestamps so the assertion is # architecture-independent (slow ppc64le build machines can add # hundreds of ms of overhead between captures of datetime.now()). t_before = datetime.now(timezone.utc).replace(tzinfo=None) obj.ratelimit_reset = t_before + timedelta(seconds=2) with mock.patch.object(obj, "throttle") as m_throttle: assert obj.send(body="test") is True t_after = datetime.now(timezone.utc).replace(tzinfo=None) # We expect throttle(wait=~2.0) to be called. assert m_throttle.call_count >= 1 wait = m_throttle.call_args_list[-1][1].get("wait") assert wait is not None # wait = ratelimit_reset - now_inside_plugin # The plugin's 'now' is somewhere in [t_before, t_after], so: # lower bound: ratelimit_reset - t_after = 2.0 - elapsed # upper bound: ratelimit_reset - t_before = 2.0 elapsed = (t_after - t_before).total_seconds() assert (2.0 - elapsed) <= wait <= 2.0 # Call count assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_fluxer_general( mock_post: mock.MagicMock, ) -> None: """NotifyFluxer() General Checks.""" webhook_id, webhook_token = _tokens() # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = "" # Invalid webhook id with pytest.raises(TypeError): NotifyFluxer(webhook_id=None, webhook_token=webhook_token) # Invalid webhook id (whitespace) with pytest.raises(TypeError): NotifyFluxer(webhook_id=" ", webhook_token=webhook_token) # Invalid webhook token with pytest.raises(TypeError): NotifyFluxer(webhook_id=webhook_id, webhook_token=None) # Private mode but no hostname provided with pytest.raises(TypeError): NotifyFluxer( webhook_id=webhook_id, webhook_token=webhook_token, mode="private", ) obj = NotifyFluxer( webhook_id=webhook_id, webhook_token=webhook_token, footer=True, include_image=True, ) # Simple Markdown Single line of text test_markdown = "body" desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True assert len(results) == 0 assert desc == "body" # Test our header parsing when not lead with a header test_markdown = """ A section of text that has no header at the top. It also has a hash tag # <- in the middle of a string. ## Heading 1 body # Heading 2 more content on multi-lines """ desc, results = obj.extract_markdown_sections(test_markdown) # we have a description assert isinstance(desc, str) is True assert desc.startswith("A section of text that has no header at the top.") assert desc.endswith("string.") assert isinstance(results, list) is True assert len(results) == 2 assert results[0]["name"] == "Heading 1" assert results[0]["value"] == "```md\nbody\n```" assert results[1]["name"] == "Heading 2" assert ( results[1]["value"] == "```md\nmore content\n on multi-lines\n```" ) # Test our header parsing test_markdown = ( "## Heading one\nbody body\n\n" + "# Heading 2 ##\n\nTest\n\n" + "more content\n" + "even more content \t\r\n\n\n" + "# Heading 3 ##\n\n\n" + "normal content\n" + "# heading 4\n" + "#### Heading 5" ) desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True # No desc details filled out assert isinstance(desc, str) is True assert not desc # We should have 5 sections (since there are 5 headers identified above) assert len(results) == 5 assert results[0]["name"] == "Heading one" assert results[0]["value"] == "```md\nbody body\n```" assert results[1]["name"] == "Heading 2" assert ( results[1]["value"] == "```md\nTest\n\nmore content\neven more content\n```" ) assert results[2]["name"] == "Heading 3" assert results[2]["value"] == "```md\nnormal content\n```" assert results[3]["name"] == "heading 4" assert results[3]["value"] == "```\n```" assert results[4]["name"] == "Heading 5" assert results[4]["value"] == "```\n```" # Create an apprise instance a = Apprise() # Our processing is slightly different when we aren't using markdown # as we do not pre-parse content during our notifications assert ( a.add( f"fluxer://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) # This call includes an image with its payload: orig_fluxer_max_fields = NotifyFluxer.fluxer_max_fields try: NotifyFluxer.fluxer_max_fields = 1 assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) finally: # Restore the original value to avoid impacting other tests NotifyFluxer.fluxer_max_fields = orig_fluxer_max_fields # Throw an exception on the forth call to requests.post() # This allows to test our batch field processing response = mock.Mock() response.content = b"" response.status_code = requests.codes.ok response.headers = {} mock_post.return_value = response mock_post.side_effect = [ response, response, response, requests.RequestException(), ] # Test our markdown obj = Apprise.instantiate( f"fluxer://{webhook_id}/{webhook_token}/?format=markdown" ) assert isinstance(obj, NotifyFluxer) # Force batching so we actually hit the 4th post() call orig_fluxer_max_fields = NotifyFluxer.fluxer_max_fields try: NotifyFluxer.fluxer_max_fields = 1 assert ( obj.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO ) is False ) finally: NotifyFluxer.fluxer_max_fields = orig_fluxer_max_fields mock_post.side_effect = None # Empty String desc, results = obj.extract_markdown_sections("") assert isinstance(results, list) is True assert len(results) == 0 # No desc details filled out assert isinstance(desc, str) is True assert not desc # String without Heading test_markdown = ( "Just a string without any header entries.\n" + "A second line" ) desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True assert len(results) == 0 # No desc details filled out assert isinstance(desc, str) is True assert ( desc == "Just a string without any header entries.\n" + "A second line" ) # Use our test markdown string during a notification assert ( obj.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO ) is True ) # Create an apprise instance a = Apprise() # Our processing is slightly different when we aren't using markdown # as we do not pre-parse content during our notifications assert ( a.add( f"fluxer://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) # This call includes an image with it's payload: assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.MARKDOWN, ) is True ) # Toggle our logo availability a.asset.image_url_logo = None assert ( a.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Create an apprise instance a = Apprise() # Reset our object mock_post.reset_mock() # Test our threading assert ( a.add(f"fluxer://{webhook_id}/{webhook_token}/?thread=12345") is True ) # This call includes an image with it's payload: assert a.notify(body="test", title="title") is True assert mock_post.call_count == 1 response = mock_post.call_args_list[0][1] assert "params" in response assert response["params"].get("thread_id") == "12345" @mock.patch("requests.post") def test_plugin_fluxer_overflow(mock_post): """NotifyFluxer() Overflow Checks.""" webhook_id, webhook_token = _tokens() # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Some variables we use to control the data we work with body_len = 8000 title_len = 1024 # Number of characters per line row = 24 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num + " ") for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) results = NotifyFluxer.parse_url( f"fluxer://{webhook_id}/{webhook_token}/?overflow=split" ) instance = NotifyFluxer(**results) assert isinstance(instance, NotifyFluxer) results = instance._apply_overflow( body, title=title, overflow=OverflowMode.SPLIT ) # Ensure we never exceed 2000 characters for entry in results: assert len(entry["title"]) <= instance.title_maxlen assert len(entry["title"]) + len(entry["body"]) <= instance.body_maxlen @mock.patch("requests.post") def test_plugin_fluxer_markdown_extra(mock_post): """NotifyFluxer() Markdown Extra Checks.""" webhook_id, webhook_token = _tokens() # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" mock_post.return_value.headers = {} # Reset our apprise object a = Apprise() assert ( a.add( f"fluxer://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) test_markdown = "[green-blue](https://google.com)" # This call includes an image with it's payload: assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) assert ( a.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) @mock.patch("requests.post") def test_plugin_fluxer_markdown_attachments( mock_post: mock.MagicMock, ) -> None: # Prepare our tokens webhook_id, webhook_token = _tokens() # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" # Prepare a bad response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error bad_response.content = b"" # Prepare Mock return object mock_post.return_value = response # Test our markdown obj = Apprise.instantiate( f"fluxer://{webhook_id}/{webhook_token}/?format=markdown" ) # attach our content attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}" ) assert ( mock_post.call_args_list[1][0][0] == f"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}" ) # Reset our object mock_post.reset_mock() # Test notifications with mentions and attachments in it assert ( obj.notify( body="Say hello to <@1234>!", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}" ) assert ( mock_post.call_args_list[1][0][0] == f"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}" ) # Reset our object mock_post.reset_mock() # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # update our attachment to be valid attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) mock_post.return_value = None # Throw an exception on the first call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.side_effect = [side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # Throw an exception on the second call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.side_effect = [response, side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # handle a bad response bad_response = mock.Mock() bad_response.content = b"" bad_response.headers = {} bad_response.status_code = requests.codes.internal_server_error mock_post.side_effect = [response, bad_response] # We'll fail now because of an internal exception assert obj.send(body="test", attach=attach) is False @mock.patch("requests.post") def test_plugin_fluxer_markdown_fields_batches_exactly(mock_post): webhook_id, webhook_token = _tokens() response = mock.Mock() response.status_code = requests.codes.ok response.content = "" response.headers = {} mock_post.return_value = response # Force tiny batches NotifyFluxer.fluxer_max_fields = 1 body = "# H1\nv1\n# H2\nv2\n# H3\nv3\n" obj = Apprise.instantiate( f"fluxer://{webhook_id}/{webhook_token}/?format=markdown&fields=yes" ) assert isinstance(obj, NotifyFluxer) assert obj.send(body=body) is True # H1, H2, H3 => 3 fields => 3 posts (since max_fields=1) assert mock_post.call_count == 3 NotifyFluxer.fluxer_max_fields = 10 @mock.patch("requests.post") def test_plugin_fluxer_markdown_ping_is_additive( mock_post: mock.MagicMock, ) -> None: webhook_id, webhook_token = _tokens() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" mock_post.return_value.headers = {} body = "Body pings <@111> and <@&222> @everyone" results = NotifyFluxer.parse_url( f"fluxer://{webhook_id}/{webhook_token}/" "?format=markdown" "&ping=<@333>,<@%26444>,@joe" ) obj = NotifyFluxer(**results) assert obj.send(body=body) is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert "allow_mentions" in payload # union assert set(payload["allow_mentions"]["users"]) == {"111", "333"} assert set(payload["allow_mentions"]["roles"]) == {"222", "444"} assert set(payload["allow_mentions"]["parse"]) == {"everyone", "joe"} assert payload["content"].startswith("πŸ‘‰ ") @mock.patch("requests.post") def test_plugin_fluxer_html_ping_is_exclusive( mock_post: mock.MagicMock, ) -> None: webhook_id, webhook_token = _tokens() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok body = "Body includes <@111> <@&222> @everyone but must be ignored" results = NotifyFluxer.parse_url( f"fluxer://{webhook_id}/{webhook_token}/" "?format=html" "&ping=<@333>,<@%26444>,@joe" ) obj = NotifyFluxer(**results) assert obj.send(body=body) is True payload = loads(mock_post.call_args_list[0][1]["data"]) assert set(payload["allow_mentions"]["users"]) == {"333"} assert set(payload["allow_mentions"]["roles"]) == {"444"} assert set(payload["allow_mentions"]["parse"]) == {"joe"} @mock.patch("requests.post") def test_plugin_fluxer_markdown_no_mentions_has_no_allow_mentions( mock_post: mock.MagicMock, ) -> None: webhook_id, webhook_token = _tokens() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" mock_post.return_value.headers = {} results = NotifyFluxer.parse_url( f"fluxer://{webhook_id}/{webhook_token}/?format=markdown" ) obj = NotifyFluxer(**results) assert obj.send(body="Hello world") is True payload = loads(mock_post.call_args_list[0][1]["data"]) assert "allow_mentions" not in payload assert "content" not in payload @mock.patch("requests.post") def test_plugin_fluxer_markdown_single_field_posts_once( mock_post: mock.MagicMock, ) -> None: webhook_id, webhook_token = _tokens() response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" response.headers = {} mock_post.return_value = response NotifyFluxer.fluxer_max_fields = 10 body = "# H1\nv1\n" obj = Apprise.instantiate( f"fluxer://{webhook_id}/{webhook_token}/?format=markdown&fields=yes" ) assert isinstance(obj, NotifyFluxer) assert obj.send(body=body) is True assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_fluxer_threading(mock_post: mock.MagicMock) -> None: """Threading passes thread_id parameter.""" webhook_id, webhook_token = _tokens() response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" response.headers = {} mock_post.return_value = response a = Apprise() assert ( a.add( f"fluxer://{webhook_id}/{webhook_token}/" "?thread=12345&thread_name=abc" ) is True ) assert a.notify(body="test", title="title") is True kwargs = mock_post.call_args_list[0][1] assert "params" in kwargs assert kwargs["params"].get("thread_id") == "12345" payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload.get("thread_name") == "abc" assert "thread_name=abc" in a[0].url() assert "thread=12345" in a[0].url() @mock.patch("requests.post") @mock.patch("time.sleep") @mock.patch("builtins.open") def test_plugin_fluxer_429_attachment_closes_edge_cases( mock_open: mock.MagicMock, mock_sleep: mock.MagicMock, mock_post: mock.MagicMock, ) -> None: """ Cover 429 error during attachment upload triggers the pre-recursion close loop, and close() exceptions are suppressed. """ mock_sleep.return_value = True webhook_id, webhook_token = _tokens() obj = NotifyFluxer(webhook_id=webhook_id, webhook_token=webhook_token) attach_path = os.path.join(TEST_VAR_DIR, "apprise-test.png") attach = AppriseAttachment(attach_path) class _BadCloseIO: def __init__(self) -> None: self.closed = False def close(self) -> None: self.closed = True raise OSError("boom") bad = _BadCloseIO() mock_open.return_value = bad def _resp(code: int, headers: dict[str, str] | None = None) -> mock.Mock: r = mock.Mock() r.status_code = code r.content = b"" r.headers = headers or {} return r # 3 posts: # (1) initial message post succeeds # (2) attachment post 429 triggers file close-before-recursion # (3) recursive retry succeeds (note: retry is non-attachment in baseline) mock_post.side_effect = [ _resp(requests.codes.no_content, {}), _resp(requests.codes.too_many_requests, {"Retry-After": "1"}), _resp(requests.codes.no_content, {}), ] assert obj.send(body="test", attach=attach) is True assert bad.closed is True mock_post.reset_mock() mock_open.reset_mock() mock_sleep.reset_mock() mock_sleep.return_value = True webhook_id, webhook_token = _tokens() obj = NotifyFluxer(webhook_id=webhook_id, webhook_token=webhook_token) attach_path = os.path.join(TEST_VAR_DIR, "apprise-test.png") attach = AppriseAttachment(attach_path) class _GoodCloseIO: def __init__(self) -> None: self.closed = False def close(self) -> None: self.closed = True good = _GoodCloseIO() mock_open.return_value = good def _resp(code: int, headers: dict[str, str] | None = None) -> mock.Mock: r = mock.Mock() r.status_code = code r.content = b"" r.headers = headers or {} return r mock_post.side_effect = [ _resp(requests.codes.no_content, {}), _resp(requests.codes.too_many_requests, {"Retry-After": "1"}), _resp(requests.codes.no_content, {}), ] assert obj.send(body="test", attach=attach) is True assert good.closed is True @mock.patch("requests.post") def test_plugin_fluxer_attach_memory(mock_post: mock.MagicMock) -> None: """Regression: AttachMemory must be sendable without OSError.""" from apprise.attachment.memory import AttachMemory webhook_id, webhook_token = _tokens() response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = Apprise.instantiate(f"fluxer://{webhook_id}/{webhook_token}/") mem = AttachMemory( content=b"

    Test

    ", name="report.html", mimetype="text/html", ) assert obj.notify(body="Test", attach=mem) is True assert mock_post.call_count >= 1 @mock.patch("requests.post") def test_plugin_fluxer_html_to_markdown_format(mock_post): """NotifyFluxer(): HTML body is converted to Markdown.""" webhook_id, webhook_token = _tokens() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = "" mock_post.return_value.headers = {} # Instantiate a Fluxer plugin in Markdown mode with fields # disabled so the body lands in embeds[0]["description"] directly obj = Apprise.instantiate( f"fluxer://{webhook_id}/{webhook_token}/?format=markdown&fields=no" ) aobj = Apprise() aobj.add(obj) # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Fluxer assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive in the embed description as Markdown, # not as stripped plain text payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["embeds"][0]["description"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_fortysixelks.py000066400000000000000000000112201524161175200236070ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyType from apprise.plugins.fortysixelks import Notify46Elks logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ("46elks://", False), ("46elks://user@/", False), ("46elks://:pass@/", False), ( "46elks://user:pass@/", { "instance": Notify46Elks, # no target was specified "notify_response": False, }, ), ( "46elks://user:pass@+15551234556", { "instance": Notify46Elks, }, ), ( "46elks://user:pass@+15551234567/+46701234534?from=Acme", { "instance": Notify46Elks, }, ), # Support elks:// too! ( "elks://user:pass@+15551234123/", { "instance": Notify46Elks, }, ), # Privacy mode redacts password ( "46elks://user:pass@+15551234512", { "privacy_url": "46elks://user:****@+15551234512", "instance": Notify46Elks, }, ), # invalid phone no ( "46elks://user:pass@Acme/234512", { "instance": Notify46Elks, "notify_response": False, }, ), # Native URL reversal ( ("https://user1:pass@api.46elks.com/a1/sms?to=+15551234511&from=Acme"), { "instance": Notify46Elks, "privacy_url": "46elks://user1:****@Acme/+15551234511", }, ), ( "46elks://user:pass@+15551234567", { "instance": Notify46Elks, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "46elks://user:pass@+15551234578", { "instance": Notify46Elks, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_46elks_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_46elks_edge_cases(mock_post): """Notify46Elks() Edge Cases.""" user = "user1" password = "pass123" phone = "+15551234591" response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response obj = Apprise.instantiate(f"46elks://{user}:{password}@{phone}") assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # We know there is 1 (valid) targets assert len(obj) == 1 # Test our call count assert mock_post.call_count == 1 # Test details = mock_post.call_args_list[0] headers = details[1]["headers"] assert headers["User-Agent"] == "Apprise" payload = details[1]["data"] assert payload["to"] == phone assert payload["from"] == phone assert payload["message"] == "title\r\nbody" # Verify our URL looks good assert obj.url().startswith(f"46elks://{user}:{password}@{phone}") caronc-apprise-182f859/tests/test_plugin_freemobile.py000066400000000000000000000060611524161175200231610ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.freemobile import NotifyFreeMobile logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "freemobile://", { "instance": TypeError, }, ), ( "freemobile://:@/", { "instance": TypeError, }, ), ( "freemobile://user@password", { # Minimum requirements met "instance": NotifyFreeMobile, }, ), ( "freemobile://?user=user&pass=password", { # Test ?user= and pass= "instance": NotifyFreeMobile, }, ), ( "freemobile://user@password", { "instance": NotifyFreeMobile, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "freemobile://user@password", { "instance": NotifyFreeMobile, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "freemobile://user@password", { "instance": NotifyFreeMobile, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_freemobile_urls(): """NotifyFreeMobile() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_glib.py000066400000000000000000000343161524161175200217710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import sys import types from unittest.mock import Mock, call from helpers import reload_plugin import pytest import apprise from apprise.plugins.glib import GLibUrgency, NotifyGLib # Disable logging output during testing logging.disable(logging.CRITICAL) @pytest.fixture def enabled_glib_environment(monkeypatch): """ Fully mocked GI/GLib/Gio/GdkPixbuf environment for local and CI. """ # Step 1: Fake gi and repository gi = types.ModuleType("gi") gi.require_version = Mock() fake_variant = Mock(name="Variant") fake_error = type("GLibError", (Exception,), {}) fake_pixbuf = Mock() fake_image = Mock() fake_pixbuf.new_from_file.return_value = fake_image fake_image.get_width.return_value = 100 fake_image.get_height.return_value = 100 fake_image.get_rowstride.return_value = 1 fake_image.get_has_alpha.return_value = False fake_image.get_bits_per_sample.return_value = 8 fake_image.get_n_channels.return_value = 1 fake_image.get_pixels.return_value = b"" gi.repository = types.SimpleNamespace( Gio=Mock(), GLib=types.SimpleNamespace(Variant=fake_variant, Error=fake_error), GdkPixbuf=types.SimpleNamespace(Pixbuf=fake_pixbuf), ) # Step 2: Inject into sys.modules sys.modules["gi"] = gi sys.modules["gi.repository"] = gi.repository # Step 3: Reload plugin with all mocks in place reload_plugin("glib") def test_plugin_glib_gdkpixbuf_attribute_error(monkeypatch): """Simulate AttributeError from importing GdkPixbuf""" # Create gi module gi = types.ModuleType("gi") # Create gi.repository mock, but DO NOT include GdkPixbuf gi.repository = types.SimpleNamespace( Gio=Mock(), GLib=types.SimpleNamespace( Variant=Mock(), Error=type("GLibError", (Exception,), {}) ), # GdkPixbuf missing entirely triggers AttributeError ) def fake_require_version(name, version): if name == "GdkPixbuf": # Simulate success in require_version return return gi.require_version = Mock(side_effect=fake_require_version) # Inject into sys.modules sys.modules["gi"] = gi sys.modules["gi.repository"] = gi.repository # Trigger the plugin reload with our patched environment reload_plugin("glib") from apprise.plugins import glib as plugin_glib assert plugin_glib.NOTIFY_GLIB_IMAGE_SUPPORT is False def test_plugin_glib_basic_notify(enabled_glib_environment): """Basic notification path""" obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert isinstance(obj, NotifyGLib) assert obj.notify("body", title="title") is True def test_plugin_glib_url_includes_coordinates(enabled_glib_environment): """Test that x/y coordinates appear in the rendered URL.""" obj = apprise.Apprise.instantiate( "glib://_/?x=7&y=9", suppress_exceptions=False ) url = obj.url(privacy=False) assert "x=7" in url assert "y=9" in url def test_plugin_glib_zero_coordinate_round_trip(enabled_glib_environment): """ Coordinate 0 is a valid screen edge (send() emits it via an `is None` guard); url() must preserve it so the configuration survives a save/reload cycle instead of being silently dropped. """ obj = apprise.Apprise.instantiate( "glib://_/?x=0&y=0", suppress_exceptions=False ) assert isinstance(obj, NotifyGLib) assert obj.x_axis == 0 assert obj.y_axis == 0 url = obj.url() assert "x=0" in url assert "y=0" in url # The rendered URL must round-trip back to an identical object rather # than dropping x=0 (which previously also raised a TypeError on reload # whenever the surviving axis kept the guard truthy). reloaded = apprise.Apprise.instantiate(url, suppress_exceptions=False) assert isinstance(reloaded, NotifyGLib) assert reloaded.x_axis == 0 assert reloaded.y_axis == 0 # A single zero axis must also survive without dropping the other value. obj = apprise.Apprise.instantiate( "glib://_/?x=0&y=5", suppress_exceptions=False ) reloaded = apprise.Apprise.instantiate( obj.url(), suppress_exceptions=False ) assert reloaded.x_axis == 0 assert reloaded.y_axis == 5 def test_plugin_glib_icon_fails_gracefully(mocker, enabled_glib_environment): """Simulate image load failure""" import gi gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = AttributeError( "fail" ) obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) spy = mocker.spy(obj, "logger") assert obj.notify("msg", title="t") is True assert any( "Could not load notification icon" in str(x) for x in spy.warning.call_args_list ) def test_plugin_glib_send_raises_glib_error(mocker, enabled_glib_environment): """Simulate GLib.Error in DBusProxy creation""" import gi gi.repository.Gio.DBusProxy.new_for_bus_sync.side_effect = ( gi.repository.GLib.Error("fail") ) obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert obj.notify("fail test") is False def test_plugin_glib_send_raises_generic(mocker, enabled_glib_environment): """Simulate generic error in gio_iface.Notify()""" # Re: https://github.com/caronc/apprise/issues/1383 # This test validates that the NotifyGLib plugin correctly handles a # generic exception raised by the `Notify()` method call on a mocked # DBus interface. However, it is only meaningful in environments that: # # 1. Do NOT have PyGObject (`gi`) installed, OR # 2. Have `gi`, but without introspection or live bindings activated. # # When PyGObject is installed and active, the `gi.repository` namespace # becomes populated by introspected C-based objects that do not behave # like regular Python functions. This causes mock patching via # `mocker.patch("gi.repository.Gio.DBusProxy.new_for_bus_sync")` to # silently fail or be ignored, as Python's mocking machinery cannot # reliably override these introspected symbols. # # This test exists to ensure coverage of legacy or minimal environments # where Apprise's GLib support can still be used through soft mocks, # such as CI/CD pipelines or headless test setups where PyGObject is # absent or stubbed (as done via `enabled_glib_environment`). # # Note: In production environments with active PyGObject, exception # handling is already tested via `GLib.Error` branches or during actual # usage of `NotifyGLib.send()`. This test supplements that by simulating # the rare fallback case of a non-GLib-related exception during Notify(). import gi if hasattr(gi, "repository"): pytest.skip( "pygobject introspection active, test won't behave as expected" ) fake_iface = Mock() fake_iface.Notify.side_effect = RuntimeError("boom") mocker.patch( "gi.repository.Gio.DBusProxy.new_for_bus_sync", return_value=fake_iface, ) obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) logger = mocker.spy(obj, "logger") assert obj.notify("boom", title="fail") is False logger.warning.assert_called_with("Failed to send GLib/Gio notification.") def test_plugin_glib_disabled(mocker, enabled_glib_environment): """Test disabled plugin returns False on notify()""" obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) obj.enabled = False assert obj.notify("x") is False def test_plugin_glib_invalid_coords(): """Invalid x/y coordinates cause TypeError""" with pytest.raises(TypeError): NotifyGLib(x_axis="bad", y_axis="1") with pytest.raises(TypeError): NotifyGLib(x_axis="1", y_axis="bad") def test_plugin_glib_urgency_parsing(): """Urgency variants map correctly""" assert NotifyGLib(urgency="high").urgency == GLibUrgency.HIGH assert NotifyGLib(urgency="invalid").urgency == GLibUrgency.NORMAL assert NotifyGLib(urgency="2").urgency == GLibUrgency.HIGH assert NotifyGLib(urgency=0).urgency == GLibUrgency.LOW def test_plugin_glib_parse_url_fields(): url = "glib://_/?x=5&y=5&image=no&priority=high" result = NotifyGLib.parse_url(url) assert result["x_axis"] == "5" assert result["y_axis"] == "5" assert result["include_image"] is False assert result["urgency"] == "high" def test_plugin_glib_xy_axis_applied_to_variant(enabled_glib_environment): """Ensure x/y values are added to GLib.Variant payload.""" obj = apprise.Apprise.instantiate( "glib://_/?x=5&y=10", suppress_exceptions=False ) # Patch GLib.Variant to track calls import gi spy_variant = Mock(wraps=gi.repository.GLib.Variant) gi.repository.GLib.Variant = spy_variant assert obj.notify("Test with coords", title="xy") is True # Check x and y were added to meta_payload assert call("i", 5) in spy_variant.mock_calls assert call("i", 10) in spy_variant.mock_calls def test_plugin_glib_no_image_support(monkeypatch, enabled_glib_environment): """Simulate GdkPixbuf unavailable""" monkeypatch.setattr( "apprise.plugins.glib.NOTIFY_GLIB_IMAGE_SUPPORT", False ) obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert obj.notify("no image") is True def test_plugin_glib_url_redaction(enabled_glib_environment): """url() privacy mode redacts safely""" obj = apprise.Apprise.instantiate( "glib://_/?image=no&urgency=high", suppress_exceptions=False ) url = obj.url(privacy=True) assert "image=" in url assert "urgency=" in url assert url.startswith("glib://_/") def test_plugin_glib_require_version_importerror(monkeypatch): """Simulate gi.require_version() raising ImportError""" gi = types.ModuleType("gi") gi.require_version = Mock(side_effect=ImportError("no gio")) sys.modules["gi"] = gi reload_plugin("glib") obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert not isinstance(obj, NotifyGLib) def test_plugin_glib_require_version_valueerror(monkeypatch): """Simulate gi.require_version() raising ValueError without reload crash.""" import gi import apprise.plugins.glib as plugin_glib # Patch require_version after import monkeypatch.setattr( gi, "require_version", Mock(side_effect=ValueError("fail")) ) # Re-evaluate plugin support logic manually try: gi.require_version("Gio", "2.0") except Exception: plugin_glib.NOTIFY_GLIB_SUPPORT_ENABLED = False plugin_glib.NotifyGLib.enabled = False # Confirm plugin is now marked disabled assert not plugin_glib.NotifyGLib.enabled # Apprise will skip this plugin obj = apprise.Apprise.instantiate("glib://", suppress_exceptions=False) assert not isinstance(obj, plugin_glib.NotifyGLib) def test_plugin_glib_gdkpixbuf_require_version_valueerror(monkeypatch): """Simulate gi.require_version('GdkPixbuf', ...) raising ValueError""" # Step 1: Mock GI gi = types.ModuleType("gi") gi.repository = types.ModuleType("gi.repository") def fake_require_version(name: str, version: str) -> None: if name == "GdkPixbuf": raise ValueError("GdkPixbuf unavailable") gi.require_version = Mock(side_effect=fake_require_version) # Step 2: Patch into sys.modules sys.modules["gi"] = gi sys.modules["gi.repository"] = gi.repository # Step 3: Reload plugin to trigger branch reload_plugin("glib") # Step 4: Confirm GdkPixbuf image support was not enabled from apprise.plugins import glib as plugin_glib assert plugin_glib.NOTIFY_GLIB_IMAGE_SUPPORT is False def test_plugin_glib_notify_generic_exception( mocker, enabled_glib_environment ): """ Test that a generic exception occurring during the notification send is caught and handled (returning False). """ import gi # 1. Create a mock object to act as the DBus Proxy mock_proxy = mocker.Mock() # 2. Force the Notify method to raise a generic Exception # This triggers the 'except Exception as e:' mock_proxy.Notify.side_effect = Exception("Simulated Generic Failure") # 3. Patch the Gio constructor to return our crashing mock # The code calls: Gio.DBusProxy.new_for_bus_sync(...) gi.repository.Gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy # 4. Instantiate the plugin from apprise.plugins.glib import NotifyGLib obj = NotifyGLib(targets=["glib://"]) # 5. Spy on the logger to ensure the warning is logged logger_spy = mocker.spy(obj, "logger") # 6. Execute notify() # It should catch the exception and return False assert obj.notify(title="Title", body="Body") is False # 7. Verify the specific log message from glib.py logger_spy.warning.assert_called_with( "Failed to send GLib/Gio notification." ) caronc-apprise-182f859/tests/test_plugin_gnome.py000066400000000000000000000324311524161175200221550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import importlib import logging import sys import types from unittest import mock from unittest.mock import ANY, Mock, call from helpers import reload_plugin import pytest import apprise from apprise.plugins.gnome import GnomeUrgency, NotifyGnome # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) def setup_glib_environment(): """Setup a heavily mocked Glib environment.""" # Our module base gi_name = "gi" # First we do an import without the gi library available to ensure # we can handle cases when the library simply isn't available if gi_name in sys.modules: # Test cases where the gi library exists; we want to remove it # for the purpose of testing and capture the handling of the # library when it is missing del sys.modules[gi_name] reload_plugin("gnome") # We need to fake our gnome environment for testing purposes since # the gi library isn't available on CI gi = types.ModuleType(gi_name) gi.repository = types.ModuleType(gi_name + ".repository") gi.module = types.ModuleType(gi_name + ".module") mock_pixbuf = mock.Mock() mock_notify = mock.Mock() gi.repository.GdkPixbuf = types.ModuleType( gi_name + ".repository.GdkPixbuf" ) gi.repository.GdkPixbuf.Pixbuf = mock_pixbuf gi.repository.Notify = mock.Mock() gi.repository.Notify.init.return_value = True gi.repository.Notify.Notification = mock_notify # Emulate require_version function: gi.require_version = mock.Mock(name=gi_name + ".require_version") # Force the fake module to exist sys.modules[gi_name] = gi sys.modules[gi_name + ".repository"] = gi.repository sys.modules[gi_name + ".repository.Notify"] = gi.repository.Notify # Notify Object notify_obj = mock.Mock() notify_obj.set_urgency.return_value = True notify_obj.set_icon_from_pixbuf.return_value = True notify_obj.set_image_from_pixbuf.return_value = True notify_obj.show.return_value = True mock_notify.new.return_value = notify_obj mock_pixbuf.new_from_file.return_value = True # When patching something which has a side effect on the module-level code # of a plugin, make sure to reload it. reload_plugin("gnome") @pytest.fixture def glib_environment(): """Fixture to provide a mocked Glib environment to test case functions.""" setup_glib_environment() @pytest.fixture def obj(glib_environment): """Fixture to provide a mocked Apprise instance.""" # Create our instance obj = apprise.Apprise.instantiate("gnome://", suppress_exceptions=False) assert obj is not None assert isinstance(obj, NotifyGnome) is True # Set our duration to 0 to speed up timeouts (for testing) obj.duration = 0 # Check that it found our mocked environments assert obj.enabled is True return obj def test_plugin_gnome_general_success(obj): """NotifyGnome() general checks.""" # Test url() call assert isinstance(obj.url(), str) is True # our URL Identifier is disabled assert obj.url_id() is None # test notifications assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) # test notification without a title assert ( obj.notify(title="", body="body", notify_type=apprise.NotifyType.INFO) is True ) def test_plugin_gnome_image_success(glib_environment): """Verify using the `image` query argument works as intended.""" obj = apprise.Apprise.instantiate( "gnome://_/?image=True", suppress_exceptions=False ) assert isinstance(obj, NotifyGnome) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "gnome://_/?image=False", suppress_exceptions=False ) assert isinstance(obj, NotifyGnome) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) def test_plugin_gnome_priority(glib_environment): """Verify correctness of the `priority` query argument.""" # Test Priority (alias of urgency) obj = apprise.Apprise.instantiate( "gnome://_/?priority=invalid", suppress_exceptions=False ) assert isinstance(obj, NotifyGnome) is True assert obj.urgency == 1 assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "gnome://_/?priority=high", suppress_exceptions=False ) assert isinstance(obj, NotifyGnome) is True assert obj.urgency == 2 assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "gnome://_/?priority=2", suppress_exceptions=False ) assert isinstance(obj, NotifyGnome) is True assert obj.urgency == 2 assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) def test_plugin_gnome_urgency(glib_environment): """Verify correctness of the `urgency` query argument.""" # Test Urgeny obj = apprise.Apprise.instantiate( "gnome://_/?urgency=invalid", suppress_exceptions=False ) assert obj.urgency == 1 assert isinstance(obj, NotifyGnome) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "gnome://_/?urgency=high", suppress_exceptions=False ) assert obj.urgency == 2 assert isinstance(obj, NotifyGnome) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "gnome://_/?urgency=2", suppress_exceptions=False ) assert isinstance(obj, NotifyGnome) is True assert obj.urgency == 2 assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) def test_plugin_gnome_parse_configuration(obj): """Verify configuration parsing works correctly.""" # Test configuration parsing content = """ urls: - gnome://: - priority: 0 tag: gnome_int low - priority: "0" tag: gnome_str_int low - priority: low tag: gnome_str low - urgency: 0 tag: gnome_int low - urgency: "0" tag: gnome_str_int low - urgency: low tag: gnome_str low # These will take on normal (default) urgency - priority: invalid tag: gnome_invalid - urgency: invalid tag: gnome_invalid - gnome://: - priority: 2 tag: gnome_int high - priority: "2" tag: gnome_str_int high - priority: high tag: gnome_str high - urgency: 2 tag: gnome_int high - urgency: "2" tag: gnome_str_int high - urgency: high tag: gnome_str high """ # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 14 servers from that # 6x low # 6x high # 2x invalid (so takes on normal urgency) assert len(ac.servers()) == 14 assert len(aobj) == 14 assert len(list(aobj.find(tag="low"))) == 6 for s in aobj.find(tag="low"): assert s.urgency == GnomeUrgency.LOW assert len(list(aobj.find(tag="high"))) == 6 for s in aobj.find(tag="high"): assert s.urgency == GnomeUrgency.HIGH assert len(list(aobj.find(tag="gnome_str"))) == 4 assert len(list(aobj.find(tag="gnome_str_int"))) == 4 assert len(list(aobj.find(tag="gnome_int"))) == 4 assert len(list(aobj.find(tag="gnome_invalid"))) == 2 for s in aobj.find(tag="gnome_invalid"): assert s.urgency == GnomeUrgency.NORMAL def test_plugin_gnome_missing_icon(mocker, obj): """Verify the notification will be submitted, even if loading the icon fails.""" # Inject error when loading icon. gi = importlib.import_module("gi") gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = AttributeError( "Something failed" ) logger: Mock = mocker.spy(obj, "logger") assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) assert logger.mock_calls == [ call.warning("Could not load notification icon (%s).", ANY), call.debug("Gnome Exception: Something failed"), call.info("Sent Gnome notification."), ] def test_plugin_gnome_disabled_plugin(obj): """Verify notification will not be submitted if plugin is disabled.""" obj.enabled = False assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) def test_plugin_gnome_set_urgency(): """Test the setting of an urgency, through `priority` keyword argument.""" NotifyGnome(priority=0) @pytest.mark.skipif("gi" not in sys.modules, reason="Requires gi library") def test_plugin_gnome_gi_croaks(): """Verify notification fails when `gi.require_version()` croaks.""" # Make `require_version` function raise an error. gi = importlib.import_module("gi") gi.require_version.side_effect = ValueError("Something failed") # When patching something which has a side effect on the module-level code # of a plugin, make sure to reload it. reload_plugin("gnome") # Create instance. obj = apprise.Apprise.instantiate("gnome://", suppress_exceptions=False) # The notifier is marked disabled. assert obj is None @pytest.mark.skipif("gi" not in sys.modules, reason="Requires gi library") def test_plugin_gnome_notify_croaks(mocker, obj): """Fail gracefully if underlying object croaks for whatever reason.""" # Inject an error when invoking `gi.repository.Notify`. mocker.patch( "gi.repository.Notify.Notification.new", side_effect=AttributeError("Something failed"), ) logger: Mock = mocker.spy(obj, "logger") assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) assert logger.mock_calls == [ call.warning("Failed to send Gnome notification."), call.debug("Gnome Exception: Something failed"), ] def test_plugin_gnome_show_exception(mocker, obj): """ Test that an exception raised during notification.show() is caught by the outer try/except block. """ # 1. Import the mocked 'gi' library. # The 'obj' fixture (via 'glib_environment') has already set this up # in sys.modules, so this import works even if Gnome isn't installed. import gi # 2. Retrieve the mocked notification instance. # Your setup_glib_environment() sets # 'gi.repository.Notify.Notification.new' to return a mock object. notification_instance = gi.repository.Notify.Notification.new.return_value # 3. Force the .show() method to raise an exception. notification_instance.show.side_effect = Exception( "Simulated Gnome Show Failure" ) # 4. Spy on the logger to verify the output. logger_spy = mocker.spy(obj, "logger") # 5. Execute the notification. # It should crash at .show(), catch the exception, and return False. assert obj.notify(title="Title", body="Body") is False # 6. Verify the specific log message. logger_spy.warning.assert_called_with("Failed to send Gnome notification.") caronc-apprise-182f859/tests/test_plugin_google_chat.py000066400000000000000000000274201524161175200233250ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyFormat, NotifyType from apprise.plugins.google_chat import NotifyGoogleChat logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "gchat://", { "instance": TypeError, }, ), ( "gchat://:@/", { "instance": TypeError, }, ), # Workspace, but not Key or Token ( "gchat://workspace", { "instance": TypeError, }, ), # Workspace and key, but no Token ( "gchat://workspace/key/", { "instance": TypeError, }, ), # Credentials are good ( "gchat://workspace/key/token", { "instance": NotifyGoogleChat, "privacy_url": "gchat://w...e/k...y/t...n", }, ), # Test arguments ( "gchat://?workspace=ws&key=mykey&token=mytoken", { "instance": NotifyGoogleChat, "privacy_url": "gchat://w...s/m...y/m...n", }, ), ( "gchat://?workspace=ws&key=mykey&token=mytoken&thread=abc123", { # Test our thread key "instance": NotifyGoogleChat, "privacy_url": "gchat://w...s/m...y/m...n/a...3", }, ), ( "gchat://?workspace=ws&key=mykey&token=mytoken&threadKey=abc345", { # Test our thread key "instance": NotifyGoogleChat, "privacy_url": "gchat://w...s/m...y/m...n/a...5", }, ), # Google Native Webhook URL ( ( "https://chat.googleapis.com/v1/spaces/myworkspace/messages" "?key=mykey&token=mytoken" ), { "instance": NotifyGoogleChat, "privacy_url": "gchat://m...e/m...y/m...n", }, ), ( ( "https://chat.googleapis.com/v1/spaces/myworkspace/messages" "?key=mykey&token=mytoken&threadKey=mythreadkey" ), { "instance": NotifyGoogleChat, "privacy_url": "gchat://m...e/m...y/m...n/m...y", }, ), ( "gchat://workspace/key/token", { "instance": NotifyGoogleChat, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "gchat://workspace/key/token", { "instance": NotifyGoogleChat, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "gchat://workspace/key/token", { "instance": NotifyGoogleChat, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_google_chat_urls(): """NotifyGoogleChat() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_google_chat_general(mock_post): """NotifyGoogleChat() General Checks.""" # Initialize some generic (but valid) tokens workspace = "ws" key = "key" threadkey = "threadkey" token = "token" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Test our messaging obj = Apprise.instantiate(f"gchat://{workspace}/{key}/{token}") assert isinstance(obj, NotifyGoogleChat) assert ( obj.notify( body="test body", title="title", notify_type=NotifyType.INFO ) is True ) # Test our call count assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://chat.googleapis.com/v1/spaces/ws/messages" ) params = mock_post.call_args_list[0][1]["params"] assert params.get("token") == token assert params.get("key") == key assert "messageReplyOption" not in params payload = loads(mock_post.call_args_list[0][1]["data"]) assert "thread" not in payload assert payload["text"] == "title\r\ntest body" mock_post.reset_mock() # Test our messaging with the thread_key obj = Apprise.instantiate(f"gchat://{workspace}/{key}/{token}/{threadkey}") assert isinstance(obj, NotifyGoogleChat) assert ( obj.notify( body="test body", title="title", notify_type=NotifyType.INFO ) is True ) # Test our call count assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://chat.googleapis.com/v1/spaces/ws/messages" ) params = mock_post.call_args_list[0][1]["params"] assert params.get("token") == token assert params.get("key") == key assert ( params.get("messageReplyOption") == "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" ) payload = loads(mock_post.call_args_list[0][1]["data"]) assert "thread" in payload assert payload["text"] == "title\r\ntest body" assert payload["thread"].get("thread_key") == threadkey def test_plugin_google_chat_edge_case(): """NotifyGoogleChat() Edge Cases.""" with pytest.raises(TypeError): NotifyGoogleChat("workspace", "webhook", "token", thread_key=object()) @mock.patch("requests.post") def test_plugin_google_chat_html_to_markdown_hardening(mock_post): """Test edge cases in the CommonMark-to-Google-Chat dialect adaptation.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"{}" mock_post.return_value.text = "{}" def notify(body): aobj = Apprise() assert aobj.add("gchat://workspace/key/token") assert aobj.notify(body=body, body_format=NotifyFormat.HTML) is True payload = loads(mock_post.call_args_list[-1][1]["data"]) mock_post.reset_mock() return payload["text"] # Convert CommonMark emphasis to Google Chat delimiters. assert notify("hello world") == "*hello* _world_" # A CommonMark link ("[text]()", html_to_markdown's output) must # arrive as Chat's own "" syntax. assert ( notify('click here') == "" ) # '&'/'<'/'>' need entity escaping, since Chat's own <...> link/mention # syntax reserves them. assert notify("

    2 < 3 & 4

    ") == "2 < 3 & 4" # Escape Chat control characters in link destinations. assert ( notify('click') == "" ) # Percent-encode Chat's URL-label separator inside destinations. assert ( notify('click') == "" ) # Entity-escape Chat controls inside code spans. assert notify("a < b & c") == "`a < b & c`" # Preserve adjacent nested emphasis. assert notify("x") == "*_x_*" # Non-adjacent nesting and sibling spans are unaffected. assert notify("bold italic still bold") == ( "*bold _italic_ still bold*" ) assert notify("AB") == "*A**B*" # Direct Google Chat Markdown remains unchanged. aobj = Apprise() assert aobj.add("gchat://workspace/key/token") assert aobj.notify(body="*already* chat-bound markdown") is True payload = loads(mock_post.call_args_list[-1][1]["data"]) assert payload["text"] == "*already* chat-bound markdown" mock_post.reset_mock() # Preserve unmatched backticks as literal text. assert ( NotifyGoogleChat._commonmark_to_google_chat("text ``unterminated") == "text ``unterminated" ) # Collapse empty entities without affecting following content. assert NotifyGoogleChat._commonmark_to_google_chat("****x") == "x" # A backslash-escaped '>' outside of link syntax still needs entity # escaping, the same as a bare one. f = NotifyGoogleChat._commonmark_to_google_chat assert f("\\>literal") == ">literal" # Drop CommonMark escapes that Google Chat does not use. assert f("\\!text") == "!text" # A three-backtick content line cannot close a four-backtick fence. # Preserve indentation until the matching four-backtick closing fence. assert f("````\n``` nested\n indented\nlast\n````") == ( "````\n``` nested\n indented\nlast\n````" ) # Preserve escapes so Chat formatting characters remain literal. assert f("\\*text\\*") == "\\*text\\*" assert f("\\_text\\_") == "\\_text\\_" assert f("\\~text\\~") == "\\~text\\~" assert f("\\`text\\`") == "\\`text\\`" # Preserve an incomplete link as literal text. assert f("[text]( # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests import apprise from apprise import NotifyFormat from apprise.plugins.gotify import GotifyPriority, NotifyGotify logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "gotify://", { "instance": None, }, ), # No token specified ( "gotify://hostname", { "instance": TypeError, }, ), # Provide a hostname and token ( "gotify://hostname/%s" % ("t" * 16), { "instance": NotifyGotify, # Our expected url(privacy=True) startswith() response: "privacy_url": "gotify://hostname/t...t", }, ), # Provide a hostname, path, and token ( "gotify://hostname/a/path/ending/in/a/slash/%s" % ("u" * 16), { "instance": NotifyGotify, # Our expected url(privacy=True) startswith() response: "privacy_url": "gotify://hostname/a/path/ending/in/a/slash/u...u/", }, ), # Markdown test ( "gotify://hostname/%s?format=markdown" % ("t" * 16), { "instance": NotifyGotify, }, ), # Provide a hostname, path, and token ( "gotify://hostname/a/path/not/ending/in/a/slash/%s" % ("v" * 16), { "instance": NotifyGotify, # Our expected url(privacy=True) startswith() response: "privacy_url": ( "gotify://hostname/a/path/not/ending/in/a/slash/v...v/" ), }, ), # Provide a priority ( "gotify://hostname/%s?priority=high" % ("i" * 16), { "instance": NotifyGotify, }, ), # Provide an invalid priority ( "gotify://hostname:8008/%s?priority=invalid" % ("i" * 16), { "instance": NotifyGotify, }, ), # An invalid url ( "gotify://:@/", { "instance": None, }, ), ( "gotify://hostname/%s/" % ("t" * 16), { "instance": NotifyGotify, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "gotifys://localhost/%s/" % ("t" * 16), { "instance": NotifyGotify, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "gotify://localhost/%s/" % ("t" * 16), { "instance": NotifyGotify, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_gotify_urls(): """NotifyGotify() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_gotify_edge_cases(): """NotifyGotify() Edge Cases.""" # Initializes the plugin with an invalid token with pytest.raises(TypeError): NotifyGotify(token=None) # Whitespace also acts as an invalid token value with pytest.raises(TypeError): NotifyGotify(token=" ") @mock.patch("requests.post") def test_plugin_gotify_config_files(mock_post): """NotifyGotify() Config File Cases.""" content = """ urls: - gotify://hostname/{}: - priority: 0 tag: gotify_int low - priority: "0" tag: gotify_str_int low # We want to make sure our '1' does not match the '10' entry - priority: "1" tag: gotify_str_int low - priority: low tag: gotify_str low # This will take on moderate (default) priority - priority: invalid tag: gotify_invalid - gotify://hostname/{}: - priority: 10 tag: gotify_int emerg - priority: "10" tag: gotify_str_int emerg - priority: emergency tag: gotify_str emerg """.format("a" * 16, "b" * 16) # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 8 servers from that # 4x low # 3x emerg # 1x invalid (so takes on normal priority) assert len(ac.servers()) == 8 assert len(aobj) == 8 assert len(list(aobj.find(tag="low"))) == 4 for s in aobj.find(tag="low"): assert s.priority == GotifyPriority.LOW assert len(list(aobj.find(tag="emerg"))) == 3 for s in aobj.find(tag="emerg"): assert s.priority == GotifyPriority.EMERGENCY assert len(list(aobj.find(tag="gotify_str"))) == 2 assert len(list(aobj.find(tag="gotify_str_int"))) == 3 assert len(list(aobj.find(tag="gotify_int"))) == 2 assert len(list(aobj.find(tag="gotify_invalid"))) == 1 assert ( next(aobj.find(tag="gotify_invalid")).priority == GotifyPriority.NORMAL ) @mock.patch("requests.post") def test_plugin_gotify_html_to_markdown_format(mock_post): """NotifyGotify(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Instantiate a Gotify plugin in Markdown mode aobj = apprise.Apprise() assert aobj.add("gotify://hostname/{}?format=markdown".format("t" * 16)) # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Gotify assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown, not as stripped plain text payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["message"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_groupme.py000066400000000000000000000474251524161175200225370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from urllib.parse import urlparse from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment from apprise.plugins.groupme import NotifyGroupMe logging.disable(logging.CRITICAL) # Attachment directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Realistic test credentials TEST_BOT_ID = "68ca900a7d17f9b9891a73af2a" TEST_TOKEN = "abc123def456gh789ijklmn0op" # A valid image URL returned by the GroupMe image service TEST_IMAGE_URL = "https://i.groupme.com/somethingsomething.large" # Fake image-service response payload IMAGE_UPLOAD_RESP = b'{"payload":{"url":"' + TEST_IMAGE_URL.encode() + b'"}}' # Our Testing URLs apprise_url_tests = ( ( # No bot_id "groupme://", { "instance": TypeError, }, ), ( # Empty URL "groupme://:@/", { "instance": TypeError, }, ), ( # Invalid bot_id containing non-hex characters "groupme://not-a-valid-bot-id!", { "instance": TypeError, }, ), ( # Valid bot_id (text only) "groupme://{}".format(TEST_BOT_ID), { "instance": NotifyGroupMe, "privacy_url": "groupme://6...a/", }, ), ( # bot_id via query string "groupme://?bot_id={}".format(TEST_BOT_ID), { "instance": NotifyGroupMe, }, ), ( # Access token in the path (canonical form with attachments) "groupme://{}/{}".format(TEST_BOT_ID, TEST_TOKEN), { "instance": NotifyGroupMe, "privacy_url": "groupme://6...a/", # Image upload fails under URL tester mock (no payload.url) "attach_response": False, }, ), ( # Access token supplied via ?token= query parameter (alias form) "groupme://{}?token={}".format(TEST_BOT_ID, TEST_TOKEN), { "instance": NotifyGroupMe, # Image upload fails under URL tester mock (no payload.url) "attach_response": False, }, ), ( # HTTP 500 response "groupme://{}".format(TEST_BOT_ID), { "instance": NotifyGroupMe, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( # Unknown response code "groupme://{}".format(TEST_BOT_ID), { "instance": NotifyGroupMe, "response": False, "requests_response_code": 999, }, ), ( # Request exception "groupme://{}".format(TEST_BOT_ID), { "instance": NotifyGroupMe, "test_requests_exceptions": True, }, ), ) def test_plugin_groupme_urls(): """NotifyGroupMe() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_groupme_init(mock_post): """NotifyGroupMe() Initialization checks.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.accepted # Missing bot_id raises TypeError with pytest.raises(TypeError): NotifyGroupMe(bot_id=None) # Blank bot_id raises TypeError with pytest.raises(TypeError): NotifyGroupMe(bot_id="") # bot_id with invalid characters raises TypeError with pytest.raises(TypeError): NotifyGroupMe(bot_id="not-valid!") # Valid bot_id instantiates without error obj = NotifyGroupMe(bot_id=TEST_BOT_ID) assert isinstance(obj, NotifyGroupMe) assert obj.bot_id == TEST_BOT_ID assert obj.token is None # Valid bot_id + token obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) assert isinstance(obj, NotifyGroupMe) assert obj.bot_id == TEST_BOT_ID assert obj.token == TEST_TOKEN @mock.patch("requests.post") def test_plugin_groupme_send(mock_post): """NotifyGroupMe() Send checks.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp # 202 Accepted is the expected success code mock_post.return_value = _mk_resp(requests.codes.accepted) obj = NotifyGroupMe(bot_id=TEST_BOT_ID) assert isinstance(obj, NotifyGroupMe) # Successful send assert obj.notify(body="Test message") is True assert mock_post.call_count == 1 # Verify the correct URL host was used call_url = mock_post.call_args[0][0] assert urlparse(call_url).hostname == "api.groupme.com" mock_post.reset_mock() # 200 OK also accepted as success mock_post.return_value = _mk_resp(requests.codes.ok) assert obj.notify(body="Test message") is True mock_post.reset_mock() # HTTP 400 returns False mock_post.return_value = _mk_resp(requests.codes.bad_request) assert obj.notify(body="Test message") is False mock_post.reset_mock() # HTTP 401 returns False mock_post.return_value = _mk_resp(requests.codes.unauthorized) assert obj.notify(body="Test message") is False mock_post.reset_mock() # HTTP 404 returns False mock_post.return_value = _mk_resp(requests.codes.not_found) assert obj.notify(body="Test message") is False mock_post.reset_mock() # HTTP 429 returns False mock_post.return_value = _mk_resp(429) assert obj.notify(body="Test message") is False mock_post.reset_mock() # HTTP 500 returns False mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert obj.notify(body="Test message") is False mock_post.reset_mock() # Unknown error code returns False mock_post.return_value = _mk_resp(999) assert obj.notify(body="Test message") is False mock_post.reset_mock() # RequestException returns False mock_post.side_effect = requests.RequestException("Network error") assert obj.notify(body="Test message") is False @mock.patch("requests.post") def test_plugin_groupme_attach_no_token(mock_post): """NotifyGroupMe() warns but still sends text when token is absent.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp mock_post.return_value = _mk_resp(requests.codes.accepted) obj = NotifyGroupMe(bot_id=TEST_BOT_ID) assert obj.token is None # Load a real attachment file attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) # Attachment send without token -- text message still succeeds assert obj.notify(body="Test", attach=attach) is True # Only the bot POST is made (no upload call since no token) assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_groupme_attach_success(mock_post): """NotifyGroupMe() image upload and attachment send succeeds.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp # First call: image upload returns 200 with URL # Second call: bot post returns 202 mock_post.side_effect = [ _mk_resp(requests.codes.ok, IMAGE_UPLOAD_RESP), _mk_resp(requests.codes.accepted), ] obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert obj.notify(body="Test", attach=attach) is True # Two requests: upload + bot post assert mock_post.call_count == 2 # Verify the image service URL host was called first upload_url = mock_post.call_args_list[0][0][0] assert urlparse(upload_url).hostname == "image.groupme.com" # Verify the bot post URL host was called second post_url = mock_post.call_args_list[1][0][0] assert urlparse(post_url).hostname == "api.groupme.com" @mock.patch("requests.post") def test_plugin_groupme_attach_inaccessible(mock_post): """NotifyGroupMe() aborts upload when attachment is inaccessible.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp mock_post.return_value = _mk_resp(requests.codes.ok, IMAGE_UPLOAD_RESP) obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) # Simulate the file being inaccessible (isfile returns False) with mock.patch("os.path.isfile", return_value=False): assert obj.notify(body="Test", attach=attach) is False # No HTTP calls should have been made assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_groupme_attach_oserror(mock_post): """NotifyGroupMe() aborts upload when open() raises OSError.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp mock_post.return_value = _mk_resp(requests.codes.ok, IMAGE_UPLOAD_RESP) obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) # Simulate an I/O error when opening the file with mock.patch("builtins.open", side_effect=OSError("disk error")): assert obj.notify(body="Test", attach=attach) is False # No HTTP calls should have been made assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_groupme_attach_upload_http_error(mock_post): """NotifyGroupMe() returns False when the image upload gets HTTP error.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp # Image upload returns 401 mock_post.return_value = _mk_resp(requests.codes.unauthorized) obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert obj.notify(body="Test", attach=attach) is False # Only the upload attempt was made (bot post never called) assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_groupme_attach_upload_request_exception(mock_post): """NotifyGroupMe() returns False on RequestException during upload.""" obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) # Simulate a network error during the image upload mock_post.side_effect = requests.RequestException("Network error") assert obj.notify(body="Test", attach=attach) is False # Only the upload attempt was made assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_groupme_attach_bad_response(mock_post): """NotifyGroupMe() returns False when upload response has no URL.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp # Upload returns 200 but with a response missing the URL field mock_post.return_value = _mk_resp(requests.codes.ok, b"{}") obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert obj.notify(body="Test", attach=attach) is False mock_post.reset_mock() # Upload returns 200 but with invalid JSON mock_post.return_value = _mk_resp(requests.codes.ok, b"not-json") assert obj.notify(body="Test", attach=attach) is False @mock.patch("requests.post") def test_plugin_groupme_attach_non_image(mock_post): """NotifyGroupMe() skips non-image attachments with a warning.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp mock_post.return_value = _mk_resp(requests.codes.accepted) obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) # apprise-archive.zip has MIME type application/zip (not an image) attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-archive.zip") ) # Non-image attachment is skipped; text message still succeeds assert obj.notify(body="Test", attach=attach) is True # Only the bot POST is made (no upload attempted for non-image file) assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_groupme_attach_multi(mock_post): """NotifyGroupMe() sends multiple attachments in one bot post.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp # Two image uploads + one bot post mock_post.side_effect = [ _mk_resp(requests.codes.ok, IMAGE_UPLOAD_RESP), _mk_resp(requests.codes.ok, IMAGE_UPLOAD_RESP), _mk_resp(requests.codes.accepted), ] obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) # Load two different attachment files attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.jpeg")) assert obj.notify(body="Test", attach=attach) is True # 2 uploads + 1 bot post assert mock_post.call_count == 3 @mock.patch("requests.post") def test_plugin_groupme_url_roundtrip(mock_post): """NotifyGroupMe() URL round-trip invariant.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.accepted # Without token obj1 = NotifyGroupMe(bot_id=TEST_BOT_ID) results = NotifyGroupMe.parse_url(obj1.url()) assert results is not None obj2 = NotifyGroupMe(**results) assert obj1.url_identifier == obj2.url_identifier assert obj1.bot_id == obj2.bot_id # With token obj3 = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) results = NotifyGroupMe.parse_url(obj3.url()) assert results is not None obj4 = NotifyGroupMe(**results) assert obj3.url_identifier == obj4.url_identifier assert obj3.bot_id == obj4.bot_id assert obj3.token == obj4.token @mock.patch("requests.post") def test_plugin_groupme_url_parsing(mock_post): """NotifyGroupMe() URL parsing checks.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.accepted # Standard URL (no token) results = NotifyGroupMe.parse_url("groupme://{}".format(TEST_BOT_ID)) assert results is not None assert results["bot_id"] == TEST_BOT_ID assert not results.get("token") # Token in path (canonical form) results = NotifyGroupMe.parse_url( "groupme://{}/{}".format(TEST_BOT_ID, TEST_TOKEN) ) assert results is not None assert results["bot_id"] == TEST_BOT_ID assert results["token"] == TEST_TOKEN # Token via ?token= query parameter (alias form) results = NotifyGroupMe.parse_url( "groupme://{}?token={}".format(TEST_BOT_ID, TEST_TOKEN) ) assert results is not None assert results["bot_id"] == TEST_BOT_ID assert results["token"] == TEST_TOKEN # ?token= overrides path-supplied token other_token = "zzz999yyy888xxx777" results = NotifyGroupMe.parse_url( "groupme://{}/{}?token={}".format(TEST_BOT_ID, TEST_TOKEN, other_token) ) assert results is not None assert results["token"] == other_token # ?bot_id= query override results = NotifyGroupMe.parse_url( "groupme://ignored?bot_id={}".format(TEST_BOT_ID) ) assert results is not None assert results["bot_id"] == TEST_BOT_ID # None URL returns None assert NotifyGroupMe.parse_url(None) is None @mock.patch("requests.post") def test_plugin_groupme_privacy_url(mock_post): """NotifyGroupMe() privacy URL masks credentials.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.accepted obj = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) privacy = obj.url(privacy=True) assert TEST_BOT_ID not in privacy assert TEST_TOKEN not in privacy assert privacy.startswith("groupme://") @mock.patch("requests.post") def test_plugin_groupme_url_identifier(mock_post): """NotifyGroupMe() url_identifier uniqueness.""" obj1 = NotifyGroupMe(bot_id=TEST_BOT_ID) obj2 = NotifyGroupMe(bot_id=TEST_BOT_ID) assert obj1.url_identifier == obj2.url_identifier # Different bot_id produces a different identifier obj3 = NotifyGroupMe(bot_id="aabbccddeeff00112233445566") assert obj1.url_identifier != obj3.url_identifier # token must not affect the identifier obj4 = NotifyGroupMe(bot_id=TEST_BOT_ID, token=TEST_TOKEN) assert obj1.url_identifier == obj4.url_identifier # Schema is the first element assert obj1.url_identifier[0] == NotifyGroupMe.secure_protocol assert obj1.url_identifier[1] == TEST_BOT_ID @mock.patch("requests.post") def test_plugin_groupme_apprise_integration(mock_post): """NotifyGroupMe() Apprise integration.""" def _mk_resp(code, content=b""): """Build a mock response.""" resp = requests.Request() resp.status_code = code resp.content = content return resp mock_post.return_value = _mk_resp(requests.codes.accepted) # Load via Apprise URL a = Apprise() assert a.add("groupme://{}".format(TEST_BOT_ID)) is True assert len(a) == 1 assert isinstance(a[0], NotifyGroupMe) assert a.notify(body="Test", title="Title") is True assert mock_post.call_count == 1 mock_post.reset_mock() # Load with token a2 = Apprise() assert ( a2.add("groupme://{}?token={}".format(TEST_BOT_ID, TEST_TOKEN)) is True ) assert len(a2) == 1 assert isinstance(a2[0], NotifyGroupMe) mock_post.return_value = _mk_resp(requests.codes.accepted) assert a2.notify(body="Test", title="Title") is True assert mock_post.call_count == 1 mock_post.reset_mock() # Failure response mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert a.notify(body="Test") is False caronc-apprise-182f859/tests/test_plugin_growl.py000066400000000000000000000323071524161175200222040ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import sys from unittest import mock import pytest import apprise from apprise import NotifyBase from apprise.plugins.growl import GrowlPriority, NotifyGrowl try: from gntp import errors TEST_GROWL_EXCEPTIONS = ( errors.NetworkError(0, "gntp.ParseError() not handled"), errors.AuthError(0, "gntp.AuthError() not handled"), errors.ParseError(0, "gntp.ParseError() not handled"), errors.UnsupportedError(0, "gntp.UnsupportedError() not handled"), ) except ImportError: # no problem; gntp isn't available to us pass # Disable logging for a cleaner testing output import logging logging.disable(logging.CRITICAL) @pytest.mark.skipif( "gntp" in sys.modules, reason="Requires that gntp NOT be installed" ) def test_plugin_growl_gntp_import_error(): """NotifyGrowl() Import Error.""" # If the object is disabled, then it can't be instantiated obj = apprise.Apprise.instantiate("growl://growl.server") assert obj is None @pytest.mark.skipif("gntp" not in sys.modules, reason="Requires gntp") @mock.patch("gntp.notifier.GrowlNotifier") def test_plugin_growl_exception_handling(mock_gntp): """NotifyGrowl() Exception Handling.""" TEST_GROWL_EXCEPTIONS = ( errors.NetworkError(0, "gntp.ParseError() not handled"), errors.AuthError(0, "gntp.AuthError() not handled"), errors.ParseError(0, "gntp.ParseError() not handled"), errors.UnsupportedError(0, "gntp.UnsupportedError() not handled"), ) mock_notifier = mock.Mock() mock_gntp.return_value = mock_notifier mock_notifier.notify.return_value = True # First we test the growl.register() function for exception in TEST_GROWL_EXCEPTIONS: mock_notifier.register.side_effect = exception # instantiate our object obj = apprise.Apprise.instantiate( "growl://growl.server.hostname", suppress_exceptions=False ) # Verify Growl object was instantiated assert obj is not None # We will fail to send the notification because our registration # would have failed assert ( obj.notify( title="test", body="body", notify_type=apprise.NotifyType.INFO ) is False ) # Now we test the growl.notify() function mock_notifier.register.side_effect = None for exception in TEST_GROWL_EXCEPTIONS: mock_notifier.notify.side_effect = exception # instantiate our object obj = apprise.Apprise.instantiate( "growl://growl.server.hostname", suppress_exceptions=False ) # Verify Growl object was instantiated assert obj is not None # We will fail to send the notification because of the underlining # notify() call throws an exception assert ( obj.notify( title="test", body="body", notify_type=apprise.NotifyType.INFO ) is False ) @pytest.mark.skipif("gntp" not in sys.modules, reason="Requires gntp") @mock.patch("gntp.notifier.GrowlNotifier") def test_plugin_growl_general(mock_gntp): """NotifyGrowl() General Checks.""" urls = ( ################################## # NotifyGrowl ################################## ( "growl://", { "instance": None, }, ), ("growl://:@/", {"instance": None}), ( "growl://pass@growl.server", { "instance": NotifyGrowl, }, ), ( "growl://ignored:pass@growl.server", { "instance": NotifyGrowl, }, ), ( "growl://growl.server", { "instance": NotifyGrowl, # don't include an image by default "include_image": False, }, ), ( "growl://growl.server?version=1", { "instance": NotifyGrowl, }, ), # Test sticky flag ( "growl://growl.server?sticky=yes", { "instance": NotifyGrowl, }, ), ( "growl://growl.server?sticky=no", { "instance": NotifyGrowl, }, ), # Force a failure ( "growl://growl.server?version=1", { "instance": NotifyGrowl, "growl_response": None, }, ), ( "growl://growl.server?version=2", { # don't include an image by default "include_image": False, "instance": NotifyGrowl, }, ), ( "growl://growl.server?version=2", { # don't include an image by default "include_image": False, "instance": NotifyGrowl, "growl_response": None, }, ), # Priorities ( "growl://pass@growl.server?priority=low", { "instance": NotifyGrowl, }, ), ( "growl://pass@growl.server?priority=moderate", { "instance": NotifyGrowl, }, ), ( "growl://pass@growl.server?priority=normal", { "instance": NotifyGrowl, }, ), ( "growl://pass@growl.server?priority=high", { "instance": NotifyGrowl, }, ), ( "growl://pass@growl.server?priority=emergency", { "instance": NotifyGrowl, }, ), # Invalid Priorities ( "growl://pass@growl.server?priority=invalid", { "instance": NotifyGrowl, }, ), ( "growl://pass@growl.server?priority=", { "instance": NotifyGrowl, }, ), # invalid version ( "growl://growl.server?version=", { "instance": NotifyGrowl, }, ), ( "growl://growl.server?version=crap", { "instance": NotifyGrowl, }, ), # Ports ( "growl://growl.changeport:2000", { "instance": NotifyGrowl, }, ), ( "growl://growl.garbageport:garbage", { "instance": NotifyGrowl, }, ), ( "growl://growl.colon:", { "instance": NotifyGrowl, }, ), ) # iterate over our dictionary and test it out for url, meta in urls: # Our expected instance instance = meta.get("instance", None) # Our expected exception exception = meta.get("exception", None) # Our expected server objects self = meta.get("self", None) # Our expected Query response (True, False, or exception type) response = meta.get("response", True) # Allow us to force the server response code to be something other then # the defaults growl_response = meta.get("growl_response", bool(response)) mock_notifier = mock.Mock() mock_gntp.return_value = mock_notifier mock_notifier.notify.side_effect = None # Store our response mock_notifier.notify.return_value = growl_response try: obj = apprise.Apprise.instantiate(url, suppress_exceptions=False) assert exception is None if obj is None: # We're done continue if instance is None: # Expected None but didn't get it raise AssertionError() assert isinstance(obj, instance) is True # Test our URL Identifier is generated assert isinstance(obj.url_id(), str) is True if isinstance(obj, NotifyBase): # We loaded okay; now lets make sure we can reverse this url assert isinstance(obj.url(), str) is True # Test our privacy=True flag assert isinstance(obj.url(privacy=True), str) is True # Instantiate the exact same object again using the URL from # the one that was already created properly obj_cmp = apprise.Apprise.instantiate(obj.url()) # Our object should be the same instance as what we had # originally expected above. if not isinstance(obj_cmp, NotifyBase): # Assert messages are hard to trace back with the way # these tests work. Just printing before throwing our # assertion failure makes things easier to debug later on raise AssertionError() if self: # Iterate over our expected entries inside of our object for key, val in self.items(): # Test that our object has the desired key assert hasattr(key, obj) assert getattr(key, obj) == val try: # check that we're as expected assert ( obj.notify( title="test", body="body", notify_type=apprise.NotifyType.INFO, ) == response ) except Exception as e: # Check that we were expecting this exception to happen assert isinstance(e, response) except AssertionError: # Don't mess with these entries raise except Exception as e: # Handle our exception assert exception is not None assert isinstance(e, exception) @pytest.mark.skipif("gntp" not in sys.modules, reason="Requires gntp") @mock.patch("gntp.notifier.GrowlNotifier") def test_plugin_growl_config_files(mock_gntp): """NotifyGrowl() Config File Cases.""" content = """ urls: - growl://pass@growl.server: - priority: -2 tag: growl_int low - priority: "-2" tag: growl_str_int low - priority: low tag: growl_str low # This will take on moderate (default) priority - priority: invalid tag: growl_invalid - growl://pass@growl.server: - priority: 2 tag: growl_int emerg - priority: "2" tag: growl_str_int emerg - priority: emergency tag: growl_str emerg """ mock_notifier = mock.Mock() mock_gntp.return_value = mock_notifier mock_notifier.notify.return_value = True # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 7 servers from that # 3x low # 3x emerg # 1x invalid (so takes on normal priority) assert len(ac.servers()) == 7 assert len(aobj) == 7 assert len(list(aobj.find(tag="low"))) == 3 for s in aobj.find(tag="low"): assert s.priority == GrowlPriority.LOW assert len(list(aobj.find(tag="emerg"))) == 3 for s in aobj.find(tag="emerg"): assert s.priority == GrowlPriority.EMERGENCY assert len(list(aobj.find(tag="growl_str"))) == 2 assert len(list(aobj.find(tag="growl_str_int"))) == 2 assert len(list(aobj.find(tag="growl_int"))) == 2 assert len(list(aobj.find(tag="growl_invalid"))) == 1 assert ( next(aobj.find(tag="growl_invalid")).priority == GrowlPriority.NORMAL ) caronc-apprise-182f859/tests/test_plugin_guilded.py000066400000000000000000000160161524161175200224660ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.guilded import NotifyGuilded logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "guilded://", { "instance": TypeError, }, ), # An invalid url ( "guilded://:@/", { "instance": TypeError, }, ), # No webhook_token specified ( "guilded://%s" % ("i" * 24), { "instance": TypeError, }, ), # Provide both an webhook id and a webhook token ( "guilded://{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), # Provide a temporary username ( "guilded://l2g@{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), # test image= field ( "guilded://{}/{}?format=markdown&footer=Yes&image=Yes".format( "i" * 24, "t" * 64 ), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, # don't include an image by default "include_image": False, }, ), ( "guilded://{}/{}?format=markdown&footer=Yes&image=No&fields=no".format( "i" * 24, "t" * 64 ), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), ( "guilded://{}/{}?format=markdown&footer=Yes&image=Yes".format( "i" * 24, "t" * 64 ), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), ( "https://media.guilded.gg/webhooks/{}/{}".format("0" * 10, "B" * 40), { # Native URL Support, support the provided guilded URL from their # webpage. "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), ( "guilded://{}/{}?format=markdown&avatar=No&footer=No".format( "i" * 24, "t" * 64 ), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), # different format support ( "guilded://{}/{}?format=markdown".format("i" * 24, "t" * 64), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), ( "guilded://{}/{}?format=text".format("i" * 24, "t" * 64), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), # Test with avatar URL ( "guilded://{}/{}?avatar_url=http://localhost/test.jpg".format( "i" * 24, "t" * 64 ), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, }, ), # Test without image set ( "guilded://{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyGuilded, "requests_response_code": requests.codes.no_content, # don't include an image by default "include_image": False, }, ), ( "guilded://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyGuilded, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "guilded://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyGuilded, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "guilded://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyGuilded, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_guilded_urls(): """NotifyGuilded() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_guilded_general(mock_post): """NotifyGuilded() General Checks.""" # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Invalid webhook id with pytest.raises(TypeError): NotifyGuilded(webhook_id=None, webhook_token=webhook_token) # Invalid webhook id (whitespace) with pytest.raises(TypeError): NotifyGuilded(webhook_id=" ", webhook_token=webhook_token) # Invalid webhook token with pytest.raises(TypeError): NotifyGuilded(webhook_id=webhook_id, webhook_token=None) # Invalid webhook token (whitespace) with pytest.raises(TypeError): NotifyGuilded(webhook_id=webhook_id, webhook_token=" ") obj = NotifyGuilded( webhook_id=webhook_id, webhook_token=webhook_token, footer=True, thumbnail=False, ) # Test that we get a string response assert isinstance(obj.url(), str) caronc-apprise-182f859/tests/test_plugin_homeassistant.py000066400000000000000000000321421524161175200237310ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise from apprise.plugins.home_assistant import NotifyHomeAssistant logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "hassio://:@/", { "instance": TypeError, }, ), ( "hassio://", { "instance": TypeError, }, ), ( "hassios://", { "instance": TypeError, }, ), # No Long Lived Access Token specified ( "hassio://user@localhost", { "instance": TypeError, }, ), ( "hassio://localhost/long.lived.token", { "instance": NotifyHomeAssistant, }, ), ( "hassio://localhost/prefix/path/long.lived.token", { "instance": NotifyHomeAssistant, }, ), ( "hassio://localhost/long.lived.token?prefix=/ha", { "instance": NotifyHomeAssistant, }, ), ( "hassio://localhost/service/?token=long.lived.token&prefix=/ha", { "instance": NotifyHomeAssistant, }, ), ( "hassio://localhost/?token=long.lived.token&prefix=/ha&to=service", { "instance": NotifyHomeAssistant, }, ), ( "hassio://localhost/service/$%/?token=long.lived.token&prefix=/ha", { # Tests an invalid service entry "instance": NotifyHomeAssistant, }, ), ( "hassio://localhost/%only%/%invalid%/?token=lng.lived.token&prefix=/ha", { # Tests an invalid service entry "instance": NotifyHomeAssistant, # we'll have a notify response failure in this case "notify_response": False, }, ), ( "hassio://localhost/service/?accesstoken=long.lived.token", { # accesstoken via query string (?accesstoken=) "instance": NotifyHomeAssistant, }, ), ( "hassio://user:pass@localhost/long.lived.token/", { "instance": NotifyHomeAssistant, # Our expected url(privacy=True) startswith() response: "privacy_url": "hassio://user:****@localhost/l...n", }, ), ( "hassio://localhost:80/long.lived.token", { "instance": NotifyHomeAssistant, }, ), ( "hassio://user@localhost:8123/long.lived.token", { "instance": NotifyHomeAssistant, "privacy_url": "hassio://user@localhost/l...n", }, ), ( "hassios://localhost/long.lived.token?nid=!%", { # Invalid notification_id "instance": TypeError, }, ), ( "hassios://localhost/long.lived.token?nid=abcd", { # Valid notification_id "instance": NotifyHomeAssistant, }, ), ( "hassios://user:pass@localhost/long.lived.token", { "instance": NotifyHomeAssistant, "privacy_url": "hassios://user:****@localhost/l...n", }, ), ( "hassios://localhost:8443/path/long.lived.token/", { "instance": NotifyHomeAssistant, "privacy_url": "hassios://localhost:8443/l...n", }, ), ( "hassio://localhost:8123/a/path?token=long.lived.token", { "instance": NotifyHomeAssistant, # Default port; so it's stripped off # token was specified as kwarg "privacy_url": "hassio://localhost/l...n", }, ), ( "hassios://user:password@localhost:80/long.lived.token/", { "instance": NotifyHomeAssistant, "privacy_url": "hassios://user:****@localhost:80", }, ), ( "hassio://user:pass@localhost:8123/long.lived.token", { "instance": NotifyHomeAssistant, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "hassio://user:pass@localhost/long.lived.token", { "instance": NotifyHomeAssistant, # throw a bizzare code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "hassio://user:pass@localhost/long.lived.token", { "instance": NotifyHomeAssistant, # Throws a series of connection and transfer exceptions # when this flag is set and tests that we gracfully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_homeassistant_urls(): """NotifyHomeAssistant() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_homeassistant_general(mock_post): """NotifyHomeAssistant() General Checks.""" response = mock.Mock() response.content = "" response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Variation Initializations obj = Apprise.instantiate("hassio://localhost/accesstoken") assert isinstance(obj, NotifyHomeAssistant) is True assert isinstance(obj.url(), str) is True # Send Notification assert obj.send(body="test") is True assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://localhost:8123/api/services/persistent_notification/create" ) # Reset our mock object mock_post.reset_mock() # Now let's notify an object obj = Apprise.instantiate("hassio://localhost/long.lived.token/service") assert isinstance(obj, NotifyHomeAssistant) is True assert isinstance(obj.url(), str) is True # Send Notification assert obj.send(body="test") is True assert ( mock_post.call_args_list[0][0][0] == "http://localhost:8123/api/services/notify/service" ) posted_json = json.loads(mock_post.call_args_list[0][1]["data"]) assert "notification_id" not in posted_json assert "targets" not in posted_json assert "message" in posted_json assert posted_json["message"] == "test" assert "title" in posted_json assert posted_json["title"] == "" # Reset our mock object mock_post.reset_mock() # # No Batch Processing # # Now let's notify an object obj = Apprise.instantiate( "hassio://localhost/long.lived.token/serviceA:target1,target2/" "service2/domain1.service3?batch=no" ) assert isinstance(obj, NotifyHomeAssistant) is True assert isinstance(obj.url(), str) is True # Send Notification assert obj.send(body="test-body", title="title") is True # Entries are split apart assert len(obj) == 4 assert mock_post.call_count == 4 assert ( mock_post.call_args_list[0][0][0] == "http://localhost:8123/api/services/domain1/service3" ) posted_json = json.loads(mock_post.call_args_list[0][1]["data"]) assert "notification_id" not in posted_json assert "targets" not in posted_json assert "message" in posted_json assert posted_json["message"] == "test-body" assert "title" in posted_json assert posted_json["title"] == "title" assert ( mock_post.call_args_list[1][0][0] == "http://localhost:8123/api/services/notify/service2" ) posted_json = json.loads(mock_post.call_args_list[1][1]["data"]) assert "notification_id" not in posted_json assert "targets" not in posted_json assert "message" in posted_json assert posted_json["message"] == "test-body" assert "title" in posted_json assert posted_json["title"] == "title" assert ( mock_post.call_args_list[2][0][0] == "http://localhost:8123/api/services/notify/serviceA" ) posted_json = json.loads(mock_post.call_args_list[2][1]["data"]) assert "notification_id" not in posted_json assert "targets" in posted_json assert isinstance(posted_json["targets"], list) assert len(posted_json["targets"]) == 1 assert "target1" in posted_json["targets"] assert "message" in posted_json assert posted_json["message"] == "test-body" assert "title" in posted_json assert posted_json["title"] == "title" assert ( mock_post.call_args_list[3][0][0] == "http://localhost:8123/api/services/notify/serviceA" ) posted_json = json.loads(mock_post.call_args_list[3][1]["data"]) assert "notification_id" not in posted_json assert "targets" in posted_json assert isinstance(posted_json["targets"], list) assert len(posted_json["targets"]) == 1 assert "target2" in posted_json["targets"] assert "message" in posted_json assert posted_json["message"] == "test-body" assert "title" in posted_json assert posted_json["title"] == "title" # Reset our mock object mock_post.reset_mock() # # Batch Processing # # Now let's notify an object obj = Apprise.instantiate( "hassio://localhost/long.lived.token/serviceA:target1,target2/" "service2/domain1.service3?batch=yes" ) assert isinstance(obj, NotifyHomeAssistant) is True assert isinstance(obj.url(), str) is True # Send Notification assert obj.send(body="test-body", title="title") is True # Entries targets can be grouped assert len(obj) == 3 assert mock_post.call_count == 3 assert ( mock_post.call_args_list[0][0][0] == "http://localhost:8123/api/services/domain1/service3" ) posted_json = json.loads(mock_post.call_args_list[0][1]["data"]) assert "notification_id" not in posted_json assert "targets" not in posted_json assert "message" in posted_json assert posted_json["message"] == "test-body" assert "title" in posted_json assert posted_json["title"] == "title" assert ( mock_post.call_args_list[1][0][0] == "http://localhost:8123/api/services/notify/service2" ) posted_json = json.loads(mock_post.call_args_list[1][1]["data"]) assert "notification_id" not in posted_json assert "targets" not in posted_json assert "message" in posted_json assert posted_json["message"] == "test-body" assert "title" in posted_json assert posted_json["title"] == "title" assert ( mock_post.call_args_list[2][0][0] == "http://localhost:8123/api/services/notify/serviceA" ) posted_json = json.loads(mock_post.call_args_list[2][1]["data"]) assert "notification_id" not in posted_json assert "targets" in posted_json assert isinstance(posted_json["targets"], list) # Our batch groups our targets assert len(posted_json["targets"]) == 2 assert "target1" in posted_json["targets"] assert "target2" in posted_json["targets"] assert "message" in posted_json assert posted_json["message"] == "test-body" assert "title" in posted_json assert posted_json["title"] == "title" # Reset our mock object mock_post.reset_mock() # # Test error handling on multi-query request # # Now let's notify an object obj = Apprise.instantiate( "hassio://localhost/long.lived.token/serviceA:target1,target2/" "service2:target3,target4,target5,target6?batch=no" ) assert isinstance(obj, NotifyHomeAssistant) is True assert isinstance(obj.url(), str) is True bad_response = mock.Mock() bad_response.content = "" bad_response.status_code = requests.codes.not_found mock_post.side_effect = (response, bad_response) # We will fail on our second message sent assert obj.send(body="test-body", title="title") is False caronc-apprise-182f859/tests/test_plugin_httpsms.py000066400000000000000000000136631524161175200225600ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyType from apprise.plugins.httpsms import NotifyHttpSMS logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "httpsms://", { # Instantiated but no auth, so no notification can happen "instance": TypeError, }, ), ( "httpsms://:@/", { # invalid token "instance": TypeError, }, ), ( "httpsms://{}:{}@{}".format("u" * 10, "p" * 10, "3" * 5), { # invalid source number provided "instance": TypeError, }, ), ( "httpsms://{}@{}/{}".format("p" * 10, "1" * 10, 2 * "5"), { # invalid target number provided "instance": NotifyHttpSMS, # Expected notify() response because we have no one to notify "notify_response": False, }, ), ( "httpsms://{}@{}".format("p" * 10, "2" * 10), { # default to ourselves "instance": NotifyHttpSMS, }, ), ( "httpsms://{}@9876543210/{}/abcd/".format("b" * 10, "3" * 11), { # included phone, short number (123) and garbage string (abcd) # dropped "instance": NotifyHttpSMS, "privacy_url": "httpsms://b...b@9876543210/33333333333", }, ), ( "httpsms://{}@{}".format("c" * 10, "4" * 11), { "instance": NotifyHttpSMS, # Our expected url(privacy=True) startswith() response: "privacy_url": "httpsms://c...c@44444444444", }, ), ( "httpsms://{}@{}".format("b" * 10, "5" * 11), { # using phone no with no target - we text ourselves in this case "instance": NotifyHttpSMS, }, ), ( "httpsms://?key={}&from={}".format("y" * 10, "5" * 11), { # use get args to acomplish the same thing "instance": NotifyHttpSMS, }, ), ( "httpsms://?key={}&from={}&to={}".format("b" * 10, "5" * 11, "7" * 13), { # use to= and key= "instance": NotifyHttpSMS, }, ), ( "httpsms://{}@{}".format("b" * 10, "5" * 11), { "instance": NotifyHttpSMS, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "httpsms://{}@{}".format("b" * 10, "5" * 11), { "instance": NotifyHttpSMS, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_httpsms_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_httpsms_edge_cases(mock_post): """NotifyHttpSMS() Edge Cases.""" # Initialize some generic (but valid) tokens apikey = "mykey123" source = "1 (405) 123 1234" targets = [ "+1(555) 123-1234", "1555 5555555", # A garbage entry "12", # NOw a valid one because a group was implicit "@12", ] # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Instantiate our object obj = Apprise.instantiate( "httpsms://{}@{}/{}".format(apikey, source, "/".join(targets)) ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # We know there are 2 targets assert len(obj) == 2 # Test our call count assert mock_post.call_count == 2 # Test details = mock_post.call_args_list[0] payload = loads(details[1]["data"]) assert payload["from"] == "+14051231234" assert payload["to"] == "+15551231234" assert payload["content"] == "title\r\nbody" details = mock_post.call_args_list[1] payload = loads(details[1]["data"]) assert payload["from"] == "+14051231234" assert payload["to"] == "+15555555555" assert payload["content"] == "title\r\nbody" # Verify our URL looks good assert obj.url().startswith( "httpsms://mykey123@14051231234/15551231234/15555555555" ) caronc-apprise-182f859/tests/test_plugin_humhub.py000066400000000000000000000513141524161175200223410ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.plugins.humhub import NotifyHumHub logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( # No host ( "humhub://", { "instance": None, }, ), # Host present but no user/token ( "humhub://hostname", { "instance": TypeError, }, ), # Token present but no container ID ( "humhub://token@hostname", { "instance": TypeError, }, ), # Invalid container ID (not numeric) ( "humhubs://token@hostname/invalid", { "instance": TypeError, }, ), # Negative container ID (not a positive integer) ( "humhubs://token@hostname/-1", { "instance": TypeError, }, ), # Zero container ID (not a positive integer) ( "humhubs://token@hostname/0", { "instance": TypeError, }, ), # Valid bearer token + container ID over HTTP (insecure) ( "humhub://mytoken@hostname/1", { "instance": NotifyHumHub, "privacy_url": "humhub://m...n@hostname/1/", "requests_response_text": '{"id": 1}', }, ), # Valid bearer token + container ID over HTTPS ( "humhubs://mytoken@hostname/1", { "instance": NotifyHumHub, "privacy_url": "humhubs://m...n@hostname/1/", "requests_response_text": '{"id": 1}', }, ), # Valid basic auth + container ID ( "humhubs://user:pass@hostname/1", { "instance": NotifyHumHub, "privacy_url": "humhubs://u...r:****@hostname/1/", "requests_response_text": '{"id": 1}', }, ), # Multiple container IDs in the path ( "humhubs://mytoken@hostname/1/2/3", { "instance": NotifyHumHub, "requests_response_text": '{"id": 1}', }, ), # Container IDs via ?to= parameter ( "humhubs://mytoken@hostname/?to=1,2,3", { "instance": NotifyHumHub, "requests_response_text": '{"id": 1}', }, ), # Custom port ( "humhubs://mytoken@hostname:8443/1", { "instance": NotifyHumHub, "requests_response_text": '{"id": 1}', }, ), # HTTP 500 response -- bearer auth ( "humhubs://mytoken@hostname/1", { "instance": NotifyHumHub, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), # Unknown error code -- bearer auth ( "humhubs://mytoken@hostname/1", { "instance": NotifyHumHub, "response": False, "requests_response_code": 999, }, ), # RequestException -- bearer auth ( "humhubs://mytoken@hostname/1", { "instance": NotifyHumHub, "test_requests_exceptions": True, }, ), # HTTP 500 response -- basic auth ( "humhubs://user:pass@hostname/1", { "instance": NotifyHumHub, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), # Unknown error code -- basic auth ( "humhubs://user:pass@hostname/1", { "instance": NotifyHumHub, "response": False, "requests_response_code": 999, }, ), # RequestException -- basic auth ( "humhubs://user:pass@hostname/1", { "instance": NotifyHumHub, "test_requests_exceptions": True, }, ), ) def test_plugin_humhub_urls(): """NotifyHumHub() Apprise URLs.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_humhub_init(): """NotifyHumHub() initialization edge cases.""" # No user/token at all with pytest.raises(TypeError): NotifyHumHub(user=None, host="hostname", targets=["1"]) # Empty string user with pytest.raises(TypeError): NotifyHumHub(user="", host="hostname", targets=["1"]) # No containers specified with pytest.raises(TypeError): NotifyHumHub(user="token", host="hostname", targets=[]) # None targets with pytest.raises(TypeError): NotifyHumHub(user="token", host="hostname", targets=None) # Only invalid targets with pytest.raises(TypeError): NotifyHumHub(user="token", host="hostname", targets=["bad", "0", "-5"]) # Mixed valid and invalid -- should succeed, dropping the bad ones obj = NotifyHumHub( user="token", host="hostname", targets=["1", "bad", "2"] ) assert len(obj.targets) == 2 assert "1" in obj.targets assert "2" in obj.targets assert len(obj._invalid_targets) == 1 assert "bad" in obj._invalid_targets # Valid bearer token obj = NotifyHumHub(user="bearertoken", host="hostname", targets=["42"]) assert obj.targets == ["42"] assert obj.password is None # Valid basic auth obj = NotifyHumHub( user="admin", password="secret", host="hostname", targets=["5"] ) assert obj.targets == ["5"] assert obj.password == "secret" @mock.patch("requests.post") def test_plugin_humhub_bearer_send(mock_post): """NotifyHumHub() bearer token send.""" # Successful response mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" obj = NotifyHumHub(user="mytoken", host="localhost", targets=["1"]) assert obj.notify(body="Test body") is True # Confirm bearer token header was set assert mock_post.called _, kwargs = mock_post.call_args assert "Authorization" in kwargs.get("headers", {}) assert kwargs["headers"]["Authorization"] == "Bearer mytoken" assert kwargs.get("auth") is None @mock.patch("requests.post") def test_plugin_humhub_basic_send(mock_post): """NotifyHumHub() basic auth send.""" # Successful response mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" obj = NotifyHumHub( user="admin", password="secret", host="localhost", targets=["1"] ) assert obj.notify(body="Test body") is True # Confirm basic auth tuple was passed and no Authorization header assert mock_post.called _, kwargs = mock_post.call_args assert kwargs.get("auth") == ("admin", "secret") assert "Authorization" not in kwargs.get("headers", {}) @mock.patch("requests.post") def test_plugin_humhub_multi_container(mock_post): """NotifyHumHub() multiple container send.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" obj = NotifyHumHub(user="token", host="localhost", targets=["1", "2", "3"]) assert obj.notify(body="Hello") is True # One POST per container assert mock_post.call_count == 3 @mock.patch("requests.post") def test_plugin_humhub_partial_failure(mock_post): """NotifyHumHub() partial failure across multiple containers.""" def side_effect_fn(*args, **kwargs): # Fail on the second call (container "2"), succeed on others if mock_post.call_count == 2: r = requests.Request() r.status_code = requests.codes.internal_server_error r.content = b"error" return r r = requests.Request() r.status_code = requests.codes.ok r.content = b"" return r mock_post.side_effect = side_effect_fn obj = NotifyHumHub(user="token", host="localhost", targets=["1", "2", "3"]) # Partial failure means overall False assert obj.notify(body="msg") is False assert mock_post.call_count == 3 @mock.patch("requests.post") def test_plugin_humhub_http_errors(mock_post): """NotifyHumHub() HTTP error responses.""" # HTTP 401 Unauthorized mock_post.return_value = requests.Request() mock_post.return_value.status_code = 401 mock_post.return_value.content = b"Unauthorized" obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.notify(body="msg") is False # HTTP 500 Internal Server Error mock_post.return_value.status_code = requests.codes.internal_server_error assert obj.notify(body="msg") is False # Unknown / unexpected code mock_post.return_value.status_code = 999 assert obj.notify(body="msg") is False @mock.patch("requests.post") def test_plugin_humhub_request_exception(mock_post): """NotifyHumHub() RequestException handling.""" mock_post.side_effect = requests.RequestException("Connection refused") obj = NotifyHumHub(user="token", host="localhost", targets=["1", "2"]) assert obj.notify(body="msg") is False # Both containers attempted assert mock_post.call_count == 2 def test_plugin_humhub_url_round_trip(): """NotifyHumHub() URL round-trip invariant.""" # Bearer token, single container obj1 = NotifyHumHub(user="mytoken", host="myhost", targets=["7"]) result = NotifyHumHub.parse_url(obj1.url()) obj2 = NotifyHumHub(**result) assert obj1.url_identifier == obj2.url_identifier assert len(obj1.targets) == len(obj2.targets) # Basic auth, multiple containers obj1 = NotifyHumHub( user="admin", password="secret", host="myhost", targets=["1", "2", "3"], ) result = NotifyHumHub.parse_url(obj1.url()) obj2 = NotifyHumHub(**result) assert obj1.url_identifier == obj2.url_identifier assert len(obj1.targets) == len(obj2.targets) # Custom port obj1 = NotifyHumHub(user="tok", host="myhost", port=9090, targets=["10"]) result = NotifyHumHub.parse_url(obj1.url()) obj2 = NotifyHumHub(**result) assert obj1.url_identifier == obj2.url_identifier # Invalid targets round-trip inline in path alongside valid ones obj1 = NotifyHumHub(user="tok", host="myhost", targets=["5", "bad", "0"]) assert len(obj1.targets) == 1 assert len(obj1._invalid_targets) == 2 # url() emits valid + invalid targets together in the path generated = obj1.url() assert "bad" in generated assert "0" in generated result = NotifyHumHub.parse_url(generated) obj2 = NotifyHumHub(**result) # obj2 re-validates: valid target "5" survives, invalid ones re-land in # _invalid_targets so the overall target count is the same assert len(obj2.targets) == 1 def test_plugin_humhub_url_parsing(): """NotifyHumHub() parse_url edge cases.""" # Bearer token from path and ?to= combined result = NotifyHumHub.parse_url("humhubs://mytoken@hostname/1/?to=2,3") assert result is not None assert "1" in result["targets"] assert "2" in result["targets"] assert "3" in result["targets"] # Basic auth result = NotifyHumHub.parse_url("humhubs://user:pass@hostname/42") assert result is not None assert result["user"] == "user" assert result["password"] == "pass" assert "42" in result["targets"] # Empty / invalid URL returns None (no host) assert NotifyHumHub.parse_url("humhubs://") is None # Only host, no user -- parse_url succeeds; TypeError raised at init result = NotifyHumHub.parse_url("humhubs://hostname/1") assert result is not None def test_plugin_humhub_privacy_url(): """NotifyHumHub() privacy URL masking.""" obj = NotifyHumHub(user="secrettoken", host="myhost", targets=["1"]) priv = obj.url(privacy=True) assert "secrettoken" not in priv assert "myhost" in priv obj2 = NotifyHumHub( user="admin", password="topsecret", host="myhost", targets=["1"] ) priv2 = obj2.url(privacy=True) assert "topsecret" not in priv2 assert "admin" not in priv2 or "****" in priv2 @mock.patch("requests.post") def test_plugin_humhub_apprise_integration(mock_post): """NotifyHumHub() Apprise integration.""" mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" aobj = Apprise() assert aobj.add("humhubs://mytoken@localhost/1") assert aobj.notify(title="Title", body="Body") is True aobj2 = Apprise() assert aobj2.add("humhubs://user:pass@localhost/1") assert aobj2.notify(title="Title", body="Body") is True @mock.patch("requests.post") def test_plugin_humhub_attachment_success(mock_post): """NotifyHumHub() attachment upload success.""" from io import BytesIO # First response: post creation with a post ID create_resp = requests.Request() create_resp.status_code = requests.codes.ok create_resp.content = b'{"id": 42}' # Second response: attachment upload success upload_resp = requests.Request() upload_resp.status_code = requests.codes.ok upload_resp.content = b"{}" mock_post.side_effect = [create_resp, upload_resp] # Build a mock accessible attachment attachment = mock.MagicMock() attachment.__bool__ = mock.MagicMock(return_value=True) attachment.name = "test.png" attachment.open = mock.MagicMock(return_value=BytesIO(b"image data")) obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.send(body="Test body", attach=[attachment]) is True # Post creation + file upload assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_humhub_attachment_multiple(mock_post): """NotifyHumHub() multiple attachments all uploaded.""" from io import BytesIO # Post creation returns a post ID create_resp = requests.Request() create_resp.status_code = requests.codes.ok create_resp.content = b'{"id": 10}' # Three attachment upload responses upload_resp = requests.Request() upload_resp.status_code = requests.codes.ok upload_resp.content = b"{}" mock_post.side_effect = [ create_resp, upload_resp, upload_resp, upload_resp, ] def make_attach(name): """Build a mock accessible attachment.""" a = mock.MagicMock() a.__bool__ = mock.MagicMock(return_value=True) a.name = name a.open = mock.MagicMock(return_value=BytesIO(b"data")) return a attachments = [ make_attach("a.png"), make_attach("b.png"), make_attach("c.png"), ] obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.send(body="msg", attach=attachments) is True # 1 creation + 3 uploads assert mock_post.call_count == 4 @mock.patch("requests.post") def test_plugin_humhub_attachment_inaccessible(mock_post): """NotifyHumHub() inaccessible attachment marks failure.""" # Post creation succeeds with a post ID create_resp = requests.Request() create_resp.status_code = requests.codes.ok create_resp.content = b'{"id": 7}' mock_post.return_value = create_resp # Build a mock inaccessible attachment (falsy) attachment = mock.MagicMock() attachment.__bool__ = mock.MagicMock(return_value=False) attachment.url = mock.MagicMock(return_value="file:///tmp/missing") obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.send(body="msg", attach=[attachment]) is False # Only the post creation call was made; upload skipped assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_humhub_attachment_no_post_id(mock_post): """NotifyHumHub() handles missing post ID in creation response.""" # Post creation returns 200 but no "id" field in the response body create_resp = requests.Request() create_resp.status_code = requests.codes.ok create_resp.content = b"{}" mock_post.return_value = create_resp attachment = mock.MagicMock() attachment.__bool__ = mock.MagicMock(return_value=True) obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.send(body="msg", attach=[attachment]) is False # Only the post creation call was made; no upload attempted assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_humhub_attachment_bad_json_response(mock_post): """NotifyHumHub() handles unparseable post creation response.""" # Post creation returns 200 but a non-JSON body create_resp = requests.Request() create_resp.status_code = requests.codes.ok create_resp.content = b"not json" mock_post.return_value = create_resp attachment = mock.MagicMock() attachment.__bool__ = mock.MagicMock(return_value=True) obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.send(body="msg", attach=[attachment]) is False assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_humhub_attachment_upload_failure(mock_post): """NotifyHumHub() attachment upload HTTP failure.""" from io import BytesIO # Post creation succeeds create_resp = requests.Request() create_resp.status_code = requests.codes.ok create_resp.content = b'{"id": 3}' # Attachment upload returns HTTP 500 upload_resp = requests.Request() upload_resp.status_code = requests.codes.internal_server_error upload_resp.content = b"error" mock_post.side_effect = [create_resp, upload_resp] attachment = mock.MagicMock() attachment.__bool__ = mock.MagicMock(return_value=True) attachment.name = "report.pdf" attachment.open = mock.MagicMock(return_value=BytesIO(b"pdf data")) obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.send(body="msg", attach=[attachment]) is False assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_humhub_attachment_request_exception(mock_post): """NotifyHumHub() RequestException during attachment upload.""" from io import BytesIO # Post creation succeeds create_resp = requests.Request() create_resp.status_code = requests.codes.ok create_resp.content = b'{"id": 5}' # Upload raises RequestException mock_post.side_effect = [ create_resp, requests.RequestException("Connection refused"), ] attachment = mock.MagicMock() attachment.__bool__ = mock.MagicMock(return_value=True) attachment.name = "file.txt" attachment.open = mock.MagicMock(return_value=BytesIO(b"text data")) obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.send(body="msg", attach=[attachment]) is False @mock.patch("requests.post") def test_plugin_humhub_attachment_oserror(mock_post): """NotifyHumHub() OSError when opening attachment file.""" # Post creation succeeds create_resp = requests.Request() create_resp.status_code = requests.codes.ok create_resp.content = b'{"id": 8}' mock_post.return_value = create_resp # Attachment open() raises OSError before requests.post is reached attachment = mock.MagicMock() attachment.__bool__ = mock.MagicMock(return_value=True) attachment.name = "secret.key" attachment.open = mock.MagicMock(side_effect=OSError("Permission denied")) obj = NotifyHumHub(user="token", host="localhost", targets=["1"]) assert obj.send(body="msg", attach=[attachment]) is False # OSError fires inside _send() before requests.post; only creation hit assert mock_post.call_count == 1 caronc-apprise-182f859/tests/test_plugin_ifttt.py000066400000000000000000000167301524161175200222060ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import NotifyType from apprise.plugins.ifttt import NotifyIFTTT logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "ifttt://", { "instance": TypeError, }, ), ( "ifttt://:@/", { "instance": TypeError, }, ), # No User ( "ifttt://EventID/", { "instance": TypeError, }, ), # A nicely formed ifttt url with 1 event and a new key/value store ( "ifttt://WebHookID@EventID/?+TemplateKey=TemplateVal", { "instance": NotifyIFTTT, # Our expected url(privacy=True) startswith() response: "privacy_url": "ifttt://W...D", }, ), # Test to= in which case we set the host to the webhook id ( "ifttt://WebHookID?to=EventID,EventID2", { "instance": NotifyIFTTT, }, ), # Removing certain keys: ( "ifttt://WebHookID@EventID/?-Value1=&-Value2", { "instance": NotifyIFTTT, }, ), # A nicely formed ifttt url with 2 events defined: ( "ifttt://WebHookID@EventID/EventID2/", { "instance": NotifyIFTTT, }, ), # Support Native URL references ( "https://maker.ifttt.com/use/WebHookID/", { # No EventID specified "instance": TypeError, }, ), ( "https://maker.ifttt.com/use/WebHookID/EventID/", { "instance": NotifyIFTTT, }, ), # Native URL with arguments ( "https://maker.ifttt.com/use/WebHookID/EventID/?-Value1=", { "instance": NotifyIFTTT, }, ), # Test website connection failures ( "ifttt://WebHookID@EventID", { "instance": NotifyIFTTT, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "ifttt://WebHookID@EventID", { "instance": NotifyIFTTT, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "ifttt://WebHookID@EventID", { "instance": NotifyIFTTT, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_ifttt_urls(): """NotifyIFTTT() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_ifttt_edge_cases(mock_post, mock_get): """NotifyIFTTT() Edge Cases.""" # Initialize some generic (but valid) tokens webhook_id = "webhook_id" events = ["event1", "event2"] # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.content = "{}" mock_post.return_value.content = "{}" # No webhook_id specified with pytest.raises(TypeError): NotifyIFTTT(webhook_id=None, events=None) # Initializes the plugin with an invalid webhook id with pytest.raises(TypeError): NotifyIFTTT(webhook_id=None, events=events) # Whitespace also acts as an invalid webhook id with pytest.raises(TypeError): NotifyIFTTT(webhook_id=" ", events=events) # No events specified with pytest.raises(TypeError): NotifyIFTTT(webhook_id=webhook_id, events=None) obj = NotifyIFTTT(webhook_id=webhook_id, events=events) assert isinstance(obj, NotifyIFTTT) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Validate outbound payload does not leak NotifyType Enum assert mock_post.call_count >= 1 call = mock_post.call_args_list[-1] payload = call[1].get("json") or call[1].get("data") or "" payload_text = payload if isinstance(payload, str) else str(payload) assert "NotifyType." not in payload_text # If JSON dict is used: if isinstance(payload, dict): assert NotifyType.INFO.value in payload_text # Test the addition of tokens obj = NotifyIFTTT( webhook_id=webhook_id, events=events, add_tokens={"Test": "ValueA", "Test2": "ValueB"}, ) assert isinstance(obj, NotifyIFTTT) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Invalid del_tokens entry with pytest.raises(TypeError): NotifyIFTTT( webhook_id=webhook_id, events=events, del_tokens=NotifyIFTTT.ifttt_default_title_key, ) assert isinstance(obj, NotifyIFTTT) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Test removal of tokens by a list obj = NotifyIFTTT( webhook_id=webhook_id, events=events, add_tokens={"MyKey": "MyValue"}, del_tokens=( NotifyIFTTT.ifttt_default_title_key, NotifyIFTTT.ifttt_default_body_key, NotifyIFTTT.ifttt_default_type_key, ), ) assert isinstance(obj, NotifyIFTTT) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Test removal of tokens as dict obj = NotifyIFTTT( webhook_id=webhook_id, events=events, add_tokens={"MyKey": "MyValue"}, del_tokens={ NotifyIFTTT.ifttt_default_title_key: None, NotifyIFTTT.ifttt_default_body_key: None, NotifyIFTTT.ifttt_default_type_key: None, }, ) assert isinstance(obj, NotifyIFTTT) caronc-apprise-182f859/tests/test_plugin_irc.py000066400000000000000000001136451524161175200216340ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Unit tests for Apprise IRC plugin and helper modules.""" from __future__ import annotations import logging import time from typing import Any, Optional from unittest import mock import pytest from apprise.plugins.irc import NotifyIRC from apprise.plugins.irc.client import IRCClient from apprise.plugins.irc.protocol import ( IRCAuthMode, IRCMessage, extract_welcome_nick, is_ping, normalise_channel, parse_irc_line, ping_payload, ) from apprise.plugins.irc.state import ( IRCActionKind, IRCContext, IRCState, IRCStateMachine, ) from apprise.utils.socket import AppriseSocketError # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) class _DummyTransport: """A lightweight SocketTransport stand-in for IRCClient tests.""" def __init__(self) -> None: self.connected = False self.closed = False self.writes: list[bytes] = [] self.reads: list[bytes] = [] def connect(self) -> None: self.connected = True def close(self) -> None: self.closed = True def write(self, payload: bytes, *, flush: bool, timeout: float) -> None: self.writes.append(payload) def read( self, max_bytes: int, *, blocking: bool, timeout: Optional[float], ) -> bytes: if not self.reads: return b"" chunk = self.reads.pop(0) return chunk[:max_bytes] def test_plugin_irc_init_targets() -> None: """NotifyIRC parses targets.""" with mock.patch.object(NotifyIRC, "apply_irc_defaults"): n = NotifyIRC( host="irc.example.com", targets=["#chan", "%23chan2:key", "@bob", "%40alice", " bad# "], ) assert "chan" in n.channels assert n.channels["chan"] is None assert "chan2" in n.channels assert n.channels["chan2"] == "key" assert "bob" in n.users assert "alice" in n.users def test_plugin_irc_modes() -> None: """NotifyIRC auth mode tests.""" with ( mock.patch.object(NotifyIRC, "apply_irc_defaults"), pytest.raises(TypeError), ): NotifyIRC(host="irc.example.com", targets=["#c"], mode="invalid") with mock.patch.object(NotifyIRC, "apply_irc_defaults"): # Case Insensitive result = NotifyIRC( host="irc.example.com", targets=["#c"], mode="NICKServ" ) assert "mode=nickserv" in result.url() # Case Insensitive result = NotifyIRC( host="irc.example.com", targets=["#c"], mode="server" ) assert "mode=" not in result.url() def test_plugin_irc_defaults_port_noop() -> None: """NotifyIRC defaults are not applied when port is explicit.""" n = NotifyIRC(host="irc.efnet.org", targets=["#c"], port=7000) n.apply_irc_defaults() assert n.port == 7000 def test_plugin_irc_defaults_template_match() -> None: """NotifyIRC defaults apply for known networks.""" n = NotifyIRC(host="irc.synirc.net", targets=["#c"]) assert n.secure is True assert n.port == 6697 assert n.auth_mode == IRCAuthMode.NICKSERV def test_plugin_irc_defaults_template_none() -> None: """NotifyIRC defaults do not force unknown networks.""" n = NotifyIRC(host="irc.unknown.tld", targets=["#c"]) assert n.secure is False def test_plugin_irc_send_no_targets() -> None: """NotifyIRC send() rejects empty targets.""" n = NotifyIRC(host="irc.example.com", targets=[]) assert n.send("body") is False def test_plugin_irc_send_ok() -> None: """NotifyIRC send() valid path.""" n = NotifyIRC( host="irc.example.com", targets=["#chan:key", "@bob"], user="me", password="pw", ) n.join = True client = mock.Mock(spec=IRCClient) client.nickname = "me" with mock.patch("apprise.plugins.irc.base.IRCClient", return_value=client): assert n.send("body", title="title") is True client.connect.assert_called_once() client.register.assert_called_once() client.join.assert_called_once() client.privmsg.assert_any_call( target="#chan", message="title body", timeout=mock.ANY, ) client.privmsg.assert_any_call( target="bob", message="title body", timeout=mock.ANY, ) client.quit.assert_called_once() client.close.assert_called_once() def test_plugin_irc_send_no_join() -> None: """NotifyIRC avoids JOIN when join is false and no key is provided.""" n = NotifyIRC(host="irc.example.com", targets=["#chan", "@bob"]) n.join = False client = mock.Mock(spec=IRCClient) client.nickname = "x" with mock.patch("apprise.plugins.irc.base.IRCClient", return_value=client): assert n.send("body") is True client.join.assert_not_called() client.privmsg.assert_any_call( target="#chan", message="body", timeout=mock.ANY, ) def test_plugin_irc_send_error() -> None: """NotifyIRC returns False on connect errors.""" n = NotifyIRC(host="irc.example.com", targets=["#chan"]) client = mock.Mock(spec=IRCClient) client.connect.side_effect = AppriseSocketError("boom") with mock.patch("apprise.plugins.irc.base.IRCClient", return_value=client): assert n.send("body") is False client.close.assert_called_once() def test_plugin_irc_url_id() -> None: """NotifyIRC url_identifier.""" n = NotifyIRC( host="irc.example.com", targets=["#c"], user="me", password="pw" ) assert n.url_identifier == ("irc", "irc.example.com", "me", "pw") n.secure = True assert n.url_identifier[0] == "ircs" def test_plugin_irc_url_format() -> None: """NotifyIRC url() basic rendering and privacy.""" n = NotifyIRC( host="irc.example.com", targets=["#chan:key", "@bob"], user="me", password="pw", secure=False, port=IRCClient.default_insecure_port, nick="nick", name="Real Name", join=False, mode=IRCAuthMode.NICKSERV, ) url = n.url(privacy=False) assert url.startswith("irc://me:pw@irc.example.com/") assert "#chan" in url assert "@bob" in url # Mode only appears when auth_mode is not SERVER. if n.auth_mode != IRCAuthMode.SERVER: assert "mode=nickserv" in url else: assert "mode=" not in url private = n.url(privacy=True) assert "pw" not in private assert "key" not in private n = NotifyIRC( host="irc.example.com", targets=["#chan", "@bob"], user="user2", secure=True, port=IRCClient.default_insecure_port, join=False, mode=IRCAuthMode.NICKSERV, ) url = n.url(privacy=False) assert url.startswith("ircs://user2@irc.example.com:6667/") assert "#chan" in url assert "@bob" in url # Mode only appears when auth_mode is not SERVER. if n.auth_mode != IRCAuthMode.SERVER: assert "mode=nickserv" in url else: assert "mode=" not in url private = n.url(privacy=True) assert "pw" not in private assert "key" not in private def test_plugin_irc_parse_url() -> None: """NotifyIRC parse_url() behaviour with host= query.""" with mock.patch( "apprise.plugins.irc.base.NotifyBase.parse_url", return_value=None, ): results = NotifyIRC.parse_url("irc://%@@") assert results is None # host= indicates the URL host is a target, it does not override host. results = NotifyIRC.parse_url("irc://%23chan?host=irc.example.com") assert results is not None assert results["host"] == "%23chan" assert "#chan" in results["targets"] results = NotifyIRC.parse_url( "irc://irc.example.com/%23a/@b?to=%23c,@d" "&join=no&name=Z&nick=N&mode=none" ) assert results is not None assert results["host"] == "irc.example.com" assert "#a" in results["targets"] assert "@b" in results["targets"] assert "#c" in results["targets"] assert "@d" in results["targets"] assert results["join"] is False assert results["name"] == "Z" assert results["nick"] == "N" assert results["mode"] == "none" def test_plugin_irc_protocol() -> None: """Protocol helpers.""" msg = parse_irc_line(":srv 001 nick :welcome") assert msg.numeric == 1 assert extract_welcome_nick(msg) == "nick" ping = parse_irc_line("PING :abc") assert is_ping(ping) is True assert ping_payload(ping) == "abc" assert normalise_channel("chan") == "#chan" assert normalise_channel("#chan") == "#chan" assert normalise_channel(" ") == "" def test_plugin_irc_state_machine() -> None: """State machine basics.""" ctx = IRCContext( desired_nick="n", accepted_nick="n", fullname="f", password="pw" ) sm = IRCStateMachine(ctx) acts = sm.start_registration() assert any( a.kind == IRCActionKind.SEND and a.line and a.line.startswith("PASS ") for a in acts ) sm.on_message(parse_irc_line(":srv 001 nick :welcome")) assert sm.ctx.registered is True assert sm.ctx.accepted_nick == "nick" ctx2 = IRCContext( desired_nick="n", accepted_nick="n", fullname="f", password="pw" ) sm2 = IRCStateMachine(ctx2) sm2.start_registration() fail = sm2.on_message(parse_irc_line(":srv 464 n :bad pass")) assert fail and fail[0].kind == IRCActionKind.FAIL ctx3 = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm3 = IRCStateMachine(ctx3) sm3.start_registration() sm3.on_message(parse_irc_line(":srv 001 n :welcome")) sm3.request_join("#c", key=None) sm3.on_message(parse_irc_line(":srv 366 n #c :End of /NAMES list.")) assert "#c" in sm3.ctx.joined def test_plugin_irc_client_nick_handling() -> None: """IRCClient nickname handling.""" nick0 = IRCClient.nick_generation(prefix="Apprise", length=9, collision=0) assert len(nick0) == 9 nick1 = IRCClient.nick_generation(prefix="Apprise", length=9, collision=12) assert len(nick1) == 9 assert nick1[-1].isdigit() client = IRCClient( host="irc.example.com", nickname="nick", fullname="full", nick_generator=None, ) with pytest.raises(AppriseSocketError): client._nickname_collision_handler(prefix="x") def gen(prefix: str, length: int, collision: int) -> str: return f"{prefix}{collision}" client = IRCClient( host="irc.example.com", nickname="nick", fullname="full", nick_generator=gen, ) client._nick_collision = int(client.nickname_collision_max) with pytest.raises(AppriseSocketError): client._nickname_collision_handler(prefix="x") def test_plugin_irc_client_handshake() -> None: """IRCClient handshake error path.""" def gen(prefix: str, length: int, collision: int) -> str: return "newnick" client = IRCClient( host="irc.example.com", nickname="nick", fullname="full", nick_generator=gen, ) client.transport = _DummyTransport() # Put the state machine into REGISTERING so 464 triggers FAIL. client.sm.start_registration() lines = [ "PING :abc", ":srv 433 nick :in use", ":srv 464 nick :bad pass", "", ] def _read(deadline: float) -> Optional[str]: return lines.pop(0) writes: list[str] = [] def _write(line: Any, deadline: float) -> None: if isinstance(line, bytes): line = line.decode("utf-8", errors="replace") writes.append(str(line)) with ( mock.patch.object(client, "_read", side_effect=_read), mock.patch.object(client, "_write", side_effect=_write), pytest.raises(AppriseSocketError), ): client._handshake(deadline=time.monotonic() + 1.0, prefix="ap") assert any(w.startswith("PONG") for w in writes) assert any(w.startswith("NICK ") for w in writes) def test_plugin_irc_client_register() -> None: """IRCClient register timeout and success.""" client = IRCClient( host="irc.example.com", nickname="nick", fullname="full", password="pw", auth_mode=IRCAuthMode.NICKSERV, ) client.transport = _DummyTransport() with ( mock.patch("time.monotonic", side_effect=[0.0, 1.0]), pytest.raises(TimeoutError), ): client.register(timeout=0.01, prefix="ap") client2 = IRCClient( host="irc.example.com", nickname="nick", fullname="full", password="pw", auth_mode=IRCAuthMode.NICKSERV, ) client2.transport = _DummyTransport() def _handshake(deadline: float, prefix: str) -> None: client2.sm.ctx.registered = True with ( mock.patch.object(client2, "_flush"), mock.patch.object(client2, "_handshake", side_effect=_handshake), mock.patch.object(client2, "identify") as m_identify, ): client2.register(timeout=0.1, prefix="ap") m_identify.assert_called_once() def test_plugin_irc_client_identify() -> None: """IRCClient identify early exits and send.""" client = IRCClient( host="irc.example.com", nickname="nick", fullname="full", password=None, auth_mode=IRCAuthMode.NICKSERV, ) client.transport = _DummyTransport() client.identify(timeout=0.1) client2 = IRCClient( host="irc.example.com", nickname="nick", fullname="full", password="pw", auth_mode=IRCAuthMode.SERVER, ) client2.transport = _DummyTransport() client2.identify(timeout=0.1) client3 = IRCClient( host="irc.example.com", nickname="nick", fullname="full", password="pw", auth_mode=IRCAuthMode.NICKSERV, ) client3.transport = _DummyTransport() with ( mock.patch.object(client3, "_flush") as m_flush, mock.patch.object(client3, "_handshake") as m_hs, ): client3.identify(timeout=0.1) m_flush.assert_called_once() m_hs.assert_called_once() def test_plugin_irc_client_join() -> None: """IRCClient join timeout path.""" client = IRCClient( host="irc.example.com", nickname="nick", fullname="full", ) client.transport = _DummyTransport() # join() calls monotonic frequently; use a callable so we never exhaust # a finite side_effect list. calls = {"n": 0} def _mono() -> float: calls["n"] += 1 # Stay below deadline long enough to exercise the loop, then exceed it. return 0.0 if calls["n"] < 2000 else 1.0 with ( mock.patch.object(client, "_flush") as m_flush, mock.patch.object(client, "_handshake") as m_hs, mock.patch("time.monotonic", side_effect=_mono), ): client.join(channel="#chan", timeout=0.01, prefix="ap", key=None) assert m_flush.called assert m_hs.called with mock.patch.object(client, "_write") as m_write: client.quit(message="bye", timeout=0.1) assert m_write.called def test_plugin_irc_protocol_parse_blank_line() -> None: """Protocol parse blank input.""" msg = parse_irc_line(" ") assert msg.command == "" assert msg.params == () assert msg.trailing is None def test_plugin_irc_protocol_parse_trailing_only() -> None: """Protocol parse trailing only.""" msg = parse_irc_line(" :hello") assert msg.command == "" assert msg.params == () assert msg.trailing == "hello" def test_plugin_irc_message() -> None: """IRCMessage testing""" message = IRCMessage("raw", None, "not-a-number", ("a", "b"), None) assert message.numeric is None def test_plugin_irc_protocol_ping_payload_from_params() -> None: """Ping payload from params.""" msg = parse_irc_line("PING abc") assert msg.trailing is None assert msg.params == ("abc",) assert ping_payload(msg) == "abc" def test_plugin_irc_protocol_extract_welcome_nick_non_welcome() -> None: """Welcome nick ignores non-001.""" msg = parse_irc_line(":srv 002 nick :Your host is") assert msg.numeric == 2 assert extract_welcome_nick(msg) is None def test_plugin_irc_protocol_normalise_channel_empty() -> None: """Normalise empty channel.""" assert normalise_channel("") == "" def test_plugin_irc_client_props_and_io() -> None: """Client basics.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() c.sm.ctx.accepted_nick = "nick" assert c.nickname == "nick" c.connect() c.close() c.transport.connect.assert_called_once() c.transport.close.assert_called_once() def test_plugin_irc_client_write_timeout() -> None: """Write timeout.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() with ( mock.patch("time.monotonic", return_value=10.0), pytest.raises(TimeoutError), ): c._write("X", deadline=9.0) def test_plugin_irc_client_write_bytes_and_flush() -> None: """Write bytes and flush queue.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() with mock.patch("time.monotonic", return_value=0.0): c._write(b"RAW", deadline=1.0) c.transport.write.assert_called_with(b"RAW", flush=True, timeout=1.0) c.transport.write.reset_mock() c._queue("A") c._queue("B") with mock.patch.object(c, "_write") as m_write: c._flush(deadline=1.0) assert m_write.call_count == 2 assert len(c._out_queue) == 0 def test_plugin_irc_client_read_buffer_and_timeout() -> None: """Read buffer and timeout.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() c._inbuf = bytearray(b"hello\r\n") assert c._read(deadline=time.monotonic() + 1.0) == "hello" c._inbuf = bytearray() with mock.patch("time.monotonic", return_value=10.0): assert c._read(deadline=9.0) is None def test_plugin_irc_client_read_transport_paths() -> None: """Read from transport.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() with mock.patch("time.monotonic", return_value=0.0): c.transport.read.return_value = b"" assert c._read(deadline=1.0) is None c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() with mock.patch("time.monotonic", side_effect=[0.0, 0.0, 0.0]): c.transport.read.side_effect = [b"he", b"llo\n"] assert c._read(deadline=1.0) == "hello" def test_plugin_irc_client_tick() -> None: """Tick timing.""" c = IRCClient(host="h", nickname="n", fullname="f") with mock.patch("time.monotonic", return_value=10.0): assert c._tick(deadline=9.0) == pytest.approx(9.0) with mock.patch("time.monotonic", return_value=0.0): # remaining=0.5 -> 0.0 + 0.5 assert c._tick(deadline=0.5) == pytest.approx(0.5) def test_plugin_irc_client_handshake_paths() -> None: """Handshake paths.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() with mock.patch.object(c, "_read", return_value=None): c._handshake(deadline=time.monotonic() + 1.0, prefix="") lines = ["PING :abc", ""] with ( mock.patch.object( c, "_read", side_effect=lambda deadline: lines.pop(0) ), mock.patch.object(c, "_write") as m_write, ): c._handshake(deadline=time.monotonic() + 1.0, prefix="") m_write.assert_any_call("PONG :abc", deadline=mock.ANY) lines = [":srv 433 n :in use", ""] with ( mock.patch.object( c, "_read", side_effect=lambda deadline: lines.pop(0) ), mock.patch.object( c, "_nickname_collision_handler", return_value="new" ), mock.patch.object(c, "_write") as m_write, ): c._handshake(deadline=time.monotonic() + 1.0, prefix="ap") m_write.assert_any_call("NICK new", deadline=mock.ANY) # FAIL action raises from apprise.plugins.irc.state import IRCAction, IRCActionKind lines = [":srv 464 n :bad pass", ""] with ( mock.patch.object( c, "_read", side_effect=lambda deadline: lines.pop(0) ), mock.patch.object( c.sm, "on_message", return_value=[IRCAction(IRCActionKind.FAIL, reason="boom")], ), pytest.raises(AppriseSocketError), ): c._handshake(deadline=time.monotonic() + 1.0, prefix="") # SEND action writes line lines = [":srv 001 n :welcome", ""] with ( mock.patch.object( c, "_read", side_effect=lambda deadline: lines.pop(0) ), mock.patch.object( c.sm, "on_message", return_value=[IRCAction(IRCActionKind.SEND, line="NICK x")], ), mock.patch.object(c, "_write") as m_write, ): c._handshake(deadline=time.monotonic() + 1.0, prefix="") m_write.assert_any_call("NICK x", deadline=mock.ANY) def test_plugin_irc_client_register_auth_modes() -> None: """Register auth modes.""" c = IRCClient( host="h", nickname="n", fullname="f", password="pw", auth_mode=IRCAuthMode.NICKSERV, ) c.transport = mock.Mock() # Force loop to exit by setting registered in handshake def _hs(deadline: float, prefix: str) -> None: c.sm.ctx.registered = True with ( mock.patch.object(c, "_flush"), mock.patch.object(c, "_handshake", side_effect=_hs), mock.patch.object(c, "identify") as m_ident, mock.patch("time.monotonic", return_value=0.0), mock.patch("time.time", side_effect=[0.0, 0.1, 0.2]), ): c.register(timeout=1.0, prefix="ap") # password cleared before registration when not SERVER assert c.sm.ctx.password is None m_ident.assert_called_once() c2 = IRCClient( host="h", nickname="n", fullname="f", password="pw", auth_mode=IRCAuthMode.SERVER, ) c2.transport = mock.Mock() def _hs2(deadline: float, prefix: str) -> None: c2.sm.ctx.registered = True with ( mock.patch.object(c2, "_flush"), mock.patch.object(c2, "_handshake", side_effect=_hs2), mock.patch.object(c2, "identify") as m_ident2, mock.patch("time.monotonic", return_value=0.0), mock.patch("time.time", side_effect=[0.0, 0.1, 0.2]), ): c2.register(timeout=1.0, prefix="ap") # identify not called for SERVER m_ident2.assert_not_called() def test_plugin_irc_client_join_privmsg_identify_quit() -> None: """Join and message helpers.""" c = IRCClient( host="h", nickname="n", fullname="f", password="pw", auth_mode=IRCAuthMode.NICKSERV, ) c.transport = mock.Mock() calls = {"n": 0} def _mono() -> float: calls["n"] += 1 # Return 0.0 long enough to enter the loop, then jump past deadline # to ensure join() exits. return 0.0 if calls["n"] < 5000 else 1.0 with ( mock.patch.object(c, "_flush") as m_flush, mock.patch.object(c, "_handshake") as m_hs, mock.patch("time.monotonic", side_effect=_mono), ): # join never confirmed -> debug path with mock.patch("apprise.plugins.irc.client.logger.debug") as m_dbg: c.join(channel="#c", timeout=0.01, prefix="ap", key=None) m_dbg.assert_called() assert m_flush.called assert m_hs.called # privmsg() and identify() also call monotonic; keep them in a separate # context with a simple stable time. with ( mock.patch.object(c, "_flush") as m_flush2, mock.patch.object(c, "_handshake") as m_hs2, mock.patch("time.monotonic", return_value=0.0), ): c.privmsg(target="#c", message="m", timeout=0.1) c.identify(timeout=0.1) assert m_flush2.called assert m_hs2.called # identify exits early c2 = IRCClient( host="h", nickname="n", fullname="f", password=None, auth_mode=IRCAuthMode.NICKSERV, ) c2.transport = mock.Mock() with mock.patch.object(c2, "_flush") as m_flush3: c2.identify(timeout=0.1) m_flush3.assert_not_called() c3 = IRCClient( host="h", nickname="n", fullname="f", password="pw", auth_mode=IRCAuthMode.SERVER, ) c3.transport = mock.Mock() with mock.patch.object(c3, "_flush") as m_flush4: c3.identify(timeout=0.1) m_flush4.assert_not_called() # quit queues and flushes c4 = IRCClient(host="h", nickname="n", fullname="f") c4.transport = mock.Mock() with mock.patch.object(c4, "_flush") as m_flush5: c4.quit(message="bye", timeout=0.1) m_flush5.assert_called_once() def test_plugin_irc_client_nick_generation_default_length() -> None: """Nick generation defaults.""" nick = IRCClient.nick_generation(prefix="Ap", length=None, collision=0) assert len(nick) == IRCClient.nickname_max_length def test_plugin_irc_client_write_trace() -> None: """Write trace logging.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() with ( mock.patch("time.monotonic", return_value=0.0), mock.patch( "apprise.plugins.irc.client.logger.isEnabledFor", return_value=True ), mock.patch("apprise.plugins.irc.client.logger.trace") as m_trace, ): c._write("HELLO", deadline=1.0) m_trace.assert_called_once() # Ensure we wrote CRLF terminated bytes c.transport.write.assert_called_once() payload = c.transport.write.call_args.args[0] assert payload.endswith(b"\r\n") def test_plugin_irc_client_handshake_send_without_line() -> None: """Handshake ignores empty SEND.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() from apprise.plugins.irc.state import IRCAction, IRCActionKind # One message then stop the loop lines = [":srv 001 n :welcome", ""] with ( mock.patch.object( c, "_read", side_effect=lambda deadline: lines.pop(0) ), mock.patch.object( c.sm, "on_message", return_value=[IRCAction(IRCActionKind.SEND, line="")], ), mock.patch.object(c, "_write") as m_write, ): c._handshake(deadline=time.monotonic() + 1.0, prefix="") m_write.assert_not_called() def test_plugin_irc_client_register_queue_ignores_empty_send() -> None: """Register ignores empty SEND.""" c = IRCClient( host="h", nickname="n", fullname="f", auth_mode=IRCAuthMode.NONE ) c.transport = mock.Mock() from apprise.plugins.irc.state import IRCAction, IRCActionKind with ( mock.patch.object( c.sm, "start_registration", return_value=[ IRCAction(IRCActionKind.SEND, line=None), IRCAction(IRCActionKind.SEND, line=""), ], ), mock.patch.object(c, "_queue") as m_queue, mock.patch.object(c, "_flush"), mock.patch.object(c, "_handshake"), mock.patch("time.monotonic", return_value=0.0), mock.patch("time.time", return_value=0.0), ): # Make it "already registered" to avoid the while loop behaviour c.sm.ctx.registered = True c.register(timeout=0.1, prefix="ap") m_queue.assert_not_called() def test_plugin_irc_client_join_queue_ignores_empty_send() -> None: """Join ignores empty SEND.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() from apprise.plugins.irc.state import IRCAction, IRCActionKind with ( mock.patch.object( c.sm, "request_join", return_value=[ IRCAction(IRCActionKind.SEND, line=None), IRCAction(IRCActionKind.SEND, line=""), ], ), mock.patch.object(c, "_queue") as m_queue, mock.patch.object(c, "_flush"), mock.patch.object(c, "_handshake"), mock.patch("time.monotonic", return_value=1.0), ): # deadline will be 1.0 + timeout, but we also ensure we do not enter # loop by setting joined to include channel. c.sm.ctx.joined.add("#c") c.join(channel="#c", timeout=0.1, prefix="ap", key=None) m_queue.assert_not_called() def test_plugin_irc_client_quit_queue_ignores_empty_send() -> None: """Quit ignores empty SEND.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() from apprise.plugins.irc.state import IRCAction, IRCActionKind with ( mock.patch.object( c.sm, "request_quit", return_value=[ IRCAction(IRCActionKind.SEND, line=None), IRCAction(IRCActionKind.SEND, line=""), ], ), mock.patch.object(c, "_queue") as m_queue, mock.patch.object(c, "_flush") as m_flush, mock.patch("time.monotonic", return_value=0.0), ): c.quit(message="bye", timeout=0.1) m_queue.assert_not_called() m_flush.assert_called_once() def test_plugin_irc_send_znc_calls_check_connection() -> None: """NotifyIRC ZNC mode triggers a connection check.""" n = NotifyIRC( host="irc.example.com", targets=["#chan"], user="zncuser", password="zncpass", mode=IRCAuthMode.ZNC, nick="mybot", ) client = mock.Mock(spec=IRCClient) client.nickname = "mybot" client.check_connection.return_value = True with mock.patch("apprise.plugins.irc.base.IRCClient", return_value=client): assert n.send("body") is True client.connect.assert_called_once() client.register.assert_called_once() client.check_connection.assert_called_once() client.privmsg.assert_called_once() client.quit.assert_called_once() client.close.assert_called_once() def test_plugin_irc_send_znc_check_connection_failure() -> None: """NotifyIRC ZNC mode fails fast if the bouncer liveness check fails.""" n = NotifyIRC( host="irc.example.com", targets=["#chan"], user="zncuser", password="zncpass", mode=IRCAuthMode.ZNC, nick="mybot", ) client = mock.Mock(spec=IRCClient) client.nickname = "mybot" client.check_connection.return_value = False with mock.patch("apprise.plugins.irc.base.IRCClient", return_value=client): assert n.send("body") is False client.connect.assert_called_once() client.register.assert_called_once() client.check_connection.assert_called_once() client.privmsg.assert_not_called() client.quit.assert_not_called() client.close.assert_called_once() def test_plugin_irc_send_znc_pass_rewrite() -> None: """NotifyIRC ZNC mode passes user:pass to IRCClient password.""" n = NotifyIRC( host="irc.example.com", targets=["#chan"], user="zncuser", password="zncpass", mode=IRCAuthMode.ZNC, nick="mybot", ) client = mock.Mock(spec=IRCClient) client.nickname = "mybot" client.check_connection.return_value = True with mock.patch( "apprise.plugins.irc.base.IRCClient", return_value=client ) as m_ctor: assert n.send("body") is True # Pull the kwargs passed into IRCClient(...) kwargs = m_ctor.call_args.kwargs assert kwargs["password"] == "zncuser:zncpass" def test_plugin_irc_client_check_connection_success_on_any_pong() -> None: """check_connection() returns True when a PONG is observed.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() # 0.0 used to compute deadline, then remain within deadline once, then stop times = [0.0, 0.0, 0.0] def _mono() -> float: return times.pop(0) if times else 10.0 with ( mock.patch("time.monotonic", side_effect=_mono), mock.patch.object(c, "_write") as m_write, mock.patch.object(c, "_read", side_effect=["PONG :server"]) as m_read, ): assert c.check_connection(timeout=5.0) is True m_write.assert_called_once() m_read.assert_called() def test_plugin_irc_client_handles_empty_reads() -> None: """check_connection() handles empty reads and returns False at deadline.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() # Start at 0.0, enter loop once, then exceed deadline after the empty read. times = [0.0, 0.0, 6.0] def _mono() -> float: return times.pop(0) with ( mock.patch("time.monotonic", side_effect=_mono), mock.patch.object(c, "_write") as m_write, mock.patch.object(c, "_read", return_value=None), ): assert c.check_connection(timeout=5.0) is False m_write.assert_called_once() def test_plugin_irc_client_join_queues_join_line_and_completes() -> None: """join() queues the JOIN command when request_join yields a SEND line.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() def _handshake(deadline: float, prefix: str) -> None: # Simulate join confirmation being observed c.sm.ctx.joined.add("#c") with ( mock.patch("time.monotonic", return_value=0.0), mock.patch.object(c, "_queue") as m_queue, mock.patch.object(c, "_flush"), mock.patch.object(c, "_handshake", side_effect=_handshake), ): c.join(channel="#c", timeout=1.0, prefix="ap", key=None) # Ensure the queue branch was taken assert any( call.args and isinstance(call.args[0], str) and call.args[0].startswith("JOIN ") for call in m_queue.call_args_list ) def test_plugin_irc_client_join_timeout_logs_debug() -> None: """join() emits a debug line when confirmation is not observed.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() # timeout=0.0 forces the while-loop to be skipped and triggers debug path with ( mock.patch("time.monotonic", return_value=0.0), mock.patch("apprise.plugins.irc.client.logger.debug") as m_dbg, mock.patch.object(c, "_flush"), mock.patch.object(c, "_handshake"), ): c.join(channel="#c", timeout=0.0, prefix="ap", key=None) m_dbg.assert_called_once() def test_plugin_irc_state_machine_joining_numeric_443_adds_channel() -> None: """State machine treats numeric 443 as joined confirmation.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) # Enter JOINING state sm.request_join("#c") assert sm.state == IRCState.JOINING # 443 usually indicates already on channel; this should mark as joined. sm.on_message(parse_irc_line(":srv 443 n #c :is already on channel")) assert "#c" in sm.ctx.joined assert sm.state == IRCState.READY def test_plugin_irc_client_connection_check() -> None: """check_connection() sees a non-PONG message.""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() # monotonic() is used to compute deadline, then loop condition. # We provide values such that we enter the loop at least once, then # exceed deadline. times = [0.0, 0.0, 0.0, 6.0] def _mono() -> float: return times.pop(0) with ( mock.patch("time.monotonic", side_effect=_mono), mock.patch.object(c, "_write") as m_write, # First read returns a non-empty line that is not a PONG, then nothing. mock.patch.object(c, "_read", side_effect=["PING :abc", None]), ): assert c.check_connection(timeout=5.0) is False m_write.assert_called_once() def test_plugin_irc_client_join_non_send_actions() -> None: """join() ignores non-SEND actions""" c = IRCClient(host="h", nickname="n", fullname="f") c.transport = mock.Mock() from apprise.plugins.irc.state import IRCAction, IRCActionKind # Return a NOOP action so: act.kind == SEND is False actions = [IRCAction(IRCActionKind.NOOP)] # Skip the while loop by making monotonic() already past deadline with ( mock.patch.object(c.sm, "request_join", return_value=actions), mock.patch.object(c, "_queue") as m_queue, mock.patch.object(c, "_flush") as m_flush, mock.patch.object(c, "_handshake") as m_hs, mock.patch("time.monotonic", side_effect=[0.0, 1.0]), mock.patch("apprise.plugins.irc.client.logger.debug") as m_dbg, ): c.join(channel="#c", timeout=0.0, prefix="ap", key=None) # Nothing queued because our action was not SEND m_queue.assert_not_called() # While loop is skipped, so these are not called m_flush.assert_not_called() m_hs.assert_not_called() # Not joined, so debug path executes m_dbg.assert_called_once() caronc-apprise-182f859/tests/test_plugin_irc_state.py000066400000000000000000000241051524161175200230240ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Tests for IRC state machine.""" from __future__ import annotations from apprise.plugins.irc.protocol import parse_irc_line from apprise.plugins.irc.state import ( IRCActionKind, IRCContext, IRCState, IRCStateMachine, _err, ) def test_plugin_irc_state_err_trailing() -> None: """Error text from trailing.""" msg = parse_irc_line(":srv 464 nick :bad pass") assert _err(msg) == "bad pass" def test_plugin_irc_state_err_params() -> None: """Error text from params.""" msg = parse_irc_line(":srv 471 nick #c") # no trailing, params join assert _err(msg) == "nick #c" def test_plugin_irc_state_err_default() -> None: """Error text default.""" msg = parse_irc_line(":srv 471") assert _err(msg) == "IRC error" def test_plugin_irc_state_ignore_when_error_or_quitting() -> None: """Ignore messages when exiting.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.state = IRCState.ERROR assert sm.on_message(parse_irc_line("PING :x")) == [] sm.state = IRCState.QUITTING assert sm.on_message(parse_irc_line(":srv 001 n :welcome")) == [] def test_plugin_irc_state_register_error_sets_last_error() -> None: """Register errors fail.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() actions = sm.on_message(parse_irc_line(":srv 464 n :bad pass")) assert sm.state == IRCState.ERROR assert ctx.last_error is not None assert actions and actions[0].kind == IRCActionKind.FAIL assert "Password incorrect" in (actions[0].reason or "") def test_plugin_irc_state_register_collision_433_sends_new_nick() -> None: """Nick collision 433.""" ctx = IRCContext(desired_nick="new", accepted_nick="old", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() actions = sm.on_message(parse_irc_line(":srv 433 old :in use")) assert actions and actions[0].kind == IRCActionKind.SEND assert actions[0].line == "NICK new" def test_plugin_irc_state_register_collision_432_sends_new_nick() -> None: """Nick collision 432.""" ctx = IRCContext(desired_nick="new", accepted_nick="old", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() actions = sm.on_message(parse_irc_line(":srv 432 old :bad nick")) assert actions and actions[0].kind == IRCActionKind.SEND assert actions[0].line == "NICK new" def test_plugin_irc_state_register_welcome_sets_accepted() -> None: """Welcome sets accepted nick.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() sm.on_message(parse_irc_line(":srv 001 nick :welcome")) assert ctx.accepted_nick == "nick" assert ctx.registered is True assert sm.state == IRCState.READY def test_plugin_irc_state_register_welcome() -> None: """Welcome without nick keeps accepted.""" ctx = IRCContext(desired_nick="n", accepted_nick="keep", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() # No nick in params, extract_welcome_nick() returns empty/None. sm.on_message(parse_irc_line(":srv 001 :welcome")) assert ctx.accepted_nick == "keep" assert ctx.registered is True assert sm.state == IRCState.READY def test_plugin_irc_state_register_motd_done_before_registered() -> None: """MOTD sets motd_done.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() sm.on_message(parse_irc_line(":srv 422 n :MOTD missing")) assert ctx.motd_done is True assert sm.state == IRCState.REGISTERING def test_plugin_irc_state_register_motd_done_376_before_registered() -> None: """MOTD 376 sets motd_done.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() sm.on_message(parse_irc_line(":srv 376 n :End of MOTD")) assert ctx.motd_done is True assert ctx.registered is False assert sm.state == IRCState.REGISTERING def test_plugin_irc_state_register_motd_done_after_registered() -> None: """MOTD sets ready when registered.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() # This branch requires REGISTERING when 376/422 arrives. ctx.registered = True sm.state = IRCState.REGISTERING sm.on_message(parse_irc_line(":srv 376 n :End of MOTD")) assert ctx.motd_done is True assert sm.state == IRCState.READY def test_plugin_irc_state_join_error_sets_last_error() -> None: """Join errors fail.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.request_join("#c", key=None) actions = sm.on_message(parse_irc_line(":srv 475 n #c :Bad key")) assert sm.state == IRCState.ERROR assert ctx.last_error is not None assert actions and actions[0].kind == IRCActionKind.FAIL assert "Bad channel key" in (actions[0].reason or "") def test_plugin_irc_state_join_numeric_366_adds_channel() -> None: """Join complete 366.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.request_join("#c", key=None) sm.on_message(parse_irc_line(":srv 366 n #c :End of /NAMES list.")) assert "#c" in ctx.joined assert sm.state == IRCState.READY def test_plugin_irc_state_join_command_trailing() -> None: """Join complete JOIN trailing.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.request_join("#c", key=None) sm.on_message(parse_irc_line(":nick!u@h JOIN :#c")) assert "#c" in ctx.joined assert sm.state == IRCState.READY def test_plugin_irc_state_join_command_params() -> None: """Join complete JOIN params.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.request_join("#c", key=None) sm.on_message(parse_irc_line(":nick!u@h JOIN #d")) assert "#d" in ctx.joined assert sm.state == IRCState.READY def test_plugin_irc_state_request_join_key_and_no_key() -> None: """Join requests render.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) a1 = sm.request_join("#c", key=None) assert sm.state == IRCState.JOINING assert a1 and a1[0].line == "JOIN #c" a2 = sm.request_join("#c", key="k") assert a2 and a2[0].line == "JOIN #c k" def test_plugin_irc_state_request_quit() -> None: """Quit request renders.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) actions = sm.request_quit("bye") assert sm.state == IRCState.QUITTING assert actions and actions[0].line == "QUIT :bye" def test_plugin_irc_state_register_unhandled_numeric() -> None: """Register ignores unhandled numerics.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.start_registration() # Numeric 2 is not handled by REGISTERING logic actions = sm.on_message(parse_irc_line(":srv 002 n :Your host is")) assert actions == [] assert sm.state == IRCState.REGISTERING assert ctx.registered is False assert ctx.motd_done is False def test_plugin_irc_state_join_command_empty_channel() -> None: """Join ignores empty JOIN channel.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.request_join("#c", key=None) # JOIN with no params and no trailing yields empty channel actions = sm.on_message(parse_irc_line("JOIN")) assert actions == [] assert ctx.joined == set() assert sm.state == IRCState.JOINING def test_plugin_irc_state_ready_falls_through() -> None: """Ready falls through to default return.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.state = IRCState.READY # Not handled in READY state, should return empty actions via final return actions = sm.on_message(parse_irc_line(":nick!u@h PRIVMSG #c :hi")) assert actions == [] assert sm.state == IRCState.READY def test_plugin_irc_state_join_non_join_command() -> None: """Join ignores non-JOIN commands.""" ctx = IRCContext(desired_nick="n", accepted_nick="n", fullname="f") sm = IRCStateMachine(ctx) sm.request_join("#c", key=None) # Not a JOIN command, not a join numeric, and not an error actions = sm.on_message(parse_irc_line(":nick!u@h PRIVMSG #c :hi")) assert actions == [] assert sm.state == IRCState.JOINING assert ctx.joined == set() caronc-apprise-182f859/tests/test_plugin_jellyfin.py000066400000000000000000000062531524161175200226670ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester from apprise import Apprise from apprise.plugins import jellyfin logging.disable(logging.CRITICAL) # Our Testing URLs # Note: The majority of tests are already handled by the emby:// adaptation apprise_url_tests = ( # Insecure Request; no hostname specified ( "jellyfin://", { "instance": None, }, ), # Secure Request; no hostname specified ( "jellyfins://", { "instance": None, }, ), # No user specified ( "jellyfin://localhost", { # Missing a username "instance": TypeError, }, ), ( "jellyfin://:@/", { "instance": None, }, ), # Valid Authentication (we do not validate credentials here) ( "jellyfin://l2g@localhost", { "instance": jellyfin.NotifyJellyfin, # Authentication can't be validated through these unit tests "response": False, }, ), ( "jellyfins://l2g:password@localhost", { "instance": jellyfin.NotifyJellyfin, "response": False, "privacy_url": "jellyfins://l2g:****@localhost", }, ), ) def test_plugin_jellyfin_urls(): """NotifyJellyfin() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_jellyfin_instantiation(): """NotifyJellyfin() instantiation tests.""" obj = Apprise.instantiate("jellyfin://l2g:l2gpass@localhost") assert isinstance(obj, jellyfin.NotifyJellyfin) obj = Apprise.instantiate("jellyfins://l2g:l2gpass@localhost") assert isinstance(obj, jellyfin.NotifyJellyfin) caronc-apprise-182f859/tests/test_plugin_jira.py000066400000000000000000000440671524161175200220050ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2025, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests import apprise from apprise.plugins.jira import JiraPriority, NotifyJira, NotifyType logging.disable(logging.CRITICAL) # a test UUID we can use UUID4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752" JIRA_GOOD_RESPONSE = dumps( { "result": "Request will be processed", "took": 0.204, "requestId": "43a29c5c-3dbf-4fa4-9c26-f4f71023e120", } ) # Our Testing URLs apprise_url_tests = ( ( "jira://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "jira://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "jira://%20%20/", { # invalid apikey specified "instance": TypeError, }, ), ( "jira://apikey/user/?region=xx", { # invalid region id "instance": TypeError, }, ), ( "jira://user@apikey/", { # No targets specified; this is allowed "instance": NotifyJira, "notify_type": NotifyType.WARNING, # Bad response returned "requests_response_text": "{", # We will not be successful sending the notice "notify_response": False, }, ), ( "jira://apikey/", { # No targets specified; this is allowed "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/user", { # Valid user "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, "privacy_url": "jira://a...y/%40user", }, ), ( "jira://apikey/@user?region=eu", { # European Region "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/@user?entity=A%20Entity", { # Assign an entity "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/@user?alias=An%20Alias", { # Assign an alias "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), # Bad Action ( "jira://apikey/@user?action=invalid", { # Assign an entity "instance": TypeError, }, ), ( "jira://from@apikey/@user?:invalid=note", { # Assign an entity "instance": TypeError, }, ), ( "jira://apikey/@user?:warning=invalid", { # Assign an entity "instance": TypeError, }, ), # Creates an index entry ( "jira://apikey/@user?entity=index&action=new", { # Assign an entity "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), # Now action it ( "jira://apikey/@user?entity=index&action=acknowledge", { # Assign an entity "instance": NotifyJira, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://from@apikey/@user?entity=index&action=note", { # Assign an entity "instance": NotifyJira, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://from@apikey/@user?entity=index&action=note", { # Assign an entity "instance": NotifyJira, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, "response": False, "requests_response_code": 500, }, ), ( "jira://apikey/@user?entity=index&action=close", { # Assign an entity "instance": NotifyJira, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/@user?entity=index&action=delete", { # Assign an entity "instance": NotifyJira, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), # map info messages to generate a new message ( "jira://apikey/@user?entity=index2&:info=new", { # Assign an entity "instance": NotifyJira, "notify_type": NotifyType.INFO, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://joe@apikey/@user?priority=p3", { # Assign our priority "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/?tags=comma,separated", { # Test our our 'tags' (tag is reserved in Apprise) but not 'tags' # Also test the fact we do not need to define a target "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/@user?priority=invalid", { # Invalid priority (loads using default) "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/user@email.com/#team/*sche/^esc/%20/a", { # Valid user (email), valid schedule, Escalated ID, # an invalid entry (%20), and too short of an entry (a) "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/@{}/#{}/*{}/^{}/".format(UUID4, UUID4, UUID4, UUID4), { # similar to the above, except we use the UUID's "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), # Same link as before but @ missing at the front causing an ambigious # lookup however the entry is treated a though a @ was in front (user) ( "jira://apikey/{}/#{}/*{}/^{}/".format(UUID4, UUID4, UUID4, UUID4), { # similar to the above, except we use the UUID's "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey?to=#team,user&+key=value&+type=override", { # Test to= and details (key/value pair) also override 'type' "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/#team/@user/?batch=yes", { # Test batch= "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/#team/@user/?batch=no", { # Test batch= "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://?apikey=abc&to=user", { # Test Kwargs "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, }, ), ( "jira://apikey/#team/user/", { "instance": NotifyJira, # throw a bizzare code forcing us to fail to look it up "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, "response": False, "requests_response_code": 999, }, ), ( "jira://apikey/#topic1/device/", { "instance": NotifyJira, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": JIRA_GOOD_RESPONSE, # Throws a series of connection and transfer exceptions when this # flag is set and tests that we gracfully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_jira_urls(tmpdir): """ NotifyJira() Apprise URLs """ # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all(str(tmpdir)) @mock.patch("requests.post") def test_plugin_jira_config_files(mock_post): """ NotifyJira() Config File Cases """ content = """ urls: - jira://apikey/user: - priority: 1 tag: jira_int low - priority: "1" tag: jira_str_int low - priority: "p1" tag: jira_pstr_int low - priority: low tag: jira_str low # This will take on moderate (default) priority - priority: invalid tag: jira_invalid - jira://apikey2/user2: - priority: 5 tag: jira_int emerg - priority: "5" tag: jira_str_int emerg - priority: "p5" tag: jira_pstr_int emerg - priority: emergency tag: jira_str emerg """ # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = JIRA_GOOD_RESPONSE # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 9 servers from that # 4x low # 4x emerg # 1x invalid (so takes on normal priority) assert len(ac.servers()) == 9 assert len(aobj) == 9 assert len(list(aobj.find(tag="low"))) == 4 for s in aobj.find(tag="low"): assert s.priority == JiraPriority.LOW assert len(list(aobj.find(tag="emerg"))) == 4 for s in aobj.find(tag="emerg"): assert s.priority == JiraPriority.EMERGENCY assert len(list(aobj.find(tag="jira_str"))) == 2 assert len(list(aobj.find(tag="jira_str_int"))) == 2 assert len(list(aobj.find(tag="jira_pstr_int"))) == 2 assert len(list(aobj.find(tag="jira_int"))) == 2 assert len(list(aobj.find(tag="jira_invalid"))) == 1 assert next(aobj.find(tag="jira_invalid")).priority == JiraPriority.NORMAL @mock.patch("requests.post") def test_plugin_jira_edge_case(mock_post): """ NotifyJira() Edge Cases """ # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = JIRA_GOOD_RESPONSE instance = apprise.Apprise.instantiate("jira://apikey") assert isinstance(instance, NotifyJira) assert len(instance.store.keys()) == 0 assert instance.notify("test", "key", NotifyType.FAILURE) is True assert len(instance.store.keys()) == 1 # Again just causes same index to get over-written assert instance.notify("test", "key", NotifyType.FAILURE) is True assert len(instance.store.keys()) == 1 assert "a62f2225bf" in instance.store # Assign it garbage instance.store["a62f2225bf"] = "garbage" # This causes an internal check to fail where the keys are expected to be # as a list (this one is now a string) # content self corrects and things are fine assert instance.notify("test", "key", NotifyType.FAILURE) is True assert len(instance.store.keys()) == 1 # new key is new index assert instance.notify("test", "key2", NotifyType.FAILURE) is True assert len(instance.store.keys()) == 2 @mock.patch("requests.post") def test_plugin_jira_mapping(mock_post): """ NotifyJira() Action Mapping Verify that custom :key=value URL mappings correctly override the default action taken for each notification type, and that self.mapping (not the removed module-level constant) is used in send(). """ # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = JIRA_GOOD_RESPONSE base_url = "https://api.atlassian.com/jsm/ops/integration/v2/alerts" # --- Default mapping: INFO β†’ NOTE --- # INFO adds a note to an existing alert; with no stored request IDs there # is nothing to annotate, so no HTTP call is made. send() still returns # True (nothing to do is not an error). instance = apprise.Apprise.instantiate("jira://apikey/@user") assert isinstance(instance, NotifyJira) assert instance.notify("body", "title", NotifyType.INFO) is True assert mock_post.call_count == 0 # --- Default mapping: WARNING/FAILURE β†’ NEW --- # These should POST to the base alerts URL immediately. mock_post.reset_mock() assert instance.notify("body", "title", NotifyType.WARNING) is True assert mock_post.call_count == 1 assert mock_post.call_args[0][0] == base_url mock_post.reset_mock() assert instance.notify("body", "title", NotifyType.FAILURE) is True assert mock_post.call_count == 1 assert mock_post.call_args[0][0] == base_url # --- Default mapping: SUCCESS β†’ CLOSE --- # instance already has stored request IDs from the WARNING/FAILURE calls # above, so SUCCESS should POST to the /close endpoint. mock_post.reset_mock() assert instance.notify("body", "title", NotifyType.SUCCESS) is True assert mock_post.call_count >= 1 assert mock_post.call_args[0][0].endswith("/close") # Fresh instance: SUCCESS with no stored IDs β†’ nothing to close, no call. fresh = apprise.Apprise.instantiate("jira://apikey/@user") mock_post.reset_mock() assert fresh.notify("body", "title", NotifyType.SUCCESS) is True assert mock_post.call_count == 0 # --- Custom mapping: :info=new overrides INFO β†’ NOTE with INFO β†’ NEW --- instance2 = apprise.Apprise.instantiate("jira://apikey/@user?:info=new") assert isinstance(instance2, NotifyJira) mock_post.reset_mock() assert instance2.notify("body", "title", NotifyType.INFO) is True # A POST to the base alerts URL must have been made assert mock_post.call_count == 1 assert mock_post.call_args[0][0] == base_url # --- URL round-trip preserves custom mapping --- # The colon prefix is kept unencoded for readability. url = instance2.url() assert ":info=new" in url # Re-instantiate from the reconstructed URL and verify the mapping instance3 = apprise.Apprise.instantiate(url) assert isinstance(instance3, NotifyJira) assert instance3.mapping[NotifyType.INFO] == "new" mock_post.reset_mock() assert instance3.notify("body", "title", NotifyType.INFO) is True assert mock_post.call_count == 1 assert mock_post.call_args[0][0] == base_url caronc-apprise-182f859/tests/test_plugin_join.py000066400000000000000000000173711524161175200220150ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests import apprise from apprise import NotifyType from apprise.plugins.join import JoinPriority, NotifyJoin logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "join://", { "instance": TypeError, }, ), # API Key + bad url ( "join://:@/", { "instance": TypeError, }, ), # APIkey; no device ( "join://%s" % ("a" * 32), { "instance": NotifyJoin, }, ), # API Key + device (using to=) ( "join://{}?to={}".format("a" * 32, "d" * 32), { "instance": NotifyJoin, # Our expected url(privacy=True) startswith() response: "privacy_url": "join://a...a/", }, ), # API Key + priority setting ( "join://%s?priority=high" % ("a" * 32), { "instance": NotifyJoin, }, ), # API Key + invalid priority setting ( "join://%s?priority=invalid" % ("a" * 32), { "instance": NotifyJoin, }, ), # API Key + priority setting (empty) ( "join://%s?priority=" % ("a" * 32), { "instance": NotifyJoin, }, ), # API Key + device ( "join://{}@{}?image=True".format("a" * 32, "d" * 32), { "instance": NotifyJoin, }, ), # No image ( "join://{}@{}?image=False".format("a" * 32, "d" * 32), { "instance": NotifyJoin, }, ), # API Key + Device Name ( "join://{}/{}".format("a" * 32, "My Device"), { "instance": NotifyJoin, }, ), # API Key + device ( "join://{}/{}".format("a" * 32, "d" * 32), { "instance": NotifyJoin, # don't include an image by default "include_image": False, }, ), # API Key + 2 devices ( "join://{}/{}/{}".format("a" * 32, "d" * 32, "e" * 32), { "instance": NotifyJoin, # don't include an image by default "include_image": False, }, ), # API Key + 1 device and 1 group ( "join://{}/{}/{}".format("a" * 32, "d" * 32, "group.chrome"), { "instance": NotifyJoin, }, ), ( "join://%s" % ("a" * 32), { "instance": NotifyJoin, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "join://%s" % ("a" * 32), { "instance": NotifyJoin, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "join://%s" % ("a" * 32), { "instance": NotifyJoin, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_join_urls(): """NotifyJoin() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_join_edge_cases(mock_post, mock_get): """NotifyJoin() Edge Cases.""" # Generate some generic message types device = "A" * 32 group = "group.chrome" apikey = "a" * 32 # Initializes the plugin with devices set to a string NotifyJoin(apikey=apikey, targets=group) # Initializes the plugin with devices set to None NotifyJoin(apikey=apikey, targets=None) # Initializes the plugin with an invalid apikey with pytest.raises(TypeError): NotifyJoin(apikey=None) # Whitespace also acts as an invalid apikey with pytest.raises(TypeError): NotifyJoin(apikey=" ") # Initializes the plugin with devices set to a set p = NotifyJoin(apikey=apikey, targets=[group, device]) # Prepare our mock responses req = requests.Request() req.status_code = requests.codes.created req.content = "" mock_get.return_value = req mock_post.return_value = req # Test notifications without a body or a title; nothing to send # so we return False assert ( p.notify(body=None, title=None, notify_type=NotifyType.INFO) is False ) @mock.patch("requests.post") def test_plugin_join_config_files(mock_post): """NotifyJoin() Config File Cases.""" content = """ urls: - join://{}@{}: - priority: -2 tag: join_int low - priority: "-2" tag: join_str_int low - priority: low tag: join_str low # This will take on normal (default) priority - priority: invalid tag: join_invalid - join://{}@{}: - priority: 2 tag: join_int emerg - priority: "2" tag: join_str_int emerg - priority: emergency tag: join_str emerg """.format("a" * 32, "b" * 32, "c" * 32, "d" * 32) # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 7 servers from that # 3x low # 3x emerg # 1x invalid (so takes on normal priority) assert len(ac.servers()) == 7 assert len(aobj) == 7 assert len(list(aobj.find(tag="low"))) == 3 for s in aobj.find(tag="low"): assert s.priority == JoinPriority.LOW assert len(list(aobj.find(tag="emerg"))) == 3 for s in aobj.find(tag="emerg"): assert s.priority == JoinPriority.EMERGENCY assert len(list(aobj.find(tag="join_str"))) == 2 assert len(list(aobj.find(tag="join_str_int"))) == 2 assert len(list(aobj.find(tag="join_int"))) == 2 assert len(list(aobj.find(tag="join_invalid"))) == 1 assert next(aobj.find(tag="join_invalid")).priority == JoinPriority.NORMAL # Notifications work assert aobj.notify(title="title", body="body") is True caronc-apprise-182f859/tests/test_plugin_kavenegar.py000066400000000000000000000110061524161175200230060ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester from apprise.plugins.kavenegar import NotifyKavenegar logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "kavenegar://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "kavenegar://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "kavenegar://{}/{}/{}".format("1" * 10, "2" * 15, "a" * 13), { # valid api key and valid authentication "instance": NotifyKavenegar, # Since there are no targets specified we expect a False return on # send() "notify_response": False, }, ), ( "kavenegar://{}/{}".format("a" * 24, "3" * 14), { # valid api key and valid number "instance": NotifyKavenegar, # Our expected url(privacy=True) startswith() response: "privacy_url": "kavenegar://a...a/", }, ), ( "kavenegar://{}?to={}".format("a" * 24, "3" * 14), { # valid api key and valid number "instance": NotifyKavenegar, # Our expected url(privacy=True) startswith() response: "privacy_url": "kavenegar://a...a/", }, ), ( "kavenegar://{}@{}/{}".format("1" * 14, "b" * 24, "3" * 14), { # valid api key and valid number "instance": NotifyKavenegar, # Our expected url(privacy=True) startswith() response: "privacy_url": "kavenegar://{}@b...b/".format("1" * 14), }, ), ( "kavenegar://{}@{}/{}".format("a" * 14, "b" * 24, "3" * 14), { # invalid from number "instance": TypeError, }, ), ( "kavenegar://{}@{}/{}".format("3" * 4, "b" * 24, "3" * 14), { # invalid from number "instance": TypeError, }, ), ( "kavenegar://{}/{}?from={}".format("b" * 24, "3" * 14, "1" * 14), { # valid api key and valid number "instance": NotifyKavenegar, # Our expected url(privacy=True) startswith() response: "privacy_url": "kavenegar://{}@b...b/".format("1" * 14), }, ), ( "kavenegar://{}/{}".format("b" * 24, "4" * 14), { "instance": NotifyKavenegar, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "kavenegar://{}/{}".format("c" * 24, "5" * 14), { "instance": NotifyKavenegar, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_kavenegar_urls(): """NotifyKavenegar() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_kook.py000066400000000000000000000672331524161175200220230ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import dumps import logging import os from unittest import mock from urllib.parse import urlparse from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyFormat from apprise.plugins.kook import ( KookMode, NotifyKook, ) logging.disable(logging.CRITICAL) # Attachment directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # A realistic-looking fake bot token (no slashes so URL interpolation works) BOT_TOKEN = "MTQ3OTE5MTA3MzQ1ODE4OTQ0ABC123XYZ" # Fake channel / user IDs (numeric snowflakes) CHANNEL_ID = "1234567890" CHANNEL_ID2 = "9876543210" USER_ID = "5555555555" # Fake webhook key WEBHOOK_KEY = "abc123webhookkey" TEST_URLS = ( ## # Invalid cases ## # Missing token ( "kook://", { "instance": TypeError, }, ), # Invalid mode ( f"kook://{BOT_TOKEN}/?mode=invalid", { "instance": TypeError, }, ), ## # Webhook mode ## # Basic webhook ( f"kook://{WEBHOOK_KEY}/?mode=webhook", { "instance": NotifyKook, "privacy_url": "kook://a...y/?mode=webhook", }, ), # Webhook via ?token= override ( f"kook://ignored/?token={WEBHOOK_KEY}&mode=webhook", { "instance": NotifyKook, }, ), # Webhook HTTP 500 ( f"kook://{WEBHOOK_KEY}/?mode=webhook", { "instance": NotifyKook, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), # Webhook unknown response code ( f"kook://{WEBHOOK_KEY}/?mode=webhook", { "instance": NotifyKook, "response": False, "requests_response_code": 999, }, ), # Webhook RequestException ( f"kook://{WEBHOOK_KEY}/?mode=webhook", { "instance": NotifyKook, "test_requests_exceptions": True, }, ), ## # Bot mode - single channel # attach_response=False because _upload() needs a CDN URL in the # response body; the default mock returns empty content so the CDN # upload step returns None and the send returns False. ## ( f"kook://{BOT_TOKEN}/{CHANNEL_ID}", { "instance": NotifyKook, "attach_response": False, }, ), # Bot mode - multiple channels ( f"kook://{BOT_TOKEN}/{CHANNEL_ID}/{CHANNEL_ID2}", { "instance": NotifyKook, "attach_response": False, }, ), # Bot mode - DM target ( f"kook://{BOT_TOKEN}/@{USER_ID}", { "instance": NotifyKook, "attach_response": False, }, ), # Bot mode - channel + DM mixed ( f"kook://{BOT_TOKEN}/{CHANNEL_ID}/@{USER_ID}", { "instance": NotifyKook, "attach_response": False, }, ), # Bot mode - no targets (loads but notify() returns False) ( f"kook://{BOT_TOKEN}", { "instance": NotifyKook, "notify_response": False, }, ), # Bot mode via ?to= ( f"kook://{BOT_TOKEN}/?to={CHANNEL_ID}", { "instance": NotifyKook, "attach_response": False, }, ), # Bot mode with ?format=text (plain text delivery) ( f"kook://{BOT_TOKEN}/{CHANNEL_ID}/?format=text", { "instance": NotifyKook, "attach_response": False, }, ), # Bot mode HTTP 500 ( f"kook://{BOT_TOKEN}/{CHANNEL_ID}", { "instance": NotifyKook, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), # Bot mode unknown response code ( f"kook://{BOT_TOKEN}/{CHANNEL_ID}", { "instance": NotifyKook, "response": False, "requests_response_code": 999, }, ), # Bot mode RequestException ( f"kook://{BOT_TOKEN}/{CHANNEL_ID}", { "instance": NotifyKook, "test_requests_exceptions": True, }, ), # Native webhook URL ( "https://www.kookapp.cn/api/v3/incoming/nativekey123", { "instance": NotifyKook, "privacy_url": "kook://n...3/?mode=webhook", }, ), ) @pytest.fixture def apprise_url_tester(): return AppriseURLTester(tests=TEST_URLS) def test_plugin_kook_urls(apprise_url_tester): """Run all URL test table entries through AppriseURLTester.""" apprise_url_tester.run_all() def test_plugin_kook_bot_mode(): """Verify bot-mode init, url(), url_identifier, and round-trip.""" # Single channel obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.mode == KookMode.BOT assert obj.notify_format == NotifyFormat.MARKDOWN assert obj.channels == [CHANNEL_ID] assert obj.dm_users == [] assert obj.attachment_support is True # url_identifier must only reference connection identity, not targets ident = obj.url_identifier assert ident == ("kook", KookMode.BOT, BOT_TOKEN) # Round-trip url = obj.url() parsed = NotifyKook.parse_url(url) assert parsed is not None obj2 = NotifyKook(**parsed) assert obj2.url_identifier == obj.url_identifier assert len(obj2.channels) == len(obj.channels) # privacy_url should mask the token purl = obj.url(privacy=True) assert BOT_TOKEN not in purl # Multiple channels obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID, CHANNEL_ID2]) assert len(obj.channels) == 2 url = obj.url() parsed = NotifyKook.parse_url(url) obj2 = NotifyKook(**parsed) assert len(obj2.channels) == 2 # DM target obj = NotifyKook(token=BOT_TOKEN, targets=[f"@{USER_ID}"]) assert obj.dm_users == [USER_ID] assert obj.channels == [] url = obj.url() parsed = NotifyKook.parse_url(url) obj2 = NotifyKook(**parsed) assert obj2.dm_users == [USER_ID] # Mixed channel + DM obj = NotifyKook( token=BOT_TOKEN, targets=[CHANNEL_ID, f"@{USER_ID}"], ) assert len(obj.channels) == 1 assert len(obj.dm_users) == 1 url = obj.url() parsed = NotifyKook.parse_url(url) obj2 = NotifyKook(**parsed) assert len(obj2.channels) == 1 assert len(obj2.dm_users) == 1 # # prefix stripped from channel obj = NotifyKook(token=BOT_TOKEN, targets=[f"#{CHANNEL_ID}"]) assert obj.channels == [CHANNEL_ID] # Invalid channel and DM IDs are stored in _invalid_targets and # survive the round-trip obj = NotifyKook( token=BOT_TOKEN, targets=["notanumber", "@notanumber"], ) assert obj.channels == [] assert obj.dm_users == [] assert len(obj._invalid_targets) == 2 url = obj.url() parsed = NotifyKook.parse_url(url) obj2 = NotifyKook(**parsed) assert len(obj2._invalid_targets) == 2 # No targets obj = NotifyKook(token=BOT_TOKEN) assert obj.channels == [] assert obj.dm_users == [] # ?format=text sets notify_format to TEXT; url() round-trips it url = f"kook://{BOT_TOKEN}/{CHANNEL_ID}/?format=text" parsed = NotifyKook.parse_url(url) obj = NotifyKook(**parsed) assert obj.notify_format == NotifyFormat.TEXT url2 = obj.url() parsed2 = NotifyKook.parse_url(url2) obj2 = NotifyKook(**parsed2) assert obj2.notify_format == NotifyFormat.TEXT # Default notify_format stays MARKDOWN (kmarkdown mode) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify_format == NotifyFormat.MARKDOWN url = obj.url() assert "msg_type" not in url # Default mode not emitted in URL assert "mode" not in url def test_plugin_kook_webhook_mode(): """Verify webhook-mode init, url(), and url_identifier.""" obj = NotifyKook(token=WEBHOOK_KEY, mode="webhook") assert obj.mode == KookMode.WEBHOOK assert obj.attachment_support is False assert obj.channels == [] assert obj.dm_users == [] # url_identifier includes mode assert obj.url_identifier == ("kook", KookMode.WEBHOOK, WEBHOOK_KEY) # url() emits mode=webhook url = obj.url() assert "mode=webhook" in url # Round-trip parsed = NotifyKook.parse_url(url) obj2 = NotifyKook(**parsed) assert obj2.url_identifier == obj.url_identifier assert obj2.mode == KookMode.WEBHOOK # Missing token with pytest.raises(TypeError): NotifyKook(token=None) with pytest.raises(TypeError): NotifyKook(token="") def test_plugin_kook_mode_invalid(): """Invalid mode raises TypeError.""" with pytest.raises(TypeError): NotifyKook(token=BOT_TOKEN, mode="invalid_mode") def test_plugin_kook_url_parsing(): """Exercise parse_url edge cases.""" # ?token= query param overrides the host url = f"kook://ignored/?token={BOT_TOKEN}&to={CHANNEL_ID}" parsed = NotifyKook.parse_url(url) assert parsed is not None assert parsed["token"] == BOT_TOKEN assert CHANNEL_ID in parsed["targets"] # split_path collects multiple targets url = f"kook://{BOT_TOKEN}/{CHANNEL_ID}/{CHANNEL_ID2}" parsed = NotifyKook.parse_url(url) assert CHANNEL_ID in parsed["targets"] assert CHANNEL_ID2 in parsed["targets"] # None input returns None assert NotifyKook.parse_url(None) is None def test_plugin_kook_native_url(): """parse_native_url accepts a full Kook webhook URL.""" url = "https://www.kookapp.cn/api/v3/incoming/mywebhookkey" parsed = NotifyKook.parse_native_url(url) assert parsed is not None assert parsed["token"] == "mywebhookkey" assert parsed["mode"] == KookMode.WEBHOOK # Native URL with trailing slash url = "https://www.kookapp.cn/api/v3/incoming/mywebhookkey/" parsed = NotifyKook.parse_native_url(url) assert parsed is not None assert parsed["token"] == "mywebhookkey" # Non-matching URL returns None assert NotifyKook.parse_native_url("https://example.com/") is None @mock.patch("requests.post") def test_plugin_kook_webhook_send(mock_post): """Webhook mode send: success, HTTP error, API error, RequestException.""" def _mk_resp(code=requests.codes.ok, api_code=0): r = mock.Mock() r.status_code = code r.content = dumps({"code": api_code, "message": "ok"}).encode() return r # Success mock_post.return_value = _mk_resp() obj = NotifyKook(token=WEBHOOK_KEY, mode="webhook") assert obj.notify(body="hello") is True assert mock_post.call_count == 1 # Verify the endpoint URL call_url = mock_post.call_args[0][0] assert urlparse(call_url).hostname == "www.kookapp.cn" assert "/incoming/" in call_url # HTTP 500 -> False mock_post.return_value = _mk_resp( code=requests.codes.internal_server_error ) assert obj.notify(body="hello") is False # Unknown HTTP code -> False mock_post.return_value = _mk_resp(code=999) assert obj.notify(body="hello") is False # API-level error (code != 0) -> False mock_post.return_value = _mk_resp(api_code=40001) assert obj.notify(body="hello") is False # Unparseable JSON body is handled gracefully r = mock.Mock() r.status_code = requests.codes.ok r.content = b"not-json" mock_post.return_value = r # code defaults to 0 -> success (unparseable treated as ok) assert obj.notify(body="hello") is True # RequestException -> False mock_post.side_effect = requests.RequestException("conn error") assert obj.notify(body="hello") is False mock_post.side_effect = None @mock.patch("requests.post") def test_plugin_kook_bot_send(mock_post): """Bot mode send: no targets, single channel, multiple, DM, error.""" def _ok(): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps({"code": 0}).encode() return r def _err(code=requests.codes.internal_server_error): r = mock.Mock() r.status_code = code r.content = b"" return r # No targets -> False without making a network call obj = NotifyKook(token=BOT_TOKEN) mock_post.reset_mock() assert obj.notify(body="hello") is False assert mock_post.call_count == 0 # Single channel success mock_post.return_value = _ok() obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello") is True assert mock_post.call_count == 1 # Verify Authorization header call_kwargs = mock_post.call_args[1] assert f"Bot {BOT_TOKEN}" in call_kwargs["headers"]["Authorization"] # Multiple channels mock_post.return_value = _ok() obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID, CHANNEL_ID2]) mock_post.reset_mock() assert obj.notify(body="hello") is True assert mock_post.call_count == 2 # DM target (uses dm_url endpoint) mock_post.return_value = _ok() obj = NotifyKook(token=BOT_TOKEN, targets=[f"@{USER_ID}"]) mock_post.reset_mock() assert obj.notify(body="hello") is True assert mock_post.call_count == 1 call_url = mock_post.call_args[0][0] assert "direct-message" in call_url # Mixed channel + DM mock_post.return_value = _ok() obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID, f"@{USER_ID}"]) mock_post.reset_mock() assert obj.notify(body="hello") is True assert mock_post.call_count == 2 # HTTP 500 on one target -> has_error=True -> False mock_post.return_value = _err() obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello") is False # Unknown HTTP code mock_post.return_value = _err(code=999) assert obj.notify(body="hello") is False # API-level error code r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps({"code": 40001, "message": "error"}).encode() mock_post.return_value = r assert obj.notify(body="hello") is False # RequestException mock_post.side_effect = requests.RequestException("boom") obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello") is False mock_post.side_effect = None # Partial failure: first channel fails, second would succeed, # but has_error is still True so overall False obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID, CHANNEL_ID2]) mock_post.side_effect = [_err(), _ok()] assert obj.notify(body="hello") is False mock_post.side_effect = None # ?format=text -> notify_format=TEXT -> Kook API type 1 in the payload import json mock_post.return_value = _ok() parsed = NotifyKook.parse_url( f"kook://{BOT_TOKEN}/{CHANNEL_ID}/?format=text" ) obj = NotifyKook(**parsed) mock_post.reset_mock() assert obj.notify(body="hello") is True payload = json.loads(mock_post.call_args[1]["data"]) assert payload["type"] == 1 # Default (markdown) -> notify_format=MARKDOWN -> Kook API type 9 mock_post.return_value = _ok() obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) mock_post.reset_mock() assert obj.notify(body="hello") is True payload = json.loads(mock_post.call_args[1]["data"]) assert payload["type"] == 9 @mock.patch("requests.post") def test_plugin_kook_attachments_success(mock_post): """Attachment upload succeeds and a follow-up message is posted.""" def _ok(cdn_url="https://img.kookapp.cn/assets/test.png"): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( { "code": 0, "data": {"url": cdn_url}, } ).encode() return r # call order: (1) text message, (2) CDN upload, (3) attach-msg POST mock_post.side_effect = [_ok(), _ok(), _ok()] attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello", attach=attach) is True assert mock_post.call_count == 3 @mock.patch("requests.post") def test_plugin_kook_attachments_inaccessible(mock_post): """Attachment that cannot be accessed returns False without HTTP call.""" mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=dumps({"code": 0}).encode(), ) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) with mock.patch("os.path.isfile", return_value=False): # Text message succeeds; attachment upload returns None -> False assert obj.notify(body="hello", attach=attach) is False @mock.patch("requests.post") def test_plugin_kook_attachments_oserror(mock_post): """OSError on attachment open is handled gracefully.""" # Text message POST returns ok mock_post.return_value = mock.Mock( status_code=requests.codes.ok, content=dumps({"code": 0}).encode(), ) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) with mock.patch("builtins.open", side_effect=OSError("read error")): assert obj.notify(body="hello", attach=attach) is False @mock.patch("requests.post") def test_plugin_kook_attachments_upload_http_error(mock_post): """HTTP error during CDN upload returns False.""" ok_resp = mock.Mock() ok_resp.status_code = requests.codes.ok ok_resp.content = dumps({"code": 0}).encode() upload_err = mock.Mock() upload_err.status_code = requests.codes.internal_server_error upload_err.content = b"" # Text message OK, then upload fails mock_post.side_effect = [ok_resp, upload_err] attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello", attach=attach) is False @mock.patch("requests.post") def test_plugin_kook_attachments_upload_no_url(mock_post): """CDN upload returns HTTP 200 but no URL in response body -> False.""" ok_resp = mock.Mock() ok_resp.status_code = requests.codes.ok ok_resp.content = dumps({"code": 0}).encode() no_url_resp = mock.Mock() no_url_resp.status_code = requests.codes.ok no_url_resp.content = dumps({"code": 0, "data": {}}).encode() mock_post.side_effect = [ok_resp, no_url_resp] attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello", attach=attach) is False @mock.patch("requests.post") def test_plugin_kook_attachments_upload_request_exception(mock_post): """RequestException during CDN upload returns False.""" ok_resp = mock.Mock() ok_resp.status_code = requests.codes.ok ok_resp.content = dumps({"code": 0}).encode() mock_post.side_effect = [ ok_resp, requests.RequestException("upload error"), ] attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello", attach=attach) is False @mock.patch("requests.post") def test_plugin_kook_attachments_post_http_error(mock_post): """HTTP error when posting attachment message returns False.""" def _ok(cdn_url="https://img.kookapp.cn/assets/test.png"): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( { "code": 0, "data": {"url": cdn_url}, } ).encode() return r err_resp = mock.Mock() err_resp.status_code = requests.codes.internal_server_error err_resp.content = b"" # Text message OK, CDN upload OK, attachment-message POST fails mock_post.side_effect = [_ok(), _ok(), err_resp] attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello", attach=attach) is False @mock.patch("requests.post") def test_plugin_kook_attachments_post_request_exception(mock_post): """RequestException when posting attachment message returns False.""" def _ok(cdn_url="https://img.kookapp.cn/assets/test.png"): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( { "code": 0, "data": {"url": cdn_url}, } ).encode() return r # Text message OK, CDN upload OK, attachment-message POST raises mock_post.side_effect = [ _ok(), _ok(), requests.RequestException("post error"), ] attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello", attach=attach) is False @mock.patch("requests.post") def test_plugin_kook_attachments_post_api_error(mock_post): """API error in attach-message response sets has_error -> False.""" def _ok(cdn_url="https://img.kookapp.cn/assets/test.png"): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( { "code": 0, "data": {"url": cdn_url}, } ).encode() return r def _api_err(): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps({"code": 40001, "message": "forbidden"}).encode() return r # Text message OK, CDN upload OK, attach-msg POST returns API error mock_post.side_effect = [_ok(), _ok(), _api_err()] attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello", attach=attach) is False @mock.patch("requests.post") def test_plugin_kook_attachments_image_and_file(mock_post): """Image attachment uses type=2; non-image uses type=4.""" import json as _json def _ok(cdn_url="https://img.kookapp.cn/assets/f"): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( { "code": 0, "data": {"url": cdn_url}, } ).encode() return r obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) # Image attachment (PNG) # call order: text-msg, upload, attach-msg mock_post.side_effect = [_ok(), _ok(), _ok()] attach_img = AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.png") ) assert obj.notify(body="hello", attach=attach_img) is True attach_msg_payload = _json.loads(mock_post.call_args_list[2][1]["data"]) assert attach_msg_payload["type"] == 2 # Non-image attachment (mp4 file) mock_post.side_effect = [_ok(), _ok(), _ok()] attach_file = AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.mp4") ) mock_post.reset_mock() mock_post.side_effect = [_ok(), _ok(), _ok()] assert obj.notify(body="hello", attach=attach_file) is True attach_msg_payload = _json.loads(mock_post.call_args_list[2][1]["data"]) assert attach_msg_payload["type"] == 4 @mock.patch("requests.post") def test_plugin_kook_webhook_no_attachment_support(mock_post): """Webhook mode ignores attachments since attachment_support=False.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps({"code": 0}).encode() mock_post.return_value = r attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) obj = NotifyKook(token=WEBHOOK_KEY, mode="webhook") assert obj.attachment_support is False # Apprise framework skips attach when attachment_support=False assert obj.notify(body="hello", attach=attach) is True # Only one POST (the webhook message itself) assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_kook_invalid_json_responses(mock_post): """Verify invalid JSON in API responses is handled gracefully.""" # _send_bot: API response with unparseable JSON # The message POST returns 200 but with invalid JSON body; the except # clause must handle it and fall back to code=0 (success). r = mock.Mock() r.status_code = requests.codes.ok r.content = b"not-json" mock_post.return_value = r obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) assert obj.notify(body="hello") is True # _upload: CDN upload response with unparseable JSON # Text message POST returns ok with bad JSON; CDN upload also returns ok # with bad JSON. The except fires, cdn_url is None, send returns False. mock_post.return_value = r # same bad JSON response for all calls obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) # Text succeeds (code defaults to 0); CDN upload has no URL -> False assert obj.notify(body="hello", attach=attach) is False # _send_bot: attach-message POST response with unparseable JSON # text OK, CDN upload OK (valid cdn_url), attach-msg POST returns bad JSON; # the except fires, content defaults to {}, code=0 -> treated as success. def _ok_cdn(): resp = mock.Mock() resp.status_code = requests.codes.ok resp.content = dumps( {"code": 0, "data": {"url": "https://cdn/f"}} ).encode() return resp bad_json_resp = mock.Mock() bad_json_resp.status_code = requests.codes.ok bad_json_resp.content = b"not-json" mock_post.side_effect = [_ok_cdn(), _ok_cdn(), bad_json_resp] mock_post.return_value = None obj = NotifyKook(token=BOT_TOKEN, targets=[CHANNEL_ID]) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert obj.notify(body="hello", attach=attach) is True mock_post.side_effect = None @mock.patch("requests.post") def test_plugin_kook_apprise_integration(mock_post): """Apprise().add() and notify() work for both modes.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps({"code": 0}).encode() mock_post.return_value = r # Bot mode a = Apprise() assert a.add(f"kook://{BOT_TOKEN}/{CHANNEL_ID}") is True assert a.notify(title="Test", body="Message") is True # Webhook mode a = Apprise() assert a.add(f"kook://{WEBHOOK_KEY}/?mode=webhook") is True assert a.notify(body="webhook message") is True caronc-apprise-182f859/tests/test_plugin_kumulos.py000066400000000000000000000103011524161175200225370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import pytest import requests from apprise.plugins.kumulos import NotifyKumulos logging.disable(logging.CRITICAL) # a test UUID we can use UUID4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752" # Our Testing URLs apprise_url_tests = ( ( "kumulos://", { # No API or Server Key specified "instance": TypeError, }, ), ( "kumulos://:@/", { # No API or Server Key specified # We don't have strict host checking on for kumulos, so this URL # actually becomes parseable and :@ becomes a hostname. # The below errors because a second token wasn't found "instance": TypeError, }, ), ( f"kumulos://{UUID4}/", { # No server key was specified "instance": TypeError, }, ), ( "kumulos://{}/{}/".format(UUID4, "w" * 36), { # Everything is okay "instance": NotifyKumulos, # Our expected url(privacy=True) startswith() response: "privacy_url": "kumulos://8...2/w...w/", }, ), ( "kumulos://{}/{}/".format(UUID4, "x" * 36), { "instance": NotifyKumulos, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, # Our expected url(privacy=True) startswith() response: "privacy_url": "kumulos://8...2/x...x/", }, ), ( "kumulos://{}/{}/".format(UUID4, "y" * 36), { "instance": NotifyKumulos, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, # Our expected url(privacy=True) startswith() response: "privacy_url": "kumulos://8...2/y...y/", }, ), ( "kumulos://{}/{}/".format(UUID4, "z" * 36), { "instance": NotifyKumulos, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_kumulos_urls(): """NotifyKumulos() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_kumulos_edge_cases(): """NotifyKumulos() Edge Cases.""" # Invalid API Key with pytest.raises(TypeError): NotifyKumulos(None, None) with pytest.raises(TypeError): NotifyKumulos(" ", None) # Invalid Server Key with pytest.raises(TypeError): NotifyKumulos("abcd", None) with pytest.raises(TypeError): NotifyKumulos("abcd", " ") caronc-apprise-182f859/tests/test_plugin_lametric.py000066400000000000000000000247251524161175200226570ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import pytest import requests from apprise.plugins.lametric import NotifyLametric logging.disable(logging.CRITICAL) # a test UUID we can use UUID4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752" # Our Testing URLs apprise_url_tests = ( ( "lametric://", { # No APIKey or App ID specified "instance": TypeError, }, ), ( "lametric://:@/", { # No APIKey or App ID specified "instance": TypeError, }, ), ( "lametric://{}/".format( "com.lametric.941c51dff3135bd87aa72db9d855dd50" ), { # No APIKey specified "instance": TypeError, }, ), ( f"lametric://root:{UUID4}@192.168.0.5:8080/", { # Everything is okay; this would be picked up in Device Mode # We're using a default port and enforcing a special user "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://root:8...2@192.168.0.5/", }, ), ( f"lametric://{UUID4}@192.168.0.4:8000/", { # Everything is okay; this would be picked up in Device Mode # Port is enforced "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://8...2@192.168.0.4:8000/", }, ), ( f"lametric://{UUID4}@192.168.0.5/", { # Everything is okay; this would be picked up in Device Mode "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://8...2@192.168.0.5/", }, ), ( f"lametrics://{UUID4}@192.168.0.6/?mode=device", { # Everything is okay; Device mode forced "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametrics://8...2@192.168.0.6/", }, ), # Support Native URL (with Access Token Argument) ( "https://developer.lametric.com/api/v1/dev/widget/update/" "com.lametric.ABCD123/1?token={}==".format("D" * 88), { # Everything is okay; Device mode forced "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://D...=@A...3/1/", }, ), ( "lametric://192.168.2.8/?mode=device&apikey=abc123", { # Everything is okay; Device mode forced "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://a...3@192.168.2.8/", }, ), ( "lametrics://{}==@com.lametric.941c51dff3135bd87aa72db9d855dd50/" "?mode=cloud&app_ver=2".format("A" * 88), { # Everything is okay; Cloud mode forced # We gracefully strip off the com.lametric. part as well # We also set an application version of 2 "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://A...=@9...0/", }, ), ( "lametrics://{}==@com.lametric.941c51dff3135bd87aa72db9d855dd50/" "?app_ver=invalid".format("A" * 88), { # We set invalid app version "instance": TypeError, }, ), # our lametric object initialized via argument ( "lametric://?app=com.lametric.941c51dff3135bd87aa72db9d855dd50&token={}==" "&mode=cloud".format("B" * 88), { # Everything is okay; Cloud mode forced "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://B...=@9...0/", }, ), ( "lametrics://{}==@abcd/?mode=cloud&sound=knock&icon_type=info" "&priority=critical&cycles=10".format("C" * 88), { # Cloud mode forced, sound, icon_type, and priority not supported # with cloud mode so warnings are created "instance": NotifyLametric, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://C...=@a...d/", }, ), ( f"lametrics://{UUID4}@192.168.0.7/?mode=invalid", { # Invalid Mode "instance": TypeError, }, ), ( f"lametrics://{UUID4}@192.168.0.6/?sound=alarm1", { # Device mode with sound set to alarm1 "instance": NotifyLametric, }, ), ( f"lametrics://{UUID4}@192.168.0.7/?sound=bike", { # Device mode with sound set to bicycle using alias "instance": NotifyLametric, # Bike is an alias, "url_matches": r"sound=bicycle", }, ), ( f"lametrics://{UUID4}@192.168.0.8/?sound=invalid!", { # Invalid sounds just produce warnings... object still loads "instance": NotifyLametric, }, ), ( f"lametrics://{UUID4}@192.168.0.9/?icon_type=alert", { # Icon Type Changed "instance": NotifyLametric, # icon=alert exists somewhere on our generated URL "url_matches": r"icon_type=alert", }, ), ( f"lametrics://{UUID4}@192.168.0.10/?icon_type=invalid", { # Invalid icon types just produce warnings... object still loads "instance": NotifyLametric, }, ), ( f"lametric://{UUID4}@192.168.1.1/?priority=warning", { # Priority changed "instance": NotifyLametric, }, ), ( f"lametrics://{UUID4}@192.168.1.2/?priority=invalid", { # Invalid priority just produce warnings... object still loads "instance": NotifyLametric, }, ), ( f"lametric://{UUID4}@192.168.1.2/?icon=230", { # Our custom icon by it's ID "instance": NotifyLametric, }, ), ( f"lametrics://{UUID4}@192.168.1.2/?icon=#230", { # Our custom icon by it's ID; the hashtag at the front is ignored "instance": NotifyLametric, }, ), ( f"lametric://{UUID4}@192.168.1.2/?icon=Heart", { # Our custom icon; the hashtag at the front is ignored "instance": NotifyLametric, }, ), ( f"lametric://{UUID4}@192.168.1.2/?icon=#", { # a hashtag and nothing else "instance": NotifyLametric, }, ), ( f"lametric://{UUID4}@192.168.1.2/?icon=#%20%20%20", { # a hashtag and some spaces "instance": NotifyLametric, }, ), ( f"lametric://{UUID4}@192.168.1.3/?cycles=2", { # Cycles changed "instance": NotifyLametric, }, ), ( f"lametric://{UUID4}@192.168.1.4/?cycles=-1", { # Cycles changed (out of range) "instance": NotifyLametric, }, ), ( f"lametrics://{UUID4}@192.168.1.5/?cycles=invalid", { # Invalid priority just produce warnings... object still loads "instance": NotifyLametric, }, ), ( f"lametric://{UUID4}@example.com/", { "instance": NotifyLametric, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametric://8...2@example.com/", }, ), ( f"lametrics://{UUID4}@example.ca/", { "instance": NotifyLametric, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, # Our expected url(privacy=True) startswith() response: "privacy_url": "lametrics://8...2@example.ca/", }, ), ( f"lametrics://{UUID4}@example.net/", { "instance": NotifyLametric, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_lametric_urls(): """NotifyLametric() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_lametric_edge_cases(): """NotifyLametric() Edge Cases.""" # Initializes the plugin with an invalid API Key with pytest.raises(TypeError): NotifyLametric(apikey=None, mode="device") # Initializes the plugin with an invalid Client Secret with pytest.raises(TypeError): NotifyLametric(client_id="valid", secret=None, mode="cloud") caronc-apprise-182f859/tests/test_plugin_lark.py000066400000000000000000000051341524161175200220010ustar00rootroot00000000000000# Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.lark import NotifyLark logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "lark://", { # Teams Token missing "instance": TypeError, }, ), ( "lark://:@/", { # We don't have strict host checking on for lark, so this URL # actually becomes parseable and :@ becomes a hostname. # The below errors because a second token wasn't found "instance": TypeError, }, ), ( "lark://{}".format("abcd-1234"), { # token provided - we're good "instance": NotifyLark, # Our expected url(privacy=True) startswith() response: "privacy_url": "lark://****/", }, ), ( "lark://{}".format("abcd-1234"), { # token provided - we're good "instance": NotifyLark, # Our expected url(privacy=True) startswith() response: "privacy_url": "lark://****/", }, ), ( "lark://?token={}".format("abcd-1234"), { # token provided - we're good "instance": NotifyLark, # Our expected url(privacy=True) startswith() response: "privacy_url": "lark://****/", }, ), # Support Native URLs with arguments ( "https://open.larksuite.com/open-apis/bot/v2/hook/{}".format( "abcd-1234" ), { # token provided - we're good "instance": NotifyLark, }, ), ( "lark://{}".format("abcd-1234"), { "instance": NotifyLark, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "lark://{}".format("abcd-1234"), { "instance": NotifyLark, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "lark://{}".format("a" * 80), { "instance": NotifyLark, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_lark_urls(): """NotifyLark() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_line.py000066400000000000000000000071021524161175200217740ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester from apprise.plugins.line import NotifyLine logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "line://", { # No Access Token "instance": TypeError, }, ), ( "line://%20/", { # invalid Access Token; no Integration/Routing Key "instance": TypeError, }, ), ( "line://token", { # no target specified "instance": NotifyLine, # Expected notify() response "notify_response": False, }, ), ( "line://token=/target", { # minimum requirements met "instance": NotifyLine, # Our expected url(privacy=True) startswith() response: "privacy_url": "line://****/t...t?", }, ), ( "line://token/target?image=no", { # minimum requirements met; no icon display "instance": NotifyLine, }, ), ( "line://a/very/long/token=/target?image=no", { # minimum requirements met; no icon display "instance": NotifyLine, }, ), ( "line://?token=token&to=target1", { # minimum requirements met "instance": NotifyLine, # Our expected url(privacy=True) startswith() response: "privacy_url": "line://****/t...1?", }, ), ( "line://token/target", { "instance": NotifyLine, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "line://token/target", { "instance": NotifyLine, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_line_urls(): """NotifyLine() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_macosx.py000066400000000000000000000173741524161175200223530ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import os import sys from unittest.mock import Mock from helpers import reload_plugin import pytest import apprise from apprise.plugins.macosx import NotifyMacOSX # Disable logging for a cleaner testing output. logging.disable(logging.CRITICAL) if sys.platform not in ["darwin", "linux"]: pytest.skip( "Only makes sense on macOS, but testable in Linux", allow_module_level=True, ) @pytest.fixture def pretend_macos(mocker): """Fixture to simulate a macOS environment.""" mocker.patch("platform.system", return_value="Darwin") mocker.patch("platform.mac_ver", return_value=("10.8", ("", "", ""), "")) # Reload plugin module, in order to re-run module-level code. reload_plugin("macosx") @pytest.fixture def terminal_notifier(mocker, tmp_path): """Fixture for providing a surrogate for the `terminal-notifier` program.""" notifier_program = tmp_path.joinpath("terminal-notifier") notifier_program.write_text("#!/bin/sh\n\necho hello") # Set execute bit. os.chmod(notifier_program, 0o755) # Make the notifier use the temporary file instead of `terminal-notifier`. mocker.patch( "apprise.plugins.macosx.NotifyMacOSX.notify_paths", (str(notifier_program),), ) yield notifier_program @pytest.fixture def macos_notify_environment(pretend_macos, terminal_notifier): """Fixture to bundle general test case setup. Use this fixture if you don't need access to the individual members. """ pass def test_plugin_macosx_general_success(macos_notify_environment): """NotifyMacOSX() general checks.""" # Toggle Enable Flag obj = apprise.Apprise.instantiate( "macosx://_/?image=True", suppress_exceptions=False ) assert isinstance(obj, NotifyMacOSX) is True # Test url() call assert isinstance(obj.url(), str) is True # URL Identifier has been disabled as this isn't unique enough # to be mapped to more the 1 end point; verify that None is always # returned assert obj.url_id() is None # test notifications assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) # test notification without a title assert ( obj.notify(title="", body="body", notify_type=apprise.NotifyType.INFO) is True ) obj = apprise.Apprise.instantiate( "macosx://_/?image=True", suppress_exceptions=False ) assert isinstance(obj, NotifyMacOSX) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "macosx://_/?image=False", suppress_exceptions=False ) assert isinstance(obj, NotifyMacOSX) is True assert isinstance(obj.url(), str) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) # Test Sound obj = apprise.Apprise.instantiate( "macosx://_/?sound=default", suppress_exceptions=False ) assert isinstance(obj, NotifyMacOSX) is True assert obj.sound == "default" assert isinstance(obj.url(), str) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) # Test Click (-open support) obj = apprise.Apprise.instantiate( "macosx://_/?click=http://google.com", suppress_exceptions=False ) assert isinstance(obj, NotifyMacOSX) is True assert obj.click == "http://google.com" assert isinstance(obj.url(), str) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) def test_plugin_macosx_terminal_notifier_not_executable( pretend_macos, terminal_notifier ): """When the `terminal-notifier` program is inaccessible or not executable, we are unable to send notifications.""" obj = apprise.Apprise.instantiate("macosx://", suppress_exceptions=False) # Unset the executable bit. os.chmod(terminal_notifier, 0o644) assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) def test_plugin_macosx_terminal_notifier_invalid(macos_notify_environment): """When the `terminal-notifier` program is wrongly addressed, notifications should fail.""" obj = apprise.Apprise.instantiate("macosx://", suppress_exceptions=False) # Let's disrupt the path location. obj.notify_path = "invalid_missing-file" assert not os.path.isfile(obj.notify_path) assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) def test_plugin_macosx_terminal_notifier_croaks( mocker, macos_notify_environment ): """When the `terminal-notifier` program croaks on execution, notifications should fail.""" # Emulate a failing program. mocker.patch("subprocess.Popen", return_value=Mock(returncode=1)) obj = apprise.Apprise.instantiate("macosx://", suppress_exceptions=False) assert isinstance(obj, NotifyMacOSX) is True assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) def test_plugin_macosx_pretend_linux(mocker, pretend_macos): """The notification object is disabled when pretending to run on Linux.""" # When patching something which has a side effect on the module-level code # of a plugin, make sure to reload it. mocker.patch("platform.system", return_value="Linux") reload_plugin("macosx") # Our object is disabled. obj = apprise.Apprise.instantiate("macosx://", suppress_exceptions=False) assert obj is None @pytest.mark.parametrize("macos_version", ["9.12", "10.7"]) def test_plugin_macosx_pretend_old_macos(mocker, macos_version): """The notification object is disabled when pretending to run on older macOS.""" # When patching something which has a side effect on the module-level code # of a plugin, make sure to reload it. mocker.patch( "platform.mac_ver", return_value=(macos_version, ("", "", ""), "") ) reload_plugin("macosx") obj = apprise.Apprise.instantiate("macosx://", suppress_exceptions=False) assert obj is None caronc-apprise-182f859/tests/test_plugin_mailersend.py000066400000000000000000000360201524161175200231710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.mailersend import NotifyMailerSend logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # A representative MailerSend API key APIKEY = "mlsn.abc123def456abc123def456abc123def456" # Our Testing URLs apprise_url_tests = ( ( "mailersend://", { "instance": None, }, ), ( "mailersend://:@/", { "instance": None, }, ), ( # API key only, no from email "mailersend://abcd", { "instance": None, }, ), ( # host only, no user (API key) "mailersend://abcd@host", { "instance": None, }, ), ( # invalid API key (contains disallowed characters) "mailersend://bad+key*!:user@example.com", { "instance": TypeError, }, ), ( # No To/Target Address; use From address as default "mailersend://abcd:user@example.com", { "instance": NotifyMailerSend, }, ), ( # Single explicit To address "mailersend://abcd:user@example.com/newuser@example.com", { "instance": NotifyMailerSend, "privacy_url": ( "mailersend://a...d:user@example.com/newuser@example.com" ), }, ), ( # Multiple To addresses in the URL path ("mailersend://abcd:user@example.com/one@example.com/two@example.com"), { "instance": NotifyMailerSend, }, ), ( # Extra To addresses via ?to= ( "mailersend://abcd:user@example.com" "/one@example.com?to=two@example.com" ), { "instance": NotifyMailerSend, }, ), ( # Carbon Copy via ?cc= ( "mailersend://abcd:user@example.com" "/newuser@example.com?cc=cc@nuxref.com" ), { "instance": NotifyMailerSend, }, ), ( # Blind Carbon Copy via ?bcc= ( "mailersend://abcd:user@example.com" "/newuser@example.com?bcc=bcc@nuxref.com" ), { "instance": NotifyMailerSend, }, ), ( # Reply-To via ?reply= ("mailersend://abcd:user@example.com?reply=reply@example.com"), { "instance": NotifyMailerSend, "url_matches": r"reply=reply%40example.com", }, ), ( # Invalid Reply-To address ("mailersend://abcd:user@example.com/newuser@example.com?reply=%20!"), { "instance": TypeError, }, ), ( # Invalid To address is dropped but plugin still loads "mailersend://abcd:user@example.com/bademailaddress", { "instance": NotifyMailerSend, "notify_response": False, }, ), ( # Invalid CC and BCC (mixed with good ones) ( "mailersend://abcd:user@example.com" "/newuser@example.com" "?cc=l2g@nuxref.com,!bad" "&bcc=l2g@nuxref.com,!bad" ), { "instance": NotifyMailerSend, }, ), ( # plain-text format "mailersend://abcd:user@example.com?format=text", { "instance": NotifyMailerSend, }, ), ( # Force debug output ("mailersend://abcd:user@example.com/newuser@example.com"), { "instance": NotifyMailerSend, "force_debug": True, }, ), ( # HTTP 500 from server "mailersend://abcd:user@example.ca/newuser@example.ca", { "instance": NotifyMailerSend, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), ( # Unknown HTTP response code "mailersend://abcd:user@example.uk/newuser@example.uk", { "instance": NotifyMailerSend, "response": False, "requests_response_code": 999, }, ), ( # RequestException "mailersend://abcd:user@example.au/newuser@example.au", { "instance": NotifyMailerSend, "test_requests_exceptions": True, }, ), ) def test_plugin_mailersend_urls(): """NotifyMailerSend() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_mailersend_edge_cases(mock_post, mock_get): """NotifyMailerSend() Edge Cases.""" # No API key with pytest.raises(TypeError): NotifyMailerSend(apikey=None, from_email="user@example.com") # Invalid API key with pytest.raises(TypeError): NotifyMailerSend(apikey="bad+key*!", from_email="user@example.com") # Invalid From email with pytest.raises(TypeError): NotifyMailerSend(apikey="abcd", from_email="!invalid") # No From email with pytest.raises(TypeError): NotifyMailerSend(apikey="abcd", from_email=None) # Invalid Reply-To email raises TypeError with pytest.raises(TypeError): NotifyMailerSend( apikey="abcd", from_email="user@example.com", reply_to="!invalid", ) # Invalid To email is dropped; object still created obj = NotifyMailerSend( apikey="abcd", from_email="user@example.com", targets="!invalid", ) # When all To addresses are invalid the targets list is empty, so # send() will return False assert isinstance(obj, NotifyMailerSend) # Mixed good and bad cc/bcc entries obj = NotifyMailerSend( apikey="abcd", from_email="l2g@example.com", bcc=("abc@def.com", "!invalid"), cc=("abc@test.org", "!invalid"), ) assert isinstance(obj, NotifyMailerSend) @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_mailersend_send(mock_post, mock_get): """NotifyMailerSend() send logic.""" def _mk_resp(code=requests.codes.accepted): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() mock_get.return_value = _mk_resp() # Single target -- success obj = Apprise.instantiate( "mailersend://abcd:user@example.com/to@example.com" ) assert isinstance(obj, NotifyMailerSend) assert obj.notify(body="body", title="title") is True assert mock_post.call_count == 1 mock_post.reset_mock() # Multiple targets -- all succeed obj = NotifyMailerSend( apikey="abcd", from_email="user@example.com", targets=["t1@example.com", "t2@example.com"], ) assert obj.notify(body="body") is True assert mock_post.call_count == 2 mock_post.reset_mock() # No targets -- send returns False without any HTTP call obj = NotifyMailerSend( apikey="abcd", from_email="user@example.com", targets=["!invalid"], ) assert obj.notify(body="body") is False assert mock_post.call_count == 0 mock_post.reset_mock() # HTTP error on one of two targets -- partial failure mock_post.side_effect = [ _mk_resp(requests.codes.accepted), _mk_resp(requests.codes.internal_server_error), ] obj = NotifyMailerSend( apikey="abcd", from_email="user@example.com", targets=["t1@example.com", "t2@example.com"], ) assert obj.notify(body="body") is False mock_post.reset_mock() mock_post.side_effect = None # RequestException -- marks failure mock_post.side_effect = requests.RequestException("boom") obj = Apprise.instantiate( "mailersend://abcd:user@example.com/to@example.com" ) assert obj.notify(body="body") is False mock_post.side_effect = None # CC and BCC are excluded from the To list de-duplication mock_post.return_value = _mk_resp() obj = NotifyMailerSend( apikey="abcd", from_email="user@example.com", targets=["to@example.com"], cc=["cc@example.com", "to@example.com"], bcc=["bcc@example.com", "to@example.com"], reply_to="reply@example.com", ) assert obj.notify(body="body") is True call_payload = mock_post.call_args assert call_payload is not None # text format triggers html conversion mock_post.reset_mock() mock_post.return_value = _mk_resp() obj = Apprise.instantiate( "mailersend://abcd:user@example.com/to@example.com?format=text" ) assert obj.notify(body="

    body

    ") is True # unknown response code (not 202) is treated as failure mock_post.reset_mock() mock_post.return_value = _mk_resp(999) obj = Apprise.instantiate( "mailersend://abcd:user@example.com/to@example.com" ) assert obj.notify(body="body") is False @mock.patch("requests.post") def test_plugin_mailersend_attachments(mock_post): """NotifyMailerSend() Attachment handling.""" def _mk_resp(code=requests.codes.accepted): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() # Success -- valid attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) obj = Apprise.instantiate( "mailersend://abcd:user@example.com/to@example.com" ) assert isinstance(obj, NotifyMailerSend) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) mock_post.reset_mock() # Inaccessible file -- os.path.isfile returns False with mock.patch("os.path.isfile", return_value=False): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) mock_post.reset_mock() # OSError on open with mock.patch("builtins.open", side_effect=OSError): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) mock_post.reset_mock() # HTTP error after successful open mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) mock_post.reset_mock() # RequestException after successful open mock_post.side_effect = requests.RequestException("boom") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) mock_post.side_effect = None # Multiple attachments -- all succeed mock_post.return_value = _mk_resp() paths = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), ] multi_attach = AppriseAttachment(paths) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=multi_attach, ) is True ) @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_mailersend_url_parsing(mock_post, mock_get): """NotifyMailerSend() URL parsing.""" # Empty URL assert NotifyMailerSend.parse_url("") is None # Missing API key (no user segment) result = NotifyMailerSend.parse_url( "mailersend://user@host.com/to@example.com" ) assert result is None # Valid round-trip obj = NotifyMailerSend( apikey="mlsn.abc123", from_email="sender@example.com", targets=["to@example.com"], cc=["cc@example.com"], bcc=["bcc@example.com"], reply_to="reply@example.com", ) url = obj.url() result = NotifyMailerSend.parse_url(url) assert result is not None obj2 = NotifyMailerSend(**result) assert obj.url_identifier == obj2.url_identifier assert len(obj.targets) == len(obj2.targets) # url() with privacy=True masks the API key priv = obj.url(privacy=True) assert "mlsn.abc123" not in priv # len() returns number of targets (at least 1) obj_self = NotifyMailerSend( apikey="abcd", from_email="user@example.com", ) assert len(obj_self) >= 1 # CC/BCC round-trip preserved assert "cc=" in url assert "bcc=" in url assert "reply=" in url # ?to= alias result = NotifyMailerSend.parse_url( "mailersend://abcd:user@example.com?to=extra@example.com" ) assert result is not None assert "extra@example.com" in result["targets"] @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_mailersend_apprise_integration(mock_post, mock_get): """NotifyMailerSend() Apprise integration test.""" def _mk_resp(code=requests.codes.accepted): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() mock_get.return_value = _mk_resp() a = Apprise() assert a.add("mailersend://abcd:user@example.com/to@example.com") assert a.notify(body="body", title="title") is True caronc-apprise-182f859/tests/test_plugin_mailgun.py000066400000000000000000000414441524161175200225100ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.mailgun import NotifyMailgun logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "mailgun://", { "instance": TypeError, }, ), ( "mailgun://:@/", { "instance": TypeError, }, ), # No Token specified ( "mailgun://user@localhost.localdomain", { "instance": TypeError, }, ), # Token is valid, but no user name specified ( "mailgun://localhost.localdomain/{}-{}-{}".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": TypeError, }, ), # Invalid from email address ( 'mailgun://"@localhost.localdomain/{}-{}-{}'.format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": TypeError, }, ), # No To email address, but everything else is valid ( "mailgun://user@localhost.localdomain/{}-{}-{}".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, }, ), ( "mailgun://user@localhost.localdomain/{}-{}-{}?format=markdown".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, }, ), ( "mailgun://user@localhost.localdomain/{}-{}-{}?format=html".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, }, ), ( "mailgun://user@localhost.localdomain/{}-{}-{}?format=text".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, }, ), # valid url with region specified (case insensitve) ( "mailgun://user@localhost.localdomain/{}-{}-{}?region=uS".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, }, ), # valid url with region specified (case insensitve) ( "mailgun://user@localhost.localdomain/{}-{}-{}?region=EU".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, }, ), # invalid url with region specified (case insensitve) ( "mailgun://user@localhost.localdomain/{}-{}-{}?region=invalid".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": TypeError, }, ), # Use of both 'name' and 'from' together; these are synonymous ( "mailgun://user@localhost.localdomain/{}-{}-{}?" "from=jack@gmail.com&name=Jason".format( "a" * 32, "b" * 8, "c" * 8 ), {"instance": NotifyMailgun}, ), # headers ( "mailgun://user@localhost.localdomain/{}-{}-{}" "?+X-Customer-Campaign-ID=Apprise".format("a" * 32, "b" * 8, "c" * 8), { "instance": NotifyMailgun, }, ), # template tokens ( "mailgun://user@localhost.localdomain/{}-{}-{}" "?:name=Chris&:status=admin".format("a" * 32, "b" * 8, "c" * 8), { "instance": NotifyMailgun, }, ), # We can use the `from=` directive as well: ( "mailgun://user@localhost.localdomain/{}-{}-{}" "?:from=Chris&:status=admin".format("a" * 32, "b" * 8, "c" * 8), { "instance": NotifyMailgun, }, ), # bcc and cc ( "mailgun://user@localhost.localdomain/{}-{}-{}" "?bcc=user@example.com&cc=user2@example.com".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, }, ), # One To Email address ( "mailgun://user@localhost.localdomain/{}-{}-{}/test@example.com".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, }, ), ( "mailgun://user@localhost.localdomain/" "{}-{}-{}?to=test@example.com".format("a" * 32, "b" * 8, "c" * 8), {"instance": NotifyMailgun}, ), # One To Email address, a from name specified too ( "mailgun://user@localhost.localdomain/{}-{}-{}/" 'test@example.com?name="Frodo"'.format("a" * 32, "b" * 8, "c" * 8), {"instance": NotifyMailgun}, ), # Invalid 'To' Email address ( "mailgun://user@localhost.localdomain/{}-{}-{}/invalid".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, # Expected notify() response "notify_response": False, }, ), # Multiple 'To', 'Cc', and 'Bcc' addresses (with invalid ones) ( "mailgun://user@example.com/{}-{}-{}/{}?bcc={}&cc={}".format( "a" * 32, "b" * 8, "c" * 8, "/".join( ("user1@example.com", "invalid", "User2:user2@example.com") ), ",".join(("user3@example.com", "i@v", "User1:user1@example.com")), ",".join(("user4@example.com", "g@r@b", "Da:user5@example.com")), ), { "instance": NotifyMailgun, }, ), ( "mailgun://user@localhost.localdomain/{}-{}-{}".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "mailgun://user@localhost.localdomain/{}-{}-{}".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "mailgun://user@localhost.localdomain/{}-{}-{}".format( "a" * 32, "b" * 8, "c" * 8 ), { "instance": NotifyMailgun, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_mailgun_urls(): """NotifyMailgun() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_mailgun_attachments(mock_post): """NotifyMailgun() Attachments.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" # Assign our mock object our return value mock_post.return_value = okay_response # API Key apikey = "abc123" obj = Apprise.instantiate(f"mailgun://user@localhost.localdomain/{apikey}") assert isinstance(obj, NotifyMailgun) # Test Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test invalid attachment path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) mock_post.return_value = None mock_post.side_effect = OSError() # We can't send the message if we can't read the attachment assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Test Valid Attachment (load 3) path = ( os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ) attach = AppriseAttachment(path) # Return our good configuration mock_post.side_effect = None mock_post.return_value = okay_response with mock.patch("builtins.open", side_effect=OSError()): # We can't send the message we can't open the attachment for reading assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Do it again, but fail on the third file with mock.patch( "builtins.open", side_effect=(mock.Mock(), mock.Mock(), OSError()) ): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) with mock.patch("builtins.open") as mock_open: mock_fp = mock.Mock() mock_fp.seek.side_effect = OSError() mock_open.return_value = mock_fp # We can't send the message we can't seek through it assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) mock_post.reset_mock() # Fail on the third file; this tests the for-loop inside the seek() # section of the code that calls close() on previously opened files mock_fp.seek.side_effect = (None, None, OSError()) mock_open.return_value = mock_fp # We can't send the message we can't seek through it assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # test the handling of our batch modes obj = Apprise.instantiate( f"mailgun://no-reply@example.com/{apikey}/" "user1@example.com/user2@example.com?batch=yes" ) assert isinstance(obj, NotifyMailgun) # objects will be combined into a single post in batch mode assert len(obj) == 1 # Force our batch to break into separate messages obj.default_batch_size = 1 # We'll send 2 messages now assert len(obj) == 2 mock_post.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 2 # single batch mock_post.reset_mock() # We'll send 1 message obj.default_batch_size = 2 assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_mailgun_header_check(mock_post): """NotifyMailgun() Test Header Prep.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" # Assign our mock object our return value mock_post.return_value = okay_response # API Key apikey = "abc123" obj = Apprise.instantiate(f"mailgun://user@localhost.localdomain/{apikey}") assert isinstance(obj, NotifyMailgun) assert isinstance(obj.url(), str) # No calls made yet assert mock_post.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # 2 calls were made, one to perform an email lookup, the second # was the notification itself assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.mailgun.net/v3/localhost.localdomain/messages" ) payload = mock_post.call_args_list[0][1]["data"] assert "from" in payload assert payload["from"] == "Apprise " assert payload["to"] == "user@localhost.localdomain" # Reset our mock object mock_post.reset_mock() obj = Apprise.instantiate( f"mailgun://user@localhost.localdomain/{apikey}?from=Luke%20Skywalker" ) assert isinstance(obj, NotifyMailgun) assert isinstance(obj.url(), str) # No calls made yet assert mock_post.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 1 payload = mock_post.call_args_list[0][1]["data"] assert "from" in payload assert "to" in payload assert payload["from"] == "Luke Skywalker " assert payload["to"] == "user@localhost.localdomain" # Reset our mock object mock_post.reset_mock() obj = Apprise.instantiate( f"mailgun://user@localhost.localdomain/{apikey}" "?from=Luke%20Skywalker" ) assert isinstance(obj, NotifyMailgun) assert isinstance(obj.url(), str) # No calls made yet assert mock_post.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 1 payload = mock_post.call_args_list[0][1]["data"] assert "from" in payload assert "to" in payload assert payload["from"] == "Luke Skywalker " assert payload["to"] == "luke@rebels.com" # Reset our mock object mock_post.reset_mock() obj = Apprise.instantiate( f"mailgun://user@localhost.localdomain/{apikey}?from=luke@rebels.com" ) assert isinstance(obj, NotifyMailgun) assert isinstance(obj.url(), str) # No calls made yet assert mock_post.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 1 payload = mock_post.call_args_list[0][1]["data"] assert "from" in payload assert "to" in payload assert payload["from"] == "luke@rebels.com" assert payload["to"] == "luke@rebels.com" def test_plugin_mailgun_cc_bcc_invalid_branch(): """NotifyMailgun() CC/BCC validation: invalid entries are dropped.""" # API key and a valid 'from' address (required by __init__) apikey = "abc123" # Build a NotifyMailgun with: # cc = one valid address + one that parse_emails passes but is_email # rejects (hits the FALSE branch at line 327). # A list must be used -- parse_emails filters invalid tokens out # of comma-separated strings before is_email is ever called. # bcc = same, for the FALSE branch at line 343 obj = NotifyMailgun( apikey=apikey, targets=["user@example.com"], cc=["good@example.com", "notanemail"], bcc=["bcc@example.com", "alsonotanemail"], user="user", host="example.com", ) # Valid CC entry was added; invalid one was silently dropped assert "good@example.com" in obj.cc assert len(obj.cc) == 1 # Valid BCC entry was added; invalid one was silently dropped assert "bcc@example.com" in obj.bcc assert len(obj.bcc) == 1 caronc-apprise-182f859/tests/test_plugin_mastodon.py000066400000000000000000001043771524161175200227050ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timezone from json import dumps, loads # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType from apprise.plugins.mastodon import NotifyMastodon logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ################################## # NotifyMastodon ################################## ( "mastodon://", { # Missing Everything :) "instance": None, }, ), ( "mastodon://:@/", { "instance": None, }, ), ( "mastodon://hostname", { # Missing Access Token "instance": TypeError, }, ), ( "toot://access_token@hostname", { # We're good; it's a simple notification "instance": NotifyMastodon, }, ), ( "toots://access_token@hostname", { # We're good; it's another simple notification "instance": NotifyMastodon, # Our expected url(privacy=True) startswith() response: "privacy_url": "mastodons://****@hostname/", }, ), ( "mastodon://access_token@hostname/@user/@user2", { # We're good; it's another simple notification "instance": NotifyMastodon, # Our expected url(privacy=True) startswith() response: "privacy_url": "mastodon://****@hostname/@user/@user2", }, ), ( "mastodon://hostname/@user/@user2?token=abcd123", { # Our access token can be provided as a token= variable "instance": NotifyMastodon, # Our expected url(privacy=True) startswith() response: "privacy_url": "mastodon://****@hostname/@user/@user2", }, ), ( "mastodon://access_token@hostname?to=@user, @user2", { # We're good; it's another simple notification "instance": NotifyMastodon, # Our expected url(privacy=True) startswith() response: "privacy_url": "mastodon://****@hostname/@user/@user2", }, ), ( "mastodon://access_token@hostname/?cache=no", { # disable cache as a test "instance": NotifyMastodon, }, ), ( "mastodon://access_token@hostname/?spoiler=spoiler%20text", { # a public post "instance": NotifyMastodon, }, ), ( "mastodon://access_token@hostname/?language=en", { # over-ride our language "instance": NotifyMastodon, }, ), ( "mastodons://access_token@hostname:8443", { # A custom port specified "instance": NotifyMastodon, }, ), ( "mastodon://access_token@hostname/?key=My%20Idempotency%20Key", { # Prevent duplicate submissions of the same status. Idempotency # keys are stored for up to 1 hour, and can be any arbitrary # string. Consider using a hash or UUID generated client-side. "instance": NotifyMastodon, }, ), ( "mastodon://access_token@hostname/-/%/", { # Invalid users specified "instance": TypeError, }, ), ( "mastodon://access_token@hostname?visibility=invalid", { # An invalid visibility "instance": TypeError, }, ), ( "mastodon://access_token@hostname?visibility=direct", { # A direct message "instance": NotifyMastodon, # Expected notify() response False (because we won't # get the response we were expecting from the upstream # server "notify_response": False, }, ), ( "mastodon://access_token@hostname?visibility=direct", { # A direct message "instance": NotifyMastodon, # Provide a response that allows us to look our content up "requests_response_text": { "id": "12345", "username": "test", }, }, ), ( "toots://access_token@hostname", { "instance": NotifyMastodon, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_mastodon_urls(): """NotifyMastodon() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_mastodon_general(mock_post, mock_get): """NotifyMastodon() General Tests.""" token = "access_key" host = "nuxref.com" response_obj = { "username": "caronc", "id": 1234, } # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) request = mock.Mock() request.content = dumps(response_obj) request.status_code = requests.codes.ok request.headers = { "X-RateLimit-Limit": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # Prepare Mock mock_get.return_value = request mock_post.return_value = request # Instantiate our object obj = NotifyMastodon(token=token, host=host) assert isinstance(obj, NotifyMastodon) assert isinstance(obj.url(), str) # apprise room was found assert obj.send(body="test") is True # Change our status code and try again request.status_code = 403 assert obj.send(body="test") is False assert obj.ratelimit_remaining == 1 # Return the status request.status_code = requests.codes.ok # Force a reset request.headers["X-RateLimit-Remaining"] = 0 # behind the scenes, it should cause us to update our rate limit assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 # This should cause us to block request.headers["X-RateLimit-Remaining"] = 10 assert obj.send(body="test") is True assert obj.ratelimit_remaining == 10 # Handle cases where we simply couldn't get this field del request.headers["X-RateLimit-Remaining"] assert obj.send(body="test") is True # It remains set to the last value assert obj.ratelimit_remaining == 10 # Reset our variable back to 1 request.headers["X-RateLimit-Remaining"] = 1 # Handle cases where our epoch time is wrong del request.headers["X-RateLimit-Limit"] assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block request.headers["X-RateLimit-Limit"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() + 1 request.headers["X-RateLimit-Remaining"] = 0 obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block request.headers["X-RateLimit-Limit"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() - 1 request.headers["X-RateLimit-Remaining"] = 0 obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our limits to always work request.headers["X-RateLimit-Limit"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() request.headers["X-RateLimit-Remaining"] = 1 obj.ratelimit_remaining = 1 # Alter pending targets obj.targets.append("usera") request.content = dumps(response_obj) response_obj = { "username": "usera", "id": 4321, } # Cause content response to be None request.content = None assert obj.send(body="test") is True # Invalid JSON request.content = "{" assert obj.send(body="test") is True # Return it to a parseable string request.content = "{}" results = NotifyMastodon.parse_url( f"mastodon://{token}@{host}/@user?visbility=direct" ) assert isinstance(results, dict) assert "@user" in results["targets"] # cause a json parsing issue now response_obj = None assert obj.send(body="test") is True response_obj = "{" assert obj.send(body="test") is True mock_get.reset_mock() mock_post.reset_mock() # # Test our lazy lookups # # Prepare a good status response request = mock.Mock() request.content = dumps({"id": "1234", "username": "caronc"}) request.status_code = requests.codes.ok mock_get.return_value = request mastodon_url = "mastodons://key@host?visibility=direct" obj = Apprise.instantiate(mastodon_url) obj._whoami(lazy=True) assert mock_get.call_count == 1 assert ( mock_get.call_args_list[0][0][0] == "https://host/api/v1/accounts/verify_credentials" ) mock_get.reset_mock() obj._whoami(lazy=True) assert mock_get.call_count == 0 mock_get.reset_mock() obj._whoami(lazy=False) assert mock_get.call_count == 1 assert ( mock_get.call_args_list[0][0][0] == "https://host/api/v1/accounts/verify_credentials" ) @mock.patch("requests.post") def test_plugin_mastodon_pings(mock_post): """NotifyMastodon() Ping Checks.""" token = "access_key" host = "nuxref.com" request = mock.Mock() request.content = dumps({}) request.status_code = requests.codes.ok request.headers = {} mock_post.return_value = request body = ( "Body **#dΓ©jΓ _vu** and @abc, plus #ε†δΌš. " "Ignore ##bad, #bad%, #123, email@miss, and duplicate " "@abc #dΓ©jΓ _vu." ) obj = NotifyMastodon( token=token, host=host, format=NotifyFormat.MARKDOWN, ping="@abc,#dΓ©jΓ _vu,#radarr,radarr,#123,bad%", ) assert obj.send(body=body) is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # Mentions and hashtags are appended in a plain form Mastodon can resolve. assert payload["status"] == f"{body} @abc #dΓ©jΓ _vu #ε†δΌš #radarr" mock_post.reset_mock() body = "Body with #solo only." obj = NotifyMastodon( token=token, host=host, format=NotifyFormat.MARKDOWN, ) assert obj.send(body=body) is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # Markdown statuses can contribute visible mention and hashtag tokens. assert payload["status"] == f"{body} #solo" mock_post.reset_mock() body = "

    No tags in this formatted body.

    " obj = NotifyMastodon( token=token, host=host, format=NotifyFormat.HTML, ) assert obj.send(body=body) is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # When no valid tag tokens are found, the status is not decorated. assert payload["status"] == body mock_post.reset_mock() obj = NotifyMastodon( token=token, host=host, ping="@caronc,#apprise,#alerts", ) assert isinstance(obj.url(), str) assert "ping=" in obj.url() results = NotifyMastodon.parse_url(obj.url()) assert results["ping"] == "#alerts,#apprise,@caronc" assert NotifyMastodon.normalize_ping_token(" ") is None assert NotifyMastodon.normalize_ping_token("alerts") is None assert NotifyMastodon.valid_hashtag("#bad%") is False assert obj.ping_tokens("bad%", normalize=True) == [] obj = NotifyMastodon( token=token, host=host, targets="#Apprise,#apprise", ping="#Media,#media", ) assert obj.hashtags == ["#Apprise"] assert obj.ping == ["#Media"] mock_post.reset_mock() results = NotifyMastodon.parse_url( f"mastodon://{token}@{host}/#apprise?ping=#apprise,#media" ) obj = NotifyMastodon(**results) assert obj.send(body="Body") is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # Hashtag path entries and configured pings share de-duplication. assert payload["status"] == "Body #apprise #media" rebuilt_url = obj.url() assert "/%23apprise" in rebuilt_url assert "ping=%23apprise%2C%23media" in rebuilt_url results = NotifyMastodon.parse_url(rebuilt_url) assert results["targets"] == ["#apprise"] assert results["ping"] == "#apprise,#media" mock_post.reset_mock() obj = NotifyMastodon(token=token, host=host, ping="#apprise") assert obj.send(body="a" * obj.body_maxlen) is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # The effective body limit reserves room for configured status tokens. assert obj.body_maxlen == obj.mastodon_body_maxlen - len(" #apprise") assert len(payload["status"]) == obj.mastodon_body_maxlen assert payload["status"].endswith(" #apprise") @mock.patch("requests.post") def test_plugin_mastodon_text(mock_post): """NotifyMastodon() Text Checks.""" token = "access_key" host = "nuxref.com" request = mock.Mock() request.content = dumps({}) request.status_code = requests.codes.ok request.headers = {} mock_post.return_value = request body = "Plain text keeps #dΓ©jΓ _vu and @abc exactly where they are." obj = NotifyMastodon( token=token, host=host, format=NotifyFormat.TEXT, ) assert obj.send(body=body) is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # Plain text is already suitable for Mastodon and is left untouched. assert payload["status"] == body mock_post.reset_mock() obj = NotifyMastodon( token=token, host=host, format=NotifyFormat.TEXT, targets="@media,#homeassistant", ping="radarr,@media,#homeassistant,#alerts,#123,bad%", ) assert obj.send(body=body) is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # Explicit path entries and ping values are appended without scanning for # new plain text body tokens. assert payload["status"] == f"{body} #alerts #homeassistant @media" @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_mastodon_direct(mock_post, mock_get): """NotifyMastodon() Direct Checks.""" token = "access_key" host = "nuxref.com" request = mock.Mock() request.content = dumps({}) request.status_code = requests.codes.ok request.headers = {} mock_post.return_value = request body = "Direct body already contains @target." obj = NotifyMastodon( token=token, host=host, targets="@target", visibility="direct", ) assert obj.send(body=body) is True assert mock_post.call_count == 1 assert mock_get.call_count == 0 payload = loads(mock_post.call_args_list[0][1]["data"]) # Explicit direct targets do not require a lookup for the authenticated # account, and duplicate body mentions are not prepended. assert payload["status"] == body mock_post.reset_mock() body = "Direct body contains email@target only." obj = NotifyMastodon( token=token, host=host, targets="@target", visibility="direct", ) assert obj.send(body=body) is True assert mock_post.call_count == 1 assert mock_get.call_count == 0 payload = loads(mock_post.call_args_list[0][1]["data"]) # Email-like text is not treated as a delivered direct-message mention. assert payload["status"] == f"@target {body}" @mock.patch("requests.post") @mock.patch("requests.get") def test_plugin_mastodon_attachments(mock_get, mock_post): """NotifyMastodon() Toot Attachment Checks.""" akey = "access_key" host = "nuxref.com" username = "caronc" # Prepare a good status response good_response_obj = { "id": "1234", } good_response = mock.Mock() good_response.content = dumps(good_response_obj) good_response.status_code = requests.codes.ok # Prepare a good whoami response good_whoami_response_obj = { "username": username, "id": "9876", } good_whoami_response = mock.Mock() good_whoami_response.content = dumps(good_whoami_response_obj) good_whoami_response.status_code = requests.codes.ok # Prepare bad response bad_response = mock.Mock() bad_response.content = dumps({}) bad_response.status_code = requests.codes.internal_server_error # Prepare a good media response good_media_response = mock.Mock() good_media_response.content = dumps( { "id": "710511363345354753", "file_mime": "image/jpeg", } ) good_media_response.status_code = requests.codes.ok # # Start testing using fixtures above # mock_post.side_effect = [good_media_response, good_response] mock_get.return_value = good_whoami_response mastodon_url = f"mastodon://{akey}@{host}" # attach our content attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # instantiate our object obj = Apprise.instantiate(mastodon_url) # Send our notification assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_get.call_count == 0 assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "http://nuxref.com/api/v1/media" ) assert ( mock_post.call_args_list[1][0][0] == "http://nuxref.com/api/v1/statuses" ) # Test our media payload assert "files" in mock_post.call_args_list[0][1] assert "file" in mock_post.call_args_list[0][1]["files"] assert ( mock_post.call_args_list[0][1]["files"]["file"][0] == "apprise-test.gif" ) # Test our status payload payload = loads(mock_post.call_args_list[1][1]["data"]) assert "status" in payload assert payload["status"] == "title\r\nbody" assert "sensitive" in payload assert payload["sensitive"] is False assert "media_ids" in payload assert isinstance(payload["media_ids"], list) assert len(payload["media_ids"]) == 1 assert payload["media_ids"][0] == "710511363345354753" # Verify we don't set incorrect keys not otherwise specified assert "spoiler_text" not in payload mock_get.reset_mock() mock_post.reset_mock() # # Handle the query again, but this time perform a direct message # requiring us to look up who we are # mock_post.side_effect = [good_media_response, good_response] mock_get.return_value = good_whoami_response mastodon_url = f"mastodon://{akey}@{host}?visibility=direct" # attach our content attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # instantiate our object obj = Apprise.instantiate(mastodon_url) # Send our notification assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_get.call_count == 1 assert mock_post.call_count == 2 assert ( mock_get.call_args_list[0][0][0] == "http://nuxref.com/api/v1/accounts/verify_credentials" ) assert ( mock_post.call_args_list[0][0][0] == "http://nuxref.com/api/v1/media" ) assert ( mock_post.call_args_list[1][0][0] == "http://nuxref.com/api/v1/statuses" ) # Test our status payload payload = loads(mock_post.call_args_list[1][1]["data"]) assert "status" in payload # Our ID was added into the payload assert payload["status"] == "@caronc title\r\nbody" assert "sensitive" in payload assert payload["sensitive"] is False assert "media_ids" in payload assert isinstance(payload["media_ids"], list) assert len(payload["media_ids"]) == 1 assert payload["media_ids"][0] == "710511363345354753" mock_get.reset_mock() mock_post.reset_mock() # Store 3 attachments attach = ( AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")), AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png")), AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.jpeg")), ) # Prepare a good media response mr1 = mock.Mock() mr1.content = dumps( { "id": "1", "file_mime": "image/gif", } ) mr1.status_code = requests.codes.ok mr2 = mock.Mock() mr2.content = dumps( { "id": "2", "file_mime": "image/png", } ) mr2.status_code = requests.codes.ok mr3 = mock.Mock() mr3.content = dumps( { "id": "3", "file_mime": "image/jpeg", } ) mr3.status_code = requests.codes.ok # Return 3 good uploads and a good status response mock_post.side_effect = [mr1, mr2, mr3, good_response, good_response] mock_get.return_value = good_whoami_response # instantiate our object mastodon_url = ( f"mastodons://{akey}@{host}?visibility=direct&sensitive=yes&key=abcd" ) obj = Apprise.instantiate(mastodon_url) # Send ourselves a direct message where our ID was already found # in the body. This smart detection method will prevent us from # adding the @caronc to the begining of the same message (since it's a # direct message) assert ( obj.notify( body="Check this out @caronc", title="Apprise", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_get.call_count == 1 assert mock_post.call_count == 5 assert ( mock_post.call_args_list[0][0][0] == "https://nuxref.com/api/v1/media" ) assert ( mock_post.call_args_list[1][0][0] == "https://nuxref.com/api/v1/media" ) assert ( mock_post.call_args_list[2][0][0] == "https://nuxref.com/api/v1/media" ) # Our status's will batch up and send the last 2 images in one # and our animated gif in one. assert ( mock_post.call_args_list[3][0][0] == "https://nuxref.com/api/v1/statuses" ) assert ( mock_post.call_args_list[4][0][0] == "https://nuxref.com/api/v1/statuses" ) assert ( mock_get.call_args_list[0][0][0] == "https://nuxref.com/api/v1/accounts/verify_credentials" ) # Test our status payload payload = loads(mock_post.call_args_list[3][1]["data"]) assert "status" in payload assert payload["status"] == "Apprise\r\nCheck this out @caronc" assert "sensitive" in payload assert payload["sensitive"] is True assert "language" not in payload assert "Idempotency-Key" in payload assert payload["Idempotency-Key"] == "abcd" assert "media_ids" in payload assert isinstance(payload["media_ids"], list) assert len(payload["media_ids"]) == 1 assert payload["media_ids"][0] == "1" payload = loads(mock_post.call_args_list[4][1]["data"]) assert "status" in payload assert payload["status"] == "02/02" assert "sensitive" in payload assert payload["sensitive"] is False assert "language" not in payload assert "Idempotency-Key" in payload assert payload["Idempotency-Key"] == "abcd-part01" assert "media_ids" in payload assert isinstance(payload["media_ids"], list) assert len(payload["media_ids"]) == 2 assert "2" in payload["media_ids"] assert "3" in payload["media_ids"] # A second call does not cause us to look up our ID as we already know it mock_get.reset_mock() mock_post.reset_mock() mock_post.side_effect = [mr1, mr2, mr3, good_response, good_response] mock_get.return_value = good_whoami_response assert ( obj.notify( body="Check this out @caronc", title="Apprise", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Same number of posts assert mock_post.call_count == 5 # But no lookup was made assert mock_get.call_count == 0 mock_get.reset_mock() mock_post.reset_mock() # Prepare an attach list attach = ( os.path.join(TEST_VAR_DIR, "apprise-test.png"), os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), ) mock_post.side_effect = [mr2, mr3, good_response, good_response] mock_get.return_value = good_whoami_response # instantiate our object (but turn off the batch mode) mastodon_url = f"mastodons://{akey}@{host}?batch=no" obj = Apprise.instantiate(mastodon_url) assert ( obj.notify( body="Check this out @caronc", title="Apprise", notify_type=NotifyType.INFO, attach=attach, ) is True ) # 2 attachments + 2 different status messages assert mock_post.call_count == 4 # But no lookup was made assert mock_get.call_count == 0 assert ( mock_post.call_args_list[0][0][0] == "https://nuxref.com/api/v1/media" ) assert ( mock_post.call_args_list[1][0][0] == "https://nuxref.com/api/v1/media" ) assert ( mock_post.call_args_list[2][0][0] == "https://nuxref.com/api/v1/statuses" ) assert ( mock_post.call_args_list[3][0][0] == "https://nuxref.com/api/v1/statuses" ) mock_get.reset_mock() mock_post.reset_mock() # Prepare a bad media response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error bad_responses = ( dumps({"error": "authorized scopes"}), "", ) # # Test our Media failures # # Try several bad responses so we can capture the block of code where # we try to help the end user to remind them what scope they're missing for response in bad_responses: mock_post.side_effect = [good_media_response, bad_response] bad_response.content = response # instantiate our object mastodon_url = ( f"mastodons://{akey}@{host}?visibility=public&spoiler=uhoh" ) obj = Apprise.instantiate(mastodon_url) # Our notification will fail now since our toot will error out # This is the same test as above, except our error response isn't # parseable assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Test our call count assert mock_get.call_count == 0 assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://nuxref.com/api/v1/media" ) assert ( mock_post.call_args_list[1][0][0] == "https://nuxref.com/api/v1/media" ) mock_get.reset_mock() mock_post.reset_mock() # # Test our Status failures # # Try several bad responses so we can capture the block of code where # we try to help the end user to remind them what scope they're missing for response in bad_responses: mock_post.side_effect = [bad_response] bad_response.content = response # instantiate our object mastodon_url = f"mastodons://{akey}@{host}" obj = Apprise.instantiate(mastodon_url) # Our notification will fail now since our toot will error out # This is the same test as above, except our error response isn't # parseable assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # Test our call count assert mock_get.call_count == 0 assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://nuxref.com/api/v1/statuses" ) mock_get.reset_mock() mock_post.reset_mock() # # Test our whoami failures # # Try several bad responses so we can capture the block of code where # we try to help the end user to remind them what scope they're missing for response in bad_responses: mock_get.side_effect = [bad_response] bad_response.content = response # instantiate our object mastodon_url = f"mastodons://{akey}@{host}?visibility=direct" obj = Apprise.instantiate(mastodon_url) # Our notification will fail now since our toot will error out # This is the same test as above, except our error response isn't # parseable assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # Test our call count assert mock_get.call_count == 1 assert mock_post.call_count == 0 assert ( mock_get.call_args_list[0][0][0] == "https://nuxref.com/api/v1/accounts/verify_credentials" ) mock_get.reset_mock() mock_post.reset_mock() mock_post.side_effect = [mr1, mr2, mr3, good_response, good_response] mock_get.return_value = None # instantiate our object mastodon_url = f"mastodons://{akey}@{host}" obj = Apprise.instantiate(mastodon_url) # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # No get requests are made assert mock_get.call_count == 0 # No post request as attachment is no good anyway assert mock_post.call_count == 0 mock_get.reset_mock() mock_post.reset_mock() # We have an OSError thrown in the middle of our preparation mock_post.side_effect = [ good_media_response, OSError(), good_media_response, ] mock_get.return_value = good_response # 3 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-archive.zip"), # A supported video file os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] # We'll fail to send this time assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_get.call_count == 0 # No get request as cached response is used assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://nuxref.com/api/v1/media" ) assert ( mock_post.call_args_list[1][0][0] == "https://nuxref.com/api/v1/media" ) @mock.patch("requests.post") def test_plugin_mastodon_apprise_tags(mock_post): """NotifyMastodon() - Apprise tag filter must not corrupt URLBase.tags. Regression test for a bug where self.hashtags = [] in __init__ was mistakenly named self.tags, overwriting the set() that URLBase stores for Apprise's own notification-tag system. Any call to Apprise.find() or a tag-filtered Apprise.notify() would crash with: AttributeError: 'list' object has no attribute 'union' """ akey = "access_key" host = "mastodon.social" # Prepare a good response good_response = mock.Mock() good_response.content = b"{}" good_response.status_code = requests.codes.ok mock_post.return_value = good_response # Load a Mastodon URL that carries Mastodon hashtag targets into an Apprise # instance that also has Apprise notification tags assigned. The Mastodon # #hashtag and the Apprise tag are distinct concepts and must not collide. a = Apprise() assert ( a.add( f"mastodon://{akey}@{host}/%23apprise", tag="prod", ) is True ) # Confirm the plugin loaded and its Apprise tag set is a proper set (not a # list), which is what caused the AttributeError before the fix. plugins = list(a) assert len(plugins) == 1 assert isinstance(plugins[0].tags, set) assert "prod" in plugins[0].tags # Confirm the Mastodon hashtag target was stored under hashtags, not tags assert plugins[0].hashtags == ["#apprise"] # Exercising Apprise.find() is the exact path that crashed before the fix; # it calls is_exclusive_match(data=notification.tags, ...) which requires # notification.tags to support .union(). found = list(a.find("prod")) assert len(found) == 1 # A tag-filtered notify() follows the same code path assert a.notify(body="test", tag="prod") is True assert mock_post.call_count == 1 caronc-apprise-182f859/tests/test_plugin_matrix.py000066400000000000000000005365361524161175200223730ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps, loads # Disable logging for a cleaner testing output import logging import os from typing import Union from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import ( Apprise, AppriseAsset, AppriseAttachment, NotifyFormat, NotifyType, OverflowMode, PersistentStoreMode, ) from apprise.plugins.matrix import MatrixDiscoveryException, NotifyMatrix # Use Matrix's internal limit and mode names for exact boundary assertions. from apprise.plugins.matrix.base import ( MATRIX_CONTENT_BYTE_LIMIT, MatrixWebhookMode, ) logging.disable(logging.CRITICAL) MATRIX_GOOD_RESPONSE = dumps( { "room_id": "!abc123:localhost", "room_alias": "#abc123:localhost", "joined_rooms": ["!abc123:localhost", "!def456:localhost"], "access_token": "abcd1234", "home_server": "localhost", # user_id is returned by /login and /account/whoami; including it # here ensures self.user_id is set after login so _whoami() is not # called unnecessarily (it only fires when user_id is still absent). "user_id": "@apprise:localhost", # Simulate .well-known "m.homeserver": {"base_url": "https://matrix.example.com"}, "m.identity_server": {"base_url": "https://vector.im"}, } ) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ################################## # NotifyMatrix ################################## ( "matrix://", { "instance": None, }, ), ( "matrixs://", { "instance": None, }, ), ( "matrix://localhost?mode=off", { # treats it as a anonymous user to register "instance": NotifyMatrix, # response is false because we have nothing to notify "response": False, }, ), ( "matrix://localhost", { # response is TypeError because we'll try to initialize as # a t2bot and fail (localhost is too short of a api key) "instance": TypeError }, ), ( "matrix://user:pass@localhost/#room1/#room2/#room3", { "instance": NotifyMatrix, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "matrix://user:pass@localhost/#room1/#room2/!room1", { "instance": NotifyMatrix, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "matrix://user:pass@localhost:1234/#room", { "instance": NotifyMatrix, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, # Our expected url(privacy=True) startswith() response: "privacy_url": "matrix://user:****@localhost:1234/", }, ), # Matrix supports webhooks too; the following tests this now: ( "matrix://user:token@localhost?mode=matrix&format=text", { # user and token correctly specified with webhook "instance": NotifyMatrix, "response": False, }, ), ( "matrix://user:token@localhost?mode=matrix&format=html", { # user and token correctly specified with webhook "instance": NotifyMatrix, }, ), ( "matrix://user:token@localhost:123/#general/?version=3", { # Provide version over-ride (using version=) "instance": NotifyMatrix, # Our response expected server response "requests_response_text": MATRIX_GOOD_RESPONSE, "privacy_url": "matrix://user:****@localhost:123", }, ), ( "matrixs://user:token@localhost/#general?v=2", { # Provide version over-ride (using v=) "instance": NotifyMatrix, # Our response expected server response "requests_response_text": MATRIX_GOOD_RESPONSE, "privacy_url": "matrixs://user:****@localhost", }, ), ( "matrix://user:token@localhost:123/#general/?v=invalid", { # Invalid version specified "instance": TypeError }, ), ( "matrix://user:token@localhost?mode=slack&format=text", { # user and token correctly specified with webhook "instance": NotifyMatrix, }, ), ( "matrixs://user:token@localhost?mode=SLACK&format=markdown", { # user and token specified; slack webhook still detected # despite uppercase characters "instance": NotifyMatrix, }, ), ( "matrix://user@localhost?mode=SLACK&format=markdown&token=mytoken", { # user and token specified; slack webhook still detected # despite uppercase characters; token also set on URL as arg "instance": NotifyMatrix, }, ), ( "matrix://_?mode=t2bot&token={}".format("b" * 64), { # Testing t2bot initialization and setting the password using the # token directive "instance": NotifyMatrix, # Our expected url(privacy=True) startswith() response: "privacy_url": "matrix://b...b/", }, ), ( ( "matrixs://hookuser:hooktoken@hookshot.example" "?mode=hookshot&path=%2Fpublic-hooks" ), { "instance": NotifyMatrix, "privacy_url": ( "matrixs://hookuser:****@hookshot.example/" "?image=no&mode=hookshot" ), }, ), ( ( "matrixs://hookuser:hooktoken@hookshot.example" "?mode=hookshot&path=public-hooks" ), { "instance": NotifyMatrix, }, ), # Image Reference ( "matrixs://user:token@localhost?mode=slack&format=markdown&image=True", { # user and token specified; image set to True "instance": NotifyMatrix, }, ), ( ( "matrixs://user:token@localhost?mode=slack" "&format=markdown&image=False" ), { # user and token specified; image set to True "instance": NotifyMatrix, }, ), # A Bunch of bad ports ( "matrixs://user:pass@hostname:port/#room_alias", { # Invalid Port specified (was a string) "instance": TypeError, }, ), ( "matrixs://user:pass@hostname:0/#room_alias", { # Invalid Port specified (was a string) "instance": TypeError, }, ), ( "matrixs://user:pass@hostname:65536/#room_alias", { # Invalid Port specified (was a string) "instance": TypeError, }, ), # More general testing... ( "matrixs://user@{}?mode=t2bot&format=markdown&image=True".format( "a" * 64 ), { # user and token specified; image set to True "instance": NotifyMatrix }, ), ( "matrix://user@{}?mode=t2bot&format=html&image=False".format("z" * 64), { # user and token specified; image set to True "instance": NotifyMatrix }, ), # This will default to t2bot because no targets were specified and no # password ( "matrixs://{}".format("c" * 64), { "instance": NotifyMatrix, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # Test Native URL ( "https://webhooks.t2bot.io/api/v1/matrix/hook/{}/".format("d" * 64), { # user and token specified; image set to True "instance": NotifyMatrix, }, ), ( "matrix://user:token@localhost?mode=On", { # invalid webhook specified (unexpected boolean) "instance": TypeError, }, ), ( "matrix://token@localhost/?mode=Matrix", { "instance": NotifyMatrix, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "matrix://user:token@localhost/mode=matrix", { "instance": NotifyMatrix, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "matrix://token@localhost:8080/?mode=slack", { "instance": NotifyMatrix, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "matrix://{}/?mode=t2bot".format("b" * 64), { "instance": NotifyMatrix, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_matrix_urls(): """NotifyMatrix() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_general(mock_post, mock_get, mock_put): """NotifyMatrix() General Tests.""" response_obj = { "room_id": "!abc123:localhost", "room_alias": "#abc123:localhost", "joined_rooms": ["!abc123:localhost", "!def456:localhost"], "access_token": "abcd1234", "home_server": "localhost", } request = mock.Mock() request.content = dumps(response_obj) request.status_code = requests.codes.ok # Prepare Mock mock_get.return_value = request mock_post.return_value = request mock_put.return_value = request # Variation Initializations obj = NotifyMatrix(host="host", targets="#abcd") assert isinstance(obj, NotifyMatrix) assert isinstance(obj.url(), str) # Registration successful assert obj.send(body="test") is True del obj obj = NotifyMatrix(host="host", user="user", targets="#abcd") assert isinstance(obj, NotifyMatrix) assert isinstance(obj.url(), str) # Registration successful assert obj.send(body="test") is True del obj obj = NotifyMatrix(host="host", password="passwd", targets="#abcd") assert isinstance(obj, NotifyMatrix) assert isinstance(obj.url(), str) # A username gets automatically generated in these cases assert obj.send(body="test") is True del obj obj = NotifyMatrix( host="host", user="user", password="passwd", targets="#abcd" ) assert isinstance(obj.url(), str) assert isinstance(obj, NotifyMatrix) # Registration Successful assert obj.send(body="test") is True del obj # Test sending other format types kwargs = NotifyMatrix.parse_url( "matrix://user:passwd@hostname/#abcd?format=html" ) obj = NotifyMatrix(**kwargs) assert isinstance(obj.url(), str) assert isinstance(obj, NotifyMatrix) assert obj.send(body="test") is True assert obj.send(title="title", body="test") is True del obj kwargs = NotifyMatrix.parse_url( "matrix://user:passwd@hostname/#abcd/#abcd:localhost?format=markdown" ) obj = NotifyMatrix(**kwargs) assert isinstance(obj.url(), str) assert isinstance(obj, NotifyMatrix) assert obj.send(body="test") is True assert obj.send(title="title", body="test") is True del obj kwargs = NotifyMatrix.parse_url( "matrix://user:passwd@hostname/#abcd/!abcd:localhost?format=text" ) obj = NotifyMatrix(**kwargs) assert isinstance(obj.url(), str) assert isinstance(obj, NotifyMatrix) is True assert obj.send(body="test") is True assert obj.send(title="title", body="test") is True del obj # Test notice type notifications kwargs = NotifyMatrix.parse_url( "matrix://user:passwd@hostname/#abcd?msgtype=notice" ) obj = NotifyMatrix(**kwargs) assert isinstance(obj.url(), str) is True assert isinstance(obj, NotifyMatrix) is True assert obj.send(body="test") is True assert obj.send(title="title", body="test") is True with pytest.raises(TypeError): # invalid message type specified kwargs = NotifyMatrix.parse_url( "matrix://user:passwd@hostname/#abcd?msgtype=invalid" ) NotifyMatrix(**kwargs) # Force a failed login ro = response_obj.copy() del ro["access_token"] request.content = dumps(ro) request.status_code = 404 # Fails because we couldn't register because of 404 errors assert obj.send(body="test") is False del obj obj = NotifyMatrix(host="host", user="test", targets="#abcd") assert isinstance(obj, NotifyMatrix) is True # Fails because we still couldn't register assert obj.send(user="test", password="passwd", body="test") is False del obj obj = NotifyMatrix( host="host", user="test", password="passwd", targets="#abcd" ) assert isinstance(obj, NotifyMatrix) is True # Fails because we still couldn't register assert obj.send(body="test") is False del obj obj = NotifyMatrix(host="host", password="passwd", targets="#abcd") # Fails because we still couldn't register assert isinstance(obj, NotifyMatrix) is True assert obj.send(body="test") is False # Force a empty joined list response ro = response_obj.copy() ro["joined_rooms"] = [] request.content = dumps(ro) assert obj.send(user="test", password="passwd", body="test") is False # Fall back to original template request.content = dumps(response_obj) request.status_code = requests.codes.ok # update our response object so logins now succeed response_obj["user_id"] = "@apprise:localhost" # Login was successful but not get a room_id ro = response_obj.copy() del ro["room_id"] request.content = dumps(ro) assert obj.send(user="test", password="passwd", body="test") is False # Fall back to original template request.content = dumps(response_obj) request.status_code = requests.codes.ok del obj obj = NotifyMatrix(host="host", targets=None) assert isinstance(obj, NotifyMatrix) is True # Force a empty joined list response ro = response_obj.copy() ro["joined_rooms"] = [] request.content = dumps(ro) assert obj.send(user="test", password="passwd", body="test") is False # Fall back to original template request.content = dumps(response_obj) request.status_code = requests.codes.ok # our room list is empty so we'll have retrieved the joined_list # as our backup assert obj.send(user="test", password="passwd", body="test") is True del obj @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_fetch(mock_post, mock_get, mock_put): """NotifyMatrix() Server Fetch/API Tests.""" response_obj = { "room_id": "!abc123:localhost", "room_alias": "#abc123:localhost", "joined_rooms": ["!abc123:localhost", "!def456:localhost"], # Login details "access_token": "abcd1234", "user_id": "@apprise:localhost", "home_server": "localhost", } def fetch_failed(url, *args, **kwargs): # Default configuration request = mock.Mock() request.status_code = requests.codes.ok request.content = dumps(response_obj) if url.find("/rooms/") > -1: # over-ride on room query request.status_code = 403 request.content = dumps( { "errcode": "M_UNKNOWN", "error": "Internal server error", } ) return request mock_put.side_effect = fetch_failed mock_get.side_effect = fetch_failed mock_post.side_effect = fetch_failed obj = NotifyMatrix( host="host", user="user", password="passwd", include_image=True ) assert isinstance(obj, NotifyMatrix) is True # We would hve failed to send our image notification assert obj.send(user="test", password="passwd", body="test") is False del obj # Do the same query with no images to fetch asset = AppriseAsset(image_path_mask=False, image_url_mask=False) obj = NotifyMatrix( host="host", user="user", password="passwd", asset=asset ) assert isinstance(obj, NotifyMatrix) is True # We would hve failed to send our notification assert obj.send(user="test", password="passwd", body="test") is False del obj response_obj = { # Registration "access_token": "abcd1234", "user_id": "@apprise:localhost", "home_server": "localhost", # For room joining "room_id": "!abc123:localhost", } # Default configuration mock_get.side_effect = None mock_post.side_effect = None mock_put.side_effect = None request = mock.Mock() request.status_code = requests.codes.ok request.content = dumps(response_obj) mock_post.return_value = request mock_get.return_value = request mock_put.return_value = request obj = NotifyMatrix(host="host", include_image=True) assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None assert obj._register() is True assert obj.access_token is not None # Cause retries request.status_code = 429 request.content = dumps( { "retry_after_ms": 1, } ) postokay, _response, _ = obj._fetch("/retry/apprise/unit/test") assert postokay is False request.content = dumps( { "error": { "retry_after_ms": 1, } } ) postokay, _response, _ = obj._fetch("/retry/apprise/unit/test") assert postokay is False request.content = dumps({"error": {}}) postokay, _response, _ = obj._fetch("/retry/apprise/unit/test") assert postokay is False del obj @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_auth(mock_post, mock_get, mock_put): """NotifyMatrix() Server Authentication.""" response_obj = { # Registration "access_token": "abcd1234", "user_id": "@apprise:localhost", "home_server": "localhost", } # Default configuration request = mock.Mock() request.status_code = requests.codes.ok request.content = dumps(response_obj) mock_post.return_value = request mock_get.return_value = request mock_put.return_value = request obj = NotifyMatrix(host="localhost") assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None # logging out without an access_token is silently a success assert obj._logout() is True assert obj.access_token is None assert obj._register() is True assert obj.access_token is not None # Logging in is silently treated as a success because we # already had success registering assert obj._login() is True assert obj.access_token is not None # However if we log out assert obj._logout() is True assert obj.access_token is None # And set ourselves up for failure request.status_code = 403 assert obj._login() is False assert obj.access_token is None # Reset our token obj.access_token = None # Adjust our response to be invalid - missing access_token in response request.status_code = requests.codes.ok ro = response_obj.copy() del ro["access_token"] request.content = dumps(ro) # Our registration will fail now assert obj._register() is False assert obj.access_token is None del obj # So will login obj = NotifyMatrix(host="host", user="user", password="password") assert isinstance(obj, NotifyMatrix) is True assert obj._login() is False assert obj.access_token is None # Adjust our response to be invalid - invalid json response request.content = "{" # Our registration will fail now assert obj._register() is False assert obj.access_token is None request.status_code = requests.codes.ok request.content = dumps(response_obj) assert obj._register() is True assert obj.access_token is not None # Test logoff when getting a 403 error request.status_code = 403 assert obj._logout() is False assert obj.access_token is not None request.status_code = requests.codes.ok request.content = dumps(response_obj) assert obj._register() is True assert obj.access_token is not None request.status_code = 403 request.content = dumps( { "errcode": "M_UNKNOWN_TOKEN", "error": "Access Token unknown or expired", } ) # Test logoff when getting a 403 error; but if we have the right error # code in the response, then we return a True assert obj._logout() is True assert obj.access_token is None del obj # Modern Matrix servers omit home_server from login/register responses. # Apprise must derive it from user_id so room-alias resolution works. modern_resp = { "access_token": "tok99", "user_id": "@bot:matrix.org", "device_id": "DEVXYZ", # no "home_server" key -- matches real matrix.org behaviour } request.status_code = requests.codes.ok request.content = dumps(modern_resp) # No home_server in response + no prior home_server -> derive from user_id obj2 = NotifyMatrix(host="matrix.org", user="bot", password="pass") assert obj2._login() is True assert obj2.home_server == "matrix.org" obj3 = NotifyMatrix(host="matrix.org", user="bot", password="pass") assert obj3._register() is True assert obj3.home_server == "matrix.org" # No home_server in response + home_server already set -> not overwritten obj4 = NotifyMatrix(host="matrix.org", user="bot", password="pass") obj4.home_server = "already.set" assert obj4._login() is True assert obj4.home_server == "already.set" obj5 = NotifyMatrix(host="matrix.org", user="bot", password="pass") obj5.home_server = "already.set" assert obj5._register() is True assert obj5.home_server == "already.set" # No home_server in response + user_id has no colon -> # home_server stays None no_colon_resp = { "access_token": "tok88", "user_id": "@nocoilon", "device_id": "DEV88", } request.content = dumps(no_colon_resp) obj6 = NotifyMatrix(host="matrix.org", user="bot", password="pass") assert obj6._login() is True assert obj6.home_server is None obj7 = NotifyMatrix(host="matrix.org", user="bot", password="pass") assert obj7._register() is True assert obj7.home_server is None del obj2, obj3, obj4, obj5, obj6, obj7 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_rooms(mock_post, mock_get, mock_put): """NotifyMatrix() Room Testing.""" response_obj = { # Registration "access_token": "abcd1234", "user_id": "@apprise:localhost", "home_server": "localhost", # For joined_room response "joined_rooms": ["!abc123", "!def456:localhost"], # For room joining "room_id": "!abc123", } # Default configuration request = mock.Mock() request.status_code = requests.codes.ok request.content = dumps(response_obj) mock_post.return_value = request mock_get.return_value = request mock_put.return_value = request obj = NotifyMatrix(host="host") assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None # Can't get room listing if we're not connnected assert obj._room_join("#abc123") is None assert obj._register() is True assert obj.access_token is not None assert obj._room_join("!abc123") == response_obj["room_id"] # Use cache to get same results assert obj.store.get("!abc123") is None # However this is how the cache entry gets stored assert obj.store.get("!abc123:localhost") is not None assert obj.store.get("!abc123:localhost")["id"] == response_obj["room_id"] # When hsreq=yes, legacy behaviour is restored and a homeserver is # automatically appended to room IDs that omit it. obj.store.clear() obj.hsreq = True mock_post.reset_mock() assert obj._room_join("!abc123") == response_obj["room_id"] assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://host/_matrix/client/v3/join/%21abc123%3Alocalhost" ) # When hsreq=no, we honour the raw !room identifier exactly as provided # and do not suffix it with :homeserver. obj.store.clear() obj.hsreq = False def _join_side_effect(url, *args, **kwargs): r = mock.Mock() # With hsreq disabled, only the raw form should be attempted: if url.endswith("/_matrix/client/v3/join/%21abc123"): r.status_code = requests.codes.ok r.content = dumps(response_obj).encode("utf-8") return r # Default ok for any other unexpected call r.status_code = requests.codes.ok r.content = dumps(response_obj).encode("utf-8") return r mock_post.reset_mock() mock_post.side_effect = _join_side_effect assert obj._room_join("!abc123") == response_obj["room_id"] assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://host/_matrix/client/v3/join/%21abc123" ) mock_post.reset_mock() assert obj._room_join("!abc123") == response_obj["room_id"] # Cache is used assert mock_post.call_count == 0 # Still using cache assert obj._room_join("!abc123:localhost") == response_obj["room_id"] assert mock_post.call_count == 0 # Toggle our settings back obj.hsreq = True mock_post.reset_mock() mock_post.side_effect = _join_side_effect assert obj._room_join("!abc123") == response_obj["room_id"] # We still no longer need to fetch as we know the info already assert mock_post.call_count == 0 # Restore defaults for remaining tests mock_post.side_effect = None obj.hsreq = True # Use cache to get same results (no additional HTTP call) mock_post.reset_mock() assert obj._room_join("!abc123") == response_obj["room_id"] assert mock_post.call_count == 0 obj.store.clear() assert obj._room_join("!abc123:localhost") == response_obj["room_id"] assert obj.store.get("!abc123:localhost") is not None assert obj.store.get("!abc123:localhost")["id"] == response_obj["room_id"] # Use cache to get same results assert obj._room_join("!abc123:localhost") == response_obj["room_id"] obj.store.clear() assert obj._room_join("abc123") == response_obj["room_id"] # Use cache to get same results assert obj.store.get("#abc123:localhost") is not None assert obj.store.get("#abc123:localhost")["id"] == response_obj["room_id"] assert obj._room_join("abc123") == response_obj["room_id"] # If home_server is missing, recover it from user_id so we never try # to join a '#room:None' alias. obj.store.clear() obj.home_server = None obj.user_id = "@apprise:localhost" mock_post.reset_mock() assert obj._room_join("abc123") == response_obj["room_id"] assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://host/_matrix/client/v3/join/%23abc123%3Alocalhost" ) obj.store.clear() assert obj._room_join("abc123:localhost") == response_obj["room_id"] # Use cache to get same results assert obj.store.get("#abc123:localhost") is not None assert obj.store.get("#abc123:localhost")["id"] == response_obj["room_id"] assert obj._room_join("abc123:localhost") == response_obj["room_id"] obj.store.clear() assert obj._room_join("#abc123:localhost") == response_obj["room_id"] # Use cache to get same results assert obj.store.get("#abc123:localhost") is not None assert obj.store.get("#abc123:localhost")["id"] == response_obj["room_id"] assert obj._room_join("#abc123:localhost") == response_obj["room_id"] obj.store.clear() assert obj._room_join("%") is None assert obj._room_join(None) is None obj.store.clear() obj.home_server = None obj.user_id = "@apprise" mock_post.reset_mock() assert obj._room_join("abc123") is None assert mock_post.call_count == 0 # 403 response; this will push for a room creation for alias based rooms # and these will fail request.status_code = 403 obj.store.clear() assert obj._room_join("!abc123") is None obj.store.clear() assert obj._room_join("!abc123:localhost") is None obj.store.clear() assert obj._room_join("abc123") is None obj.store.clear() assert obj._room_join("abc123:localhost") is None obj.store.clear() assert obj._room_join("#abc123:localhost") is None del obj # Room creation request.status_code = requests.codes.ok obj = NotifyMatrix(host="host") assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None # Can't get room listing if we're not connnected assert obj._room_create("#abc123") is None assert obj._register() is True assert obj.access_token is not None # You can't add room_id's, they must be aliases assert obj._room_create("!abc123") is None assert obj._room_create("!abc123:localhost") is None obj.store.clear() assert obj._room_create("abc123") == response_obj["room_id"] obj.store.clear() assert obj._room_create("abc123:localhost") == response_obj["room_id"] obj.store.clear() assert obj._room_create("#abc123:localhost") == response_obj["room_id"] obj.store.clear() assert obj._room_create("%") is None assert obj._room_create(None) is None # 403 response; this will push for a room creation for alias based rooms # and these will fail request.status_code = 403 obj.store.clear() assert obj._room_create("abc123") is None obj.store.clear() assert obj._room_create("abc123:localhost") is None obj.store.clear() assert obj._room_create("#abc123:localhost") is None request.status_code = 403 request.content = dumps( { "errcode": "M_ROOM_IN_USE", "error": "Room alias already taken", } ) obj.store.clear() # This causes us to look up a channel ID if we get a ROOM_IN_USE response assert obj._room_create("#abc123:localhost") is None del obj # Room detection request.status_code = requests.codes.ok request.content = dumps(response_obj) obj = NotifyMatrix(host="localhost") assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None # No rooms if we're not connected response = obj._joined_rooms() assert isinstance(response, list) is True assert len(response) == 0 # register our account assert obj._register() is True assert obj.access_token is not None response = obj._joined_rooms() assert isinstance(response, list) is True assert len(response) == len(response_obj["joined_rooms"]) for r in response: assert r in response_obj["joined_rooms"] request.status_code = 403 response = obj._joined_rooms() assert isinstance(response, list) is True assert len(response) == 0 del obj # Room id lookup request.status_code = requests.codes.ok obj = NotifyMatrix(host="localhost") assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None # Can't get room listing if we're not connnected assert obj._room_id("#abc123") is None assert obj._register() is True assert obj.access_token is not None # You can't add room_id's, they must be aliases assert obj._room_id("!abc123") is None assert obj._room_id("!abc123:localhost") is None obj.store.clear() assert obj._room_id("abc123") == response_obj["room_id"] obj.store.clear() assert obj._room_id("abc123:localhost") == response_obj["room_id"] obj.store.clear() assert obj._room_id("#abc123:localhost") == response_obj["room_id"] obj.store.clear() assert obj._room_id("%") is None assert obj._room_id(None) is None # If we can't look the code up, we return None request.status_code = 403 obj.store.clear() assert obj._room_id("#abc123:localhost") is None # Force a object removal (thus a logout call) del obj def test_plugin_matrix_url_parsing(): """NotifyMatrix() URL Testing.""" result = NotifyMatrix.parse_url("matrix://user:token@localhost?to=#room") assert isinstance(result, dict) is True assert len(result["targets"]) == 1 assert "#room" in result["targets"] result = NotifyMatrix.parse_url( "matrix://user:token@localhost?to=#room&hsreq=yes" ) assert isinstance(result, dict) is True assert result.get("hsreq") is True result = NotifyMatrix.parse_url( "matrix://user:token@localhost?to=#room1,#room2,#room3" ) assert isinstance(result, dict) is True assert len(result["targets"]) == 3 assert "#room1" in result["targets"] assert "#room2" in result["targets"] assert "#room3" in result["targets"] # Mixed-case alias with underscore should parse result = NotifyMatrix.parse_url( "matrix://user:token@localhost?to=#Dev_Room:localhost" ) assert isinstance(result, dict) is True assert len(result["targets"]) == 1 assert "#Dev_Room:localhost" in result["targets"] # Mixed-case room id with underscore should be accepted by _room_join from apprise.plugins.matrix import IS_ROOM_ID # local alias nm = NotifyMatrix(host="localhost") nm.access_token = "abc" # simulate logged-in nm.home_server = "localhost" # this should NOT be rejected by the regex assert IS_ROOM_ID.match("!Jm_LvU1nas_8KJPBmN9n:nginx.eu") @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_image_errors(mock_post, mock_get, mock_put): """NotifyMatrix() Image Error Handling.""" def mock_function_handing(url, data, **kwargs): """Dummy function for handling image posts (as a failure)""" response_obj = { "room_id": "!abc123:localhost", "room_alias": "#abc123:localhost", "joined_rooms": ["!abc123:localhost", "!def456:localhost"], "access_token": "abcd1234", "home_server": "localhost", } request = mock.Mock() request.content = dumps(response_obj) request.status_code = requests.codes.ok if "m.image" in data: # Fail for images request.status_code = 400 return request # Prepare Mock mock_get.side_effect = mock_function_handing mock_post.side_effect = mock_function_handing mock_put.side_effect = mock_function_handing obj = NotifyMatrix(host="host", include_image=True, version="2") assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None # Notification was successful, however we could not post image and since # we had post errors (of any kind) we still report a failure. assert obj.notify("test", "test") is False del obj obj = NotifyMatrix(host="host", include_image=False, version="2") assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None # We didn't post an image (which was set to fail) and therefore our # post was okay assert obj.notify("test", "test") is True # Force a object removal (thus a logout call) del obj def mock_function_handing(url, data, **kwargs): """Dummy function for handling image posts (successfully)""" response_obj = { "room_id": "!abc123:localhost", "room_alias": "#abc123:localhost", "joined_rooms": ["!abc123:localhost", "!def456:localhost"], "access_token": "abcd1234", "home_server": "localhost", } request = mock.Mock() request.content = dumps(response_obj) request.status_code = requests.codes.ok return request # Prepare Mock mock_get.side_effect = mock_function_handing mock_put.side_effect = mock_function_handing mock_post.side_effect = mock_function_handing obj = NotifyMatrix(host="host", include_image=True) assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None assert obj.notify("test", "test") is True del obj obj = NotifyMatrix(host="host", include_image=False) assert isinstance(obj, NotifyMatrix) is True assert obj.access_token is None assert obj.notify("test", "test") is True # Force a object removal (thus a logout call) del obj @mock.patch("requests.put") @mock.patch("requests.post") def test_plugin_matrix_attachments_api_v3(mock_post, mock_put): """NotifyMatrix() Attachment Checks (v3)""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") # Prepare a bad response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error # Prepare Mock return object mock_post.return_value = response mock_put.return_value = response # Instantiate our object obj = Apprise.instantiate("matrix://user:pass@localhost/#general?v=3") # attach our content attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Test our call count assert mock_put.call_count == 2 assert mock_post.call_count == 3 assert ( mock_post.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/login" ) assert ( mock_post.call_args_list[1][0][0] == "http://localhost/_matrix/client/v3/join/%23general%3Alocalhost" ) assert ( mock_post.call_args_list[2][0][0] == "http://localhost/_matrix/media/v3/upload" ) assert ( mock_put.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/rooms/%21abc123%3Alocalhost/" "send/m.room.message/0" ) assert ( mock_put.call_args_list[1][0][0] == "http://localhost/_matrix/client/v3/rooms/%21abc123%3Alocalhost/" "send/m.room.message/1" ) # Attach a zip file type attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-archive.zip") ) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # update our attachment to be valid attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) mock_put.return_value = None mock_post.return_value = None # Throw an exception on the first call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): # Reset our value mock_put.reset_mock() mock_post.reset_mock() mock_post.side_effect = [side_effect] assert obj.send(body="test", attach=attach) is False # Throw an exception on the second call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): # Reset our value mock_put.reset_mock() mock_post.reset_mock() mock_put.side_effect = [side_effect, response] mock_post.side_effect = [response, side_effect, response] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # handle a bad response mock_put.side_effect = [bad_response, response] mock_post.side_effect = [response, bad_response, response] # We'll fail now because of an internal exception assert obj.send(body="test", attach=attach) is False # Force a object removal (thus a logout call) del obj @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_discovery_service(mock_post, mock_get, mock_put): """NotifyMatrix() Discovery Service.""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") # Prepare a good response bad_response = mock.Mock() bad_response.status_code = requests.codes.unauthorized bad_response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") # Prepare Mock return object mock_post.return_value = response mock_get.return_value = response mock_put.return_value = response # Instantiate our object obj = Apprise.instantiate( "matrixs://user:pass@example.com/#general?v=2&discovery=yes" ) assert obj.notify("body") is True response = mock.Mock() response.status_code = requests.codes.unavailable resp = loads(MATRIX_GOOD_RESPONSE) mock_get.return_value = response mock_post.return_value = response obj = Apprise.instantiate( "matrixs://user:pass@example.com/#general?v=2&discovery=yes" ) obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) # Invalid host / fallback is to resolve our own host with pytest.raises(MatrixDiscoveryException): _ = obj.base_url # Verify cache is not saved assert NotifyMatrix.discovery_base_key not in obj.store assert NotifyMatrix.discovery_identity_key not in obj.store response.status_code = requests.codes.ok obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) # bad data resp["m.homeserver"] = "!garbage!:303" response.content = dumps(resp).encode("utf-8") obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) with pytest.raises(MatrixDiscoveryException): _ = obj.base_url # Verify cache is not saved assert NotifyMatrix.discovery_base_key not in obj.store assert NotifyMatrix.discovery_identity_key not in obj.store # We fail our discovery and therefore can't send our notification assert obj.notify("hello world") is False # bad key resp["m.homeserver"] = {} response.content = dumps(resp).encode("utf-8") obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) with pytest.raises(MatrixDiscoveryException): _ = obj.base_url # Verify cache is not saved assert NotifyMatrix.discovery_base_key not in obj.store assert NotifyMatrix.discovery_identity_key not in obj.store resp["m.homeserver"] = {"base_url": "https://nuxref.com/base"} response.content = dumps(resp).encode("utf-8") obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) assert obj.base_url == "https://nuxref.com/base" assert obj.identity_url == "https://vector.im" # Verify cache saved assert NotifyMatrix.discovery_base_key in obj.store assert NotifyMatrix.discovery_identity_key in obj.store # Discovery passes so notifications work too assert obj.notify("hello world") is True # bad data resp["m.identity_server"] = "!garbage!:303" response.content = dumps(resp).encode("utf-8") obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) with pytest.raises(MatrixDiscoveryException): _ = obj.base_url # Verify cache is not saved assert NotifyMatrix.discovery_base_key not in obj.store assert NotifyMatrix.discovery_identity_key not in obj.store # no key resp["m.identity_server"] = {} response.content = dumps(resp).encode("utf-8") obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) with pytest.raises(MatrixDiscoveryException): _ = obj.base_url # Verify cache is not saved assert NotifyMatrix.discovery_base_key not in obj.store assert NotifyMatrix.discovery_identity_key not in obj.store # remove del resp["m.identity_server"] response.content = dumps(resp).encode("utf-8") obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) assert obj.base_url == "https://nuxref.com/base" assert obj.identity_url == "https://nuxref.com/base" # restore resp["m.identity_server"] = {"base_url": '"https://vector.im'} response.content = dumps(resp).encode("utf-8") # Not found is an acceptable response (no exceptions thrown) response.status_code = requests.codes.not_found obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) assert obj.base_url == "https://example.com" assert obj.identity_url == "https://example.com" # Verify cache saved assert NotifyMatrix.discovery_base_key in obj.store assert NotifyMatrix.discovery_identity_key in obj.store # Discovery passes so notifications work too response.status_code = requests.codes.ok assert obj.notify("hello world") is True response.status_code = requests.codes.ok mock_get.return_value = None mock_get.side_effect = (response, bad_response) obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) with pytest.raises(MatrixDiscoveryException): _ = obj.base_url # Verify cache is not saved assert NotifyMatrix.discovery_base_key not in obj.store assert NotifyMatrix.discovery_identity_key not in obj.store # Test case where ourIdentity URI fails to do it's check mock_get.side_effect = (response, response, bad_response) obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) with pytest.raises(MatrixDiscoveryException): _ = obj.base_url # Verify cache is not saved assert NotifyMatrix.discovery_base_key not in obj.store assert NotifyMatrix.discovery_identity_key not in obj.store # Test an empty block response response.status_code = requests.codes.ok response.content = "" mock_get.return_value = response mock_get.side_effect = None mock_post.return_value = response mock_post.side_effect = None obj.store.clear( NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key ) assert obj.base_url == "https://example.com" assert obj.identity_url == "https://example.com" # Verify cache saved assert NotifyMatrix.discovery_base_key in obj.store assert NotifyMatrix.discovery_identity_key in obj.store del obj @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_attachments_api_v2(mock_post, mock_get, mock_put): """NotifyMatrix() Attachment Checks (v2)""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") # Prepare a bad response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error # Prepare Mock return object mock_post.return_value = response mock_get.return_value = response mock_put.return_value = response # Instantiate our object obj = Apprise.instantiate("matrix://user:pass@localhost/#general?v=2") # attach our content attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Attach an unsupported file mock_post.return_value = response mock_get.return_value = response mock_post.side_effect = None mock_get.side_effect = None # Force a object removal (thus a logout call) del obj # Instantiate our object obj = Apprise.instantiate("matrixs://user:pass@localhost/#general?v=2") # Reset our object mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count -- login, join, upload use POST; sends use PUT assert mock_post.call_count == 3 assert ( mock_post.call_args_list[0][0][0] == "https://matrix.example.com/_matrix/client/r0/login" ) assert ( mock_post.call_args_list[1][0][0] == "https://matrix.example.com/_matrix/client/r0/" "join/%23general%3Alocalhost" ) assert ( mock_post.call_args_list[2][0][0] == "https://matrix.example.com/_matrix/media/r0/upload" ) assert mock_put.call_count == 2 assert ( mock_put.call_args_list[0][0][0] == "https://matrix.example.com/_matrix/client/r0" "/rooms/%21abc123%3Alocalhost/send/m.room.message/0" ) assert ( mock_put.call_args_list[1][0][0] == "https://matrix.example.com/_matrix/client/r0/" "rooms/%21abc123%3Alocalhost/send/m.room.message/1" ) # Attach an unsupported file type; these are skipped attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-archive.zip") ) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # update our attachment to be valid attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) mock_post.return_value = None mock_get.return_value = None # Throw an exception on the first call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): # Reset our value mock_post.reset_mock() mock_get.reset_mock() mock_post.side_effect = [side_effect, response] mock_get.side_effect = [side_effect, response] assert obj.send(body="test", attach=attach) is False # Throw an exception on the attachment send (now uses PUT) for side_effect in (requests.RequestException(), OSError(), bad_response): # Reset our value mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() mock_post.side_effect = [response] # upload ok mock_put.side_effect = [side_effect, side_effect] # sends fail # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # handle a bad response on the attachment send (now PUT) mock_post.side_effect = [response] # upload ok mock_put.side_effect = [bad_response, response] # attachment fails mock_get.side_effect = None # We'll fail now because of an internal exception assert obj.send(body="test", attach=attach) is False # Force a object removal (thus a logout call) del obj # Instantiate our object (no discovery required) obj = Apprise.instantiate( "matrixs://user:pass@localhost/#general?v=2&discovery=no&image=y" ) # Reset our object mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() # login + join succeed; image inline send (now PUT) fails mock_post.side_effect = [response, response] mock_get.side_effect = None mock_put.side_effect = [bad_response] # image attachment didn't succeed assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # All calls now succeed mock_post.return_value = response mock_get.return_value = response mock_put.return_value = response mock_post.side_effect = None mock_get.side_effect = None mock_put.side_effect = None # We'll fail now because of an internal exception assert obj.send(body="test", attach=attach) is True # Force __del__() call del obj @mock.patch("requests.put") @mock.patch("requests.post") def test_plugin_matrix_v2_compliance(mock_post, mock_put): """NotifyMatrix() Verify V2 uses PUT and TID for standard messages.""" # Setup compliant response response = mock.Mock() response.status_code = requests.codes.ok response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response mock_put.return_value = response # Instantiate as V2 obj = Apprise.instantiate("matrix://user:pass@localhost/#general?v=2") # Send a standard notification assert obj.notify(body="test message") is True # Confirm the fix: # 1. Path contains the transaction ID '0' # 2. Method is PUT assert mock_put.call_count == 1 assert "/_matrix/client/r0/rooms/" in mock_put.call_args_list[0][0][0] assert "/send/m.room.message/0" in mock_put.call_args_list[0][0][0] @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_v2_token_mode_no_txn_increment( mock_post, mock_get, mock_put ): """Token mode (access_token == password) skips transaction ID increment.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response mock_get.return_value = response mock_put.return_value = response # Token mode: user omitted, password treated as access token # (parse_url swaps user->password when no password supplied) obj = Apprise.instantiate( "matrixs://my_access_token@localhost/#general?v=2&image=y" ) assert obj is not None # Send with image inline enabled assert obj.notify(body="token mode image test") is True # Send with an attachment attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) assert obj.send(body="token mode attach test", attach=attach) is True def test_plugin_matrix_parse_native_url_no_match(): """parse_native_url returns None for non-t2bot URLs.""" assert ( NotifyMatrix.parse_native_url("https://not-a-t2bot-url.com/some/path") is None ) @mock.patch("requests.post") def test_plugin_matrix_hookshot_webhook(mock_post): """matrix-hookshot webhook mode uses hookshot URL/payload conventions.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"{}" mock_post.return_value = response obj = Apprise.instantiate( "matrixs://apprise:supersecret@hookshot.example" "?mode=hookshot&format=html&path=%2Fpublic-hooks" ) assert obj is not None assert obj.notify(title="Title", body="Body") is True assert mock_post.call_args.args[0] == ( "https://hookshot.example/public-hooks/supersecret" ) payload = loads(mock_post.call_args.kwargs["data"]) assert payload["username"] == "apprise" # An omitted input format preserves v1's pass-through behavior. assert payload["text"] == "Title\r\nBody" assert payload["html"] == "

    Title

    Body" @mock.patch("requests.post") def test_plugin_matrix_hookshot_webhook_empty_title(mock_post): """Hookshot webhook mode avoids extra separators for empty titles.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"{}" mock_post.return_value = response obj = Apprise.instantiate( "matrixs://apprise:supersecret@hookshot.example" "?mode=hookshot&format=markdown&path=%2Fpublic-hooks" ) assert obj is not None assert obj.notify(body="**Body**") is True payload = loads(mock_post.call_args.kwargs["data"]) assert payload["username"] == "apprise" assert payload["text"] == "**Body**" assert payload["html"] == "

    Body

    " def test_plugin_matrix_hookshot_path_normalization(): """Hookshot webhook paths normalize missing leading slashes.""" obj = Apprise.instantiate( "matrixs://apprise:supersecret@hookshot.example" "?mode=hookshot&path=public-hooks" ) assert obj is not None assert obj.webhook_path == "/public-hooks" @mock.patch("requests.post") def test_plugin_matrix_hookshot_root_path_text(mock_post): """Hookshot root paths and text mode stay literal.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"{}" mock_post.return_value = response obj = Apprise.instantiate( "matrixs://apprise:supersecret@hookshot.example" "?mode=hookshot&format=text&path=%2F" ) assert obj is not None assert obj.notify(body="Body") is True assert mock_post.call_args.args[0] == ( "https://hookshot.example/supersecret" ) assert loads(mock_post.call_args.kwargs["data"])["html"] == ( "<b>Body</b>" ) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_transaction_ids_api_v3_no_cache( mock_post, mock_get, mock_put ): """NotifyMatrix() Transaction ID Checks (v3)""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") # Prepare a bad response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error # Prepare Mock return object mock_post.return_value = response mock_get.return_value = response mock_put.return_value = response # For each element is 1 batch that is ran # the number defined is the number of notifications to send batch = [10, 1, 5] for notifications in batch: # Instantiate our object obj = Apprise.instantiate("matrix://user:pass@localhost/#general?v=3") # Ensure mode is memory assert obj.store.mode == PersistentStoreMode.MEMORY # Performs a login assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_get.call_count == 0 assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/login" ) assert ( mock_post.call_args_list[1][0][0] == "http://localhost/_matrix/client/v3/join/%23general%3Alocalhost" ) assert mock_put.call_count == 1 assert ( mock_put.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/rooms/" + "%21abc123%3Alocalhost/send/m.room.message/0" ) for no, _ in enumerate(range(notifications), start=1): # Clean our slate mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO ) is True ) assert mock_get.call_count == 0 assert mock_post.call_count == 0 assert mock_put.call_count == 1 assert ( mock_put.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/rooms/" + f"%21abc123%3Alocalhost/send/m.room.message/{no}" ) mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() # Force a object removal (thus a logout call) del obj assert mock_get.call_count == 0 assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/logout" ) mock_post.reset_mock() assert mock_put.call_count == 0 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_transaction_ids_api_v3_w_cache( mock_post, mock_get, mock_put, tmpdir ): """NotifyMatrix() Transaction ID Checks (v3)""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") # Prepare a bad response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error # Prepare Mock return object mock_post.return_value = response mock_get.return_value = response mock_put.return_value = response # For each element is 1 batch that is ran # the number defined is the number of notifications to send batch = [10, 1, 5] mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir), ) # Message Counter transaction_id = 1 for no, notifications in enumerate(batch): # Instantiate our object obj = Apprise.instantiate( "matrix://user:pass@localhost/#general?v=3", asset=asset ) # Ensure mode is flush assert obj.store.mode == PersistentStoreMode.FLUSH # Performs a login assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_get.call_count == 0 if no == 0: # first entry assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/login" ) assert ( mock_post.call_args_list[1][0][0] == "http://localhost/_matrix/client/v3/" "join/%23general%3Alocalhost" ) assert mock_put.call_count == 1 assert ( mock_put.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/rooms/" + "%21abc123%3Alocalhost/send/m.room.message/0" ) for no, _ in enumerate(range(notifications), start=transaction_id): # Clean our slate mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO ) is True ) # Increment transaction counter transaction_id += 1 assert mock_get.call_count == 0 assert mock_post.call_count == 0 assert mock_put.call_count == 1 assert ( mock_put.call_args_list[0][0][0] == "http://localhost/_matrix/client/v3/rooms/" + f"%21abc123%3Alocalhost/send/m.room.message/{no}" ) # Increment transaction counter transaction_id += 1 mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() # Force a object removal # Biggest takeaway is that a logout no longer happens del obj assert mock_get.call_count == 0 assert mock_post.call_count == 0 assert mock_put.call_count == 0 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_v3_url_with_port_assembly( mock_post, mock_get, mock_put, tmpdir ): """NotifyMatrix() URL with Port Assembly Checks (v3)""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = MATRIX_GOOD_RESPONSE.encode("utf-8") # Prepare Mock return object mock_post.return_value = response mock_get.return_value = response mock_put.return_value = response asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir), ) # Instantiate our object obj = Apprise.instantiate( "matrixs://user1:pass123@example.ca:8080/#general?v=3", asset=asset ) # Performs a login assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Secure Connections have a bit of additional overhead to verify # the authenticity of the server through discovery assert mock_get.call_count == 3 assert ( mock_get.call_args_list[0][0][0] == "https://example.ca:8080/.well-known/matrix/client" ) assert ( mock_get.call_args_list[1][0][0] == "https://matrix.example.com/_matrix/client/versions" ) assert ( mock_get.call_args_list[2][0][0] == "https://vector.im/_matrix/identity/v2" ) assert mock_post.call_count == 2 # matrix.example.com comes from our MATRIX_GOOD_RESPONSE # response which defines wht our .well-known returned to us assert ( mock_post.call_args_list[0][0][0] == "https://matrix.example.com/_matrix/client/v3/login" ) assert ( mock_post.call_args_list[1][0][0] == "https://matrix.example.com/_matrix/client/v3/" "join/%23general%3Alocalhost" ) assert mock_put.call_count == 1 assert ( mock_put.call_args_list[0][0][0] == "https://matrix.example.com/_matrix/client/v3/rooms/" + "%21abc123%3Alocalhost/send/m.room.message/0" ) mock_post.reset_mock() mock_get.reset_mock() mock_put.reset_mock() assert obj.base_url == "https://matrix.example.com" # Cache is used under the hood; no second discover is performed assert mock_put.call_count == 0 assert mock_get.call_count == 0 assert mock_post.call_count == 0 # Cleanup del obj @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_no_room_create_on_non_not_found_join( mock_post: mock.Mock, mock_get: mock.Mock, mock_put: mock.Mock, ) -> None: """No room creation when join fails with 400 (or other non-404). A join failure can occur for many reasons, such as auth failure or invite required. In these cases, attempting to create the room is incorrect and produces misleading follow-up errors like M_ROOM_IN_USE. """ def _resp(status_code: int, content: Union[str, bytes]) -> mock.Mock: r = mock.Mock() r.status_code = status_code if isinstance(content, str): r.content = content.encode("utf-8") else: r.content = content return r login = dumps( { "access_token": "t", "home_server": "hs", "user_id": "@u:hs", } ) # POST sequence: # 1. /login succeeds # 2. /join/#backup fails with 400 and an empty body # 3. /logout succeeds during object cleanup remembering token mock_post.side_effect = [ _resp(requests.codes.ok, login), _resp(requests.codes.bad_request, b""), _resp(requests.codes.ok, dumps({})), ] # No other requests should be needed (no createRoom, no directory lookup) mock_get.return_value = _resp(requests.codes.internal_server_error, b"") mock_put.return_value = _resp(requests.codes.ok, dumps({})) ap = Apprise() ap.add("matrixs://user:pass@matrix.vip/#backup?discovery=no") assert ap.notify(title="t", body="b") is False # Cleanup explicitly to ensure __del__ executes while mocks are active. import gc del ap gc.collect() # login + join + logout assert mock_post.call_count == 3 assert mock_get.call_count == 0 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_room_create_on_not_found_join( mock_post: mock.Mock, mock_get: mock.Mock, mock_put: mock.Mock, ) -> None: """Attempt room creation only when join reports 404 / M_NOT_FOUND.""" def _resp(status_code: int, content: Union[str, bytes]) -> mock.Mock: r = mock.Mock() r.status_code = status_code if isinstance(content, str): r.content = content.encode("utf-8") else: r.content = content return r login = dumps( { "access_token": "t", "home_server": "hs", "user_id": "@u:hs", } ) # POST sequence: # 1. /login succeeds # 2. /join/#backup returns 404 not found # 3. /createRoom returns alias in use, triggering alias resolution # 4. /logout succeeds during object cleanup mock_post.side_effect = [ _resp(requests.codes.ok, login), _resp( requests.codes.not_found, dumps({"errcode": "M_NOT_FOUND", "error": "Not found"}), ), _resp( requests.codes.bad_request, dumps( { "errcode": "M_ROOM_IN_USE", "error": "Room alias already taken", } ), ), _resp(requests.codes.ok, dumps({})), ] # Directory lookup returns the room id mock_get.return_value = _resp( requests.codes.ok, dumps({"room_id": "!abc123:matrix.vip"}), ) # Sending the message succeeds (v3 uses PUT) mock_put.return_value = _resp(requests.codes.ok, dumps({})) ap = Apprise() ap.add("matrixs://user:pass@matrix.vip/#backup?discovery=no") assert ap.notify(title="t", body="b") is True import gc del ap gc.collect() # login + join + createRoom + logout assert mock_post.call_count == 4 assert mock_get.call_count == 1 assert mock_put.call_count == 1 # --------------------------------------------------------------------------- # E2EE unit tests (require the cryptography package) # --------------------------------------------------------------------------- try: import cryptography # noqa: F401 CRYPTOGRAPHY_AVAILABLE = True except ImportError: CRYPTOGRAPHY_AVAILABLE = False @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_room_create_e2ee_initial_state( mock_post, mock_get, mock_put ): """_room_create with e2ee=True/secure=True embeds m.room.encryption in initial_state and pre-seeds the room encryption cache.""" response_obj = { "access_token": "tok", "user_id": "@u:localhost", "home_server": "localhost", "room_id": "!abc123", } r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps(response_obj).encode() mock_post.return_value = r mock_get.return_value = r mock_put.return_value = r obj = NotifyMatrix( host="localhost", user="u", password="p", e2ee=True, secure=True, discovery=False, ) obj.access_token = "tok" obj.home_server = "localhost" captured_payloads = [] _orig_fetch = obj._fetch def _capture_fetch(path, payload=None, **kw): if path == "/createRoom": captured_payloads.append(payload) return _orig_fetch(path, payload=payload, **kw) obj._fetch = _capture_fetch room_id = obj._room_create("e2ee_room") assert room_id == response_obj["room_id"] assert len(captured_payloads) == 1 init_state = captured_payloads[0].get("initial_state", []) assert any(s.get("type") == "m.room.encryption" for s in init_state) assert obj.store.get("e2ee_room_enc_{}".format(room_id)) is True def _rand_b64_32(): """32 bytes of random data encoded as unpadded base64.""" import base64 return base64.b64encode(os.urandom(32)).decode() def _make_signed_otk(account, user_id, device_id, key_b64=None): """Return a signed_curve25519 OTK dict signed by *account*. Produces {"key": ..., "signatures": {user_id: {"ed25519:DEV": ...}}} suitable for inclusion in a /keys/claim mock response. """ from apprise.plugins.matrix.e2ee import _canonical_json if key_b64 is None: key_b64 = _rand_b64_32() payload = {"key": key_b64} sig = account.sign(_canonical_json(payload)) payload["signatures"] = {user_id: {"ed25519:{}".format(device_id): sig}} return payload @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_helpers(): """e2ee helper functions.""" from apprise.plugins.matrix.e2ee import ( _b64dec, _b64enc, _canonical_json, _hmac_sha256, _pb_bytes, _pb_varint_field, _varint, ) # _b64enc / _b64dec round-trip raw = b"\x00\x01\x02\x03" assert _b64dec(_b64enc(raw)) == raw # _b64dec tolerates missing padding and URL-safe chars # "AA==" -> decode "AA" (2 chars, pad=2) assert _b64dec("AA") == b"\x00" # URL-safe chars are normalised assert _b64dec("AA--") == _b64dec("AA++") # _hmac_sha256 returns 32 bytes mac = _hmac_sha256(b"\x00" * 32, b"data") assert isinstance(mac, bytes) and len(mac) == 32 # _varint: n==0 -> single zero byte assert _varint(0) == b"\x00" # n>0 is encoded as multi-byte varint assert _varint(128) == b"\x80\x01" # _pb_bytes / _pb_varint_field produce bytes pb = _pb_bytes(1, b"hello") assert isinstance(pb, bytes) and len(pb) > 5 pv = _pb_varint_field(2, 42) assert isinstance(pv, bytes) # _canonical_json produces sorted-key JSON bytes data = {"z": 1, "a": 2} out = _canonical_json(data) assert out.startswith(b'{"a"') def test_plugin_matrix_e2ee_no_cryptography(): """MATRIX_E2EE_SUPPORT is False when cryptography is unavailable.""" import importlib import sys # Block 'cryptography' so the ImportError branch is taken on reload saved = { k: v for k, v in sys.modules.items() if k.startswith("cryptography") } for key in list(saved): sys.modules[key] = None # type: ignore[assignment] try: import apprise.plugins.matrix.e2ee as e2ee_mod importlib.reload(e2ee_mod) assert e2ee_mod.MATRIX_E2EE_SUPPORT is False finally: # Restore everything so subsequent tests are not broken for key in list(saved): if saved[key] is None: del sys.modules[key] else: sys.modules[key] = saved[key] # Force a clean reload so MATRIX_E2EE_SUPPORT is True again importlib.reload(e2ee_mod) @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_olm_account(): """MatrixOlmAccount creation, signing, serialisation.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount # Fresh account acct = MatrixOlmAccount() assert isinstance(acct.identity_key, str) assert isinstance(acct.signing_key, str) # sign() accepts str and bytes sig = acct.sign("hello") assert isinstance(sig, str) sig2 = acct.sign(b"hello") assert isinstance(sig2, str) # to_dict / from_dict round-trip d = acct.to_dict() assert "ik" in d and "sk" in d restored = MatrixOlmAccount.from_dict(d) assert restored.identity_key == acct.identity_key assert restored.signing_key == acct.signing_key # Explicit key construction from stored keys acct2 = MatrixOlmAccount( ik_priv_b64=d["ik"], sk_priv_b64=d["sk"], ) assert acct2.identity_key == acct.identity_key # device_keys_payload produces a signed dict payload = acct.device_keys_payload("@u:h", "DEVID") assert "signatures" in payload assert "algorithms" in payload # fallback_keys_payload fb1 = acct.fallback_keys_payload("@u:h", "DEVID") assert len(fb1) == 1 fb_key_id1 = next(iter(fb1.keys())) assert fb_key_id1.startswith("signed_curve25519:") fb2 = acct.fallback_keys_payload("@u:h", "DEVID") # second call returns the same key id (key not regenerated) assert next(iter(fb2.keys())) == fb_key_id1 @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_olm_session(): """MatrixOlmSession encrypts and produces correct wire format.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount, _b64dec acct = MatrixOlmAccount() # Create a second account to act as recipient recipient = MatrixOlmAccount() session = acct.create_outbound_session( recipient.identity_key, recipient.identity_key, # reuse IK as a stand-in OTK ) assert session.their_identity_key == recipient.identity_key # str plaintext result = session.encrypt("test plaintext") assert result["type"] == 0 assert isinstance(result["body"], str) # Decode and check the outer pre-key wire format. # Outer: version(0x03) | field1(OTK) | field2(EK) | field3(IK) | # field4(inner+mac) | outer_mac(8) # Inner (in field 4): version(0x03) | field1(ratchet_key) | # field2(chain_index) | field4(ciphertext,tag=0x22) | inner_mac(8) raw = _b64dec(result["body"]) assert raw[0:1] == b"\x03", "outer version must be 0x03" # field 4, wire-type 2 -> tag = (4<<3)|2 = 0x22 -- ciphertext in inner msg assert b"\x22" in raw, "ciphertext field tag 0x22 must be present" # Outer field 1 (OTK, tag 0x0A), field 2 (EK, tag 0x12) must appear assert b"\x0a" in raw, "outer OTK field (tag 0x0A) must be present" assert b"\x12" in raw, "outer EK field (tag 0x12) must be present" # bytes plaintext also accepted result2 = session.encrypt(b"bytes input") assert result2["type"] == 0 @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_olm_roundtrip(): """Full Olm round-trip: Alice encrypts, Bob manually decrypts. This test implements the Bob (inbound) side of the Olm X3DH and message-decryption protocol using the same cryptography primitives, verifying that our Alice-side implementation produces wire bytes that a conformant receiver can decrypt. Critical invariant verified: the outer base-key (pre-key field 2) equals the inner ratchet-key (normal-message field 1). They MUST be the same ephemeral key E_A or the receiver cannot reconstruct the session and decryption fails. """ import hmac as _hmac_stdlib from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, hmac as _hmac_mod from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, ) from cryptography.hazmat.primitives.ciphers import ( Cipher, algorithms, modes, ) from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.padding import PKCS7 from cryptography.hazmat.primitives.serialization import ( Encoding, PublicFormat, ) from apprise.plugins.matrix.e2ee import ( MatrixOlmAccount, _b64dec, _b64enc, ) # -- helpers (Bob-side) ------------------------------------------- def _hmac(key, data): h = _hmac_mod.HMAC(key, hashes.SHA256(), backend=default_backend()) h.update(data) return h.finalize() def _hkdf(ikm, length, salt, info): return HKDF( algorithm=hashes.SHA256(), length=length, salt=salt, info=info, backend=default_backend(), ).derive(ikm) def _aes_cbc_decrypt(key, iv, ciphertext): cipher = Cipher( algorithms.AES(key), modes.CBC(iv), backend=default_backend() ) dec = cipher.decryptor() padded = dec.update(ciphertext) + dec.finalize() unpadder = PKCS7(128).unpadder() return unpadder.update(padded) + unpadder.finalize() def _read_varint(data, pos): val, shift = 0, 0 while True: b = data[pos] pos += 1 val |= (b & 0x7F) << shift shift += 7 if not (b & 0x80): return val, pos def _parse_pb_fields(data): """Parse length-delimited protobuf body; return {field_num: bytes}.""" fields = {} pos = 0 while pos < len(data): tag, pos = _read_varint(data, pos) field_num = tag >> 3 wire_type = tag & 0x07 if wire_type == 2: length, pos = _read_varint(data, pos) fields[field_num] = data[pos : pos + length] pos += length elif wire_type == 0: val, pos = _read_varint(data, pos) fields[field_num] = val else: raise ValueError("Unexpected wire type {}".format(wire_type)) return fields # -- test setup --------------------------------------------------- # Alice's account (sender) alice = MatrixOlmAccount() # Bob's account: identity key + a separate one-time key bob = MatrixOlmAccount() bob_otk_priv = X25519PrivateKey.generate() bob_otk_pub = bob_otk_priv.public_key().public_bytes( Encoding.Raw, PublicFormat.Raw ) bob_otk_pub_b64 = _b64enc(bob_otk_pub) # Bob's identity-key private (for DH computation) bob_ik_priv = bob._ik # Alice creates an outbound session to Bob using his real OTK session = alice.create_outbound_session( bob.identity_key, # Bob's Curve25519 identity key bob_otk_pub_b64, # Bob's one-time key ) plaintext = "hello from Alice" result = session.encrypt(plaintext) assert result["type"] == 0 # -- Bob-side decryption ------------------------------------------ raw = _b64dec(result["body"]) # Outer pre-key message format (Olm spec Section 5.2): # version(1) | protobuf(field1=OTK, field2=base_key, # field3=IK, field4=inner_message) # # There is NO outer MAC on the pre-key message. Only the inner # normal-message (field 4) has its own 8-byte MAC appended. # Reference: olm.md Section 5.2; libolm session.cpp # encode_one_time_key_message_length() (no MAC bytes allocated); # vodozemac src/olm/messages/pre_key.rs decode() (no MAC consumed). assert raw[0:1] == b"\x03", "outer version must be 0x03" # Parse the full outer protobuf body (all bytes after the version byte) outer_fields = _parse_pb_fields(raw[1:]) otk_pub_in_msg = outer_fields[1] # field 1: Bob's OTK public bytes base_key = outer_fields[2] # field 2: Alice's ephemeral E_A alice_ik_pub = outer_fields[3] # field 3: Alice's identity key # The OTK bytes in the message must equal Bob's OTK public key assert otk_pub_in_msg == bob_otk_pub, ( "OTK in pre-key message must match the OTK that was claimed" ) # Bob computes X3DH (mirroring Alice's DH operations) # DH1 = X25519(OTK_B_priv, IK_A_pub) # DH2 = X25519(IK_B_priv, E_A_pub) # DH3 = X25519(OTK_B_priv, E_A_pub) from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PublicKey, ) alice_ik = X25519PublicKey.from_public_bytes(alice_ik_pub) e_a = X25519PublicKey.from_public_bytes(base_key) dh1 = bob_otk_priv.exchange(alice_ik) dh2 = bob_ik_priv.exchange(e_a) dh3 = bob_otk_priv.exchange(e_a) ikm = dh1 + dh2 + dh3 keys64 = _hkdf(ikm, 64, salt=None, info=b"OLM_ROOT") root_key = keys64[:32] chain_key = keys64[32:] del root_key # not needed for first-message decryption # Derive message keys from the initial chain key msg_key = _hmac(chain_key, b"\x01") keys80 = _hkdf(msg_key, 80, salt=b"\x00" * 32, info=b"OLM_KEYS") aes_key = keys80[:32] mac_key = keys80[32:64] iv = keys80[64:80] # Field 4 = inner_normal_message || 8-byte MAC. # The inner normal message itself starts with version byte 0x03 followed # by the protobuf body; the MAC covers the version + protobuf body. inner_with_mac = outer_fields[4] inner_body = inner_with_mac[:-8] # version(1) + pb_fields inner_mac_received = inner_with_mac[-8:] # Verify the inner MAC (HMAC-SHA-256 of inner_body, truncated to 8 bytes) inner_mac_expected = _hmac(mac_key, inner_body)[:8] assert _hmac_stdlib.compare_digest( inner_mac_expected, inner_mac_received ), "inner MAC verification failed" # Parse inner fields # inner_body = version(0x03) | pb_fields assert inner_body[0:1] == b"\x03", "inner version must be 0x03" inner_fields = _parse_pb_fields(inner_body[1:]) # CRITICAL invariant: # ratchet_key (inner field 1) == base_key (outer field 2) ratchet_key = inner_fields[1] assert ratchet_key == base_key, ( "Olm invariant violated: inner ratchet_key must equal outer base_key " "(E_A must be used in BOTH positions per Olm spec Section 5.1). " "Got ratchet_key={} base_key={}".format( _b64enc(ratchet_key), _b64enc(base_key) ) ) # Decrypt the AES-256-CBC ciphertext (inner field 4) ciphertext = inner_fields[4] decrypted = _aes_cbc_decrypt(aes_key, iv, ciphertext) assert decrypted.decode("utf-8") == plaintext, ( "round-trip decryption failed: got {!r}".format(decrypted) ) @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_verify_keys(): """verify_device_keys and verify_signed_otk accept/reject signatures.""" from apprise.plugins.matrix.e2ee import ( MatrixOlmAccount, _canonical_json, verify_device_keys, verify_signed_otk, ) acct = MatrixOlmAccount() uid = "@alice:h" dev = "DEVID" # --- verify_device_keys --- # Valid self-signed device keys -> True dk = acct.device_keys_payload(uid, dev) assert verify_device_keys(dk, uid, dev) is True # Payload user_id doesn't match -> False (identity binding check) mismatched_uid = {**dk, "user_id": "@other:h"} assert verify_device_keys(mismatched_uid, uid, dev) is False # Payload device_id doesn't match -> False (identity binding check) mismatched_dev = {**dk, "device_id": "WRONGDEV"} assert verify_device_keys(mismatched_dev, uid, dev) is False # Missing user_id in payload -> False no_user_id = {k: v for k, v in dk.items() if k != "user_id"} assert verify_device_keys(no_user_id, uid, dev) is False # Missing signatures field -> False no_sig = {k: v for k, v in dk.items() if k != "signatures"} assert verify_device_keys(no_sig, uid, dev) is False # Wrong user_id in signatures -> False wrong_uid = { **dk, "signatures": {"@wrong:h": dk["signatures"][uid]}, } assert verify_device_keys(wrong_uid, uid, dev) is False # Tampered key value -> False tampered = {**dk, "keys": {**dk["keys"], f"ed25519:{dev}": "bad"}} assert verify_device_keys(tampered, uid, dev) is False # Missing ed25519 key entry -> False no_keys = {**dk, "keys": {f"curve25519:{dev}": acct.identity_key}} assert verify_device_keys(no_keys, uid, dev) is False # --- verify_signed_otk --- key_b64 = _rand_b64_32() otk_obj = {"key": key_b64} sig = acct.sign(_canonical_json(otk_obj)) signed_otk = { **otk_obj, "signatures": {uid: {f"ed25519:{dev}": sig}}, } # Valid OTK -> True assert verify_signed_otk(signed_otk, uid, dev, acct.signing_key) is True # Missing signatures -> False assert verify_signed_otk(otk_obj, uid, dev, acct.signing_key) is False # Wrong signing key -> False other = MatrixOlmAccount() assert verify_signed_otk(signed_otk, uid, dev, other.signing_key) is False # Tampered key value -> False tampered_otk = {**signed_otk, "key": _rand_b64_32()} assert verify_signed_otk(tampered_otk, uid, dev, acct.signing_key) is False @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_megolm_session(): """MatrixMegOlmSession encrypt, session_key, rotation, serialisation.""" from apprise.plugins.matrix.e2ee import ( MATRIX_MEGOLM_STORE_VERSION, MEGOLM_ROTATION_MSGS, MatrixMegOlmSession, ) s = MatrixMegOlmSession() assert isinstance(s.session_id, str) # encrypt returns base64 ciphertext ct = s.encrypt({"type": "m.room.message", "content": {}}) assert isinstance(ct, str) # Verify MegOLM wire format (libolm message.cpp): # version(0x03) | field1_varint(msg_index,tag=0x08) | # field2_bytes(ciphertext,tag=0x12) | mac(8) | sig(64) from apprise.plugins.matrix.e2ee import _b64dec raw = _b64dec(ct) assert raw[0:1] == b"\x03", "MegOLM version must be 0x03" assert raw[1:2] == b"\x08", "MegOLM field 1 tag must be 0x08 (varint)" # After tag 0x08: the varint counter value (0 -> single byte 0x00), # then the ciphertext tag 0x12. assert b"\x12" in raw, "MegOLM ciphertext field tag 0x12 must be present" # session_key returns base64 -- verify wire format per libolm # outbound_group_session.c: version(1) + counter(4) + ratchet(128) # + ed25519_pub(32) + ed25519_sig(64) = 229 bytes raw = 308 base64 chars sk = s.session_key() assert isinstance(sk, str) sk_raw = _b64dec(sk) assert len(sk_raw) == 229, "session_key must be 229 bytes (sig included)" assert sk_raw[0:1] == b"\x02", "session_key version must be 0x02" # to_dict / from_dict round-trip d = s.to_dict() assert d["version"] == MATRIX_MEGOLM_STORE_VERSION s2 = MatrixMegOlmSession.from_dict(d) assert s2.session_id == s.session_id assert s2._counter == s._counter # should_rotate: explicit count threshold assert not s.should_rotate(msg_count=MEGOLM_ROTATION_MSGS - 1) assert s.should_rotate(msg_count=MEGOLM_ROTATION_MSGS) # should_rotate: time threshold (old created_at) old = MatrixMegOlmSession(created_at=0.0) assert old.should_rotate() # _advance branches -- cascade fires when (n+1) % threshold == 0, # i.e. when the counter is one less than a threshold multiple. # -- normal step (else branch) s3 = MatrixMegOlmSession() s3._advance() # counter 0 -> 1 (1 % 256 != 0, else: R[3] only) # -- %256 branch: next counter will be 256 s4 = MatrixMegOlmSession() s4._counter = 255 s4._advance() # -- %65536 branch: next counter will be 65536 s5 = MatrixMegOlmSession() s5._counter = 65535 s5._advance() # -- %2^24 branch: next counter will be 2^24 s6 = MatrixMegOlmSession() s6._counter = (1 << 24) - 1 s6._advance() # --------------------------------------------------------------------------- # E2EE integration tests -- URL parsing and send flow # --------------------------------------------------------------------------- @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_url_roundtrip(): """e2ee default is True; disabled state survives url() / parse_url().""" # Default (no e2ee param) is now True obj = NotifyMatrix( host="matrix.example.com", user="user", password="pass", targets=["#room"], ) assert obj.e2ee is True # Enabled is the default -- not written to URL assert "e2ee" not in obj.url() # Disabled survives round-trip obj2 = NotifyMatrix( host="matrix.example.com", user="user", password="pass", targets=["#room"], e2ee=False, ) assert obj2.e2ee is False u2 = obj2.url() assert "e2ee=no" in u2 result = NotifyMatrix.parse_url(u2) assert result is not None assert result.get("e2ee") is False or str(result.get("e2ee")).lower() in ( "false", "no", "0", ) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_insecure_connection(mock_post, mock_get, mock_put): """e2ee=yes over insecure (matrix://) falls back to unencrypted.""" resp = mock.Mock() resp.status_code = requests.codes.ok resp.content = dumps( { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV", "room_id": "!r:h", } ).encode() mock_post.return_value = resp mock_get.return_value = resp mock_put.return_value = resp # matrix:// (not matrixs://) -- E2EE silently skipped, plain delivery obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#room"], e2ee=True, # secure=False is the default for matrix:// URLs ) assert not obj.secure assert obj.send(body="test") is True @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_no_support(mock_post, mock_get, mock_put): """e2ee=yes with MATRIX_E2EE_SUPPORT=False sends unencrypted.""" resp = mock.Mock() resp.status_code = requests.codes.ok resp.content = dumps( { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV", "room_id": "!r:h", } ).encode() mock_post.return_value = resp mock_get.return_value = resp mock_put.return_value = resp with mock.patch( "apprise.plugins.matrix.base.MATRIX_E2EE_SUPPORT", False, ): obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#room"], e2ee=True, ) # Should succeed (falls back to unencrypted) assert obj.send(body="test") is True @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_send(mock_post, mock_get, mock_put): """Full E2EE send path via _send_server_notification.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount # Recipient device (acts as a room member) recipient = MatrixOlmAccount() login_resp = { "access_token": "tok123", "user_id": "@sender:localhost", "home_server": "localhost", "device_id": "SENDERDEV", } members_resp = { "joined": {"@other:localhost": {}}, } # Properly signed device keys (required by verify_device_keys) signed_dev = recipient.device_keys_payload("@other:localhost", "OTHERDEV") query_resp = { "device_keys": {"@other:localhost": {"OTHERDEV": signed_dev}} } # Properly signed OTK (required by verify_signed_otk) signed_otk = _make_signed_otk(recipient, "@other:localhost", "OTHERDEV") claim_resp = { "one_time_keys": { "@other:localhost": { "OTHERDEV": {"signed_curve25519:KEYID": signed_otk} } } } def _mk_resp(d): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps(d).encode() return r # POST sequence: # login, keys/upload (with OTK count), join, keys/query, keys/claim, # keys/upload (OTK replenishment), logout mock_post.side_effect = [ _mk_resp(login_resp), _mk_resp({"one_time_key_counts": {"signed_curve25519": 1}}), _mk_resp({"room_id": "!room:localhost"}), # join _mk_resp(query_resp), # keys/query _mk_resp(claim_resp), # keys/claim # Replenishment: remaining = 1 - 1 (built_count) = 0 < threshold _mk_resp( { "one_time_key_counts": { "signed_curve25519": NotifyMatrix.default_e2ee_otk_count, } } ), _mk_resp({}), # logout ] mock_get.return_value = _mk_resp(members_resp) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="localhost", user="sender", password="pass", targets=["#room"], e2ee=True, secure=True, discovery=False, ) assert obj.send(body="hello", title="hi") is True # PUT was called: sendToDevice + rooms/send/encrypted assert mock_put.call_count >= 2 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_send_cached_session(mock_post, mock_get, mock_put): """E2EE send skips key-share when session key already shared.""" login_resp = { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV", } def _mk_resp(d): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps(d).encode() return r # POST: login, keys/upload, join, logout mock_post.side_effect = [ _mk_resp(login_resp), _mk_resp({}), # keys/upload _mk_resp({"room_id": "!r:h"}), # join _mk_resp({}), # logout ] mock_put.return_value = _mk_resp({}) from apprise.plugins.matrix.e2ee import MatrixMegOlmSession obj = NotifyMatrix( host="h", user="u", password="pass", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) # Pre-seed room encryption state, MegOLM session, and key-shared flag # so that no additional GETs or key-exchange PUTs are issued. _sess = MatrixMegOlmSession() obj.store.set("e2ee_room_enc_!r:h", True) obj.store.set("e2ee_megolm_!r:h", _sess.to_dict()) obj.store.set("e2ee_key_shared_!r:h", _sess.session_id) assert obj.send(body="silent") is True # Only one PUT for the encrypted message itself assert mock_put.call_count == 1 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_send_legacy_shared_flag_rekeys( mock_post, mock_get, mock_put ): """Legacy boolean key-share cache forces a fresh room-key share.""" from apprise.plugins.matrix.e2ee import ( MatrixMegOlmSession, MatrixOlmAccount, ) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj.access_token = "tok" obj.home_server = "h" obj.user_id = "@u:h" obj.device_id = "DEV" obj._e2ee_account = MatrixOlmAccount() session = MatrixMegOlmSession() obj.store.set("e2ee_megolm_!r:h", session.to_dict()) obj.store.set("e2ee_key_shared_!r:h", True) with ( mock.patch.object( obj, "_e2ee_share_room_key", return_value=True ) as share, mock.patch.object(obj, "_fetch", return_value=(True, {}, 200)), ): assert ( obj._e2ee_send_to_room("!r:h", "body", "", NotifyType.INFO) is True ) assert share.call_count == 1 assert obj.store.get("e2ee_key_shared_!r:h") == session.session_id @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_setup_restored(): """E2EE account restores from store via _e2ee_setup and __init__.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount acct = MatrixOlmAccount() obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) # Seed account + mark keys already uploaded obj.store.set("e2ee_account", acct.to_dict()) obj.store.set("e2ee_keys_uploaded", True) obj.store.set( "e2ee_device_binding", "{}|{}|{}|{}".format( obj.user_id or "", obj.device_id or "", acct.identity_key, acct.signing_key, ), ) # Simulate state where account is not yet loaded in memory obj._e2ee_account = None # _e2ee_setup should restore the account from the store assert obj._e2ee_setup() is True assert obj._e2ee_account is not None assert obj._e2ee_account.identity_key == acct.identity_key # Bad account data in store -> _e2ee_setup creates a fresh account; # the try/except in _e2ee_setup is exercised when from_dict raises. obj._e2ee_account = None obj.store.set("e2ee_account", {"bad": "data"}) obj.store.clear("e2ee_keys_uploaded") with mock.patch.object(obj, "_e2ee_upload_keys", return_value=True): result = obj._e2ee_setup() assert result is True assert obj._e2ee_account is not None # Patch the store so __init__ sees a valid pre-existing account, # exercising the success path of the contextlib.suppress block. valid_data = obj._e2ee_account.to_dict() with mock.patch( "apprise.persistent_store.PersistentStore.get", return_value=valid_data, ): obj2 = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) assert obj2._e2ee_account is not None @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_setup_reuploads_on_binding_change(): """Changed device/account identity forces a fresh key upload.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount acct = MatrixOlmAccount() obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj.user_id = "@u:h" obj.device_id = "DEV_NEW" obj._e2ee_account = acct obj.store.set("e2ee_account", acct.to_dict()) obj.store.set("e2ee_keys_uploaded", True) obj.store.set("e2ee_device_binding", "@u:h|DEV_OLD|oldik|oldsk") with mock.patch.object( obj, "_e2ee_upload_keys", return_value=True ) as upload: assert obj._e2ee_setup() is True assert upload.call_count == 1 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_upload_keys_no_ids(mock_post, mock_get, mock_put): """_e2ee_upload_keys returns False when user_id/device_id missing.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj._e2ee_account = MatrixOlmAccount() # No user_id / device_id set assert obj._e2ee_upload_keys() is False @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_upload_keys_http_fail( mock_post, mock_get, mock_put ): """_e2ee_upload_keys returns False on HTTP error.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount resp = mock.Mock() resp.status_code = 500 resp.content = dumps({}).encode() mock_post.return_value = resp obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj._e2ee_account = MatrixOlmAccount() obj.access_token = "tok" obj.user_id = "@u:h" obj.device_id = "DEV" obj.home_server = "h" assert obj._e2ee_upload_keys() is False @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_upload_keys_success_marks_published( mock_post, mock_get, mock_put ): """Successful upload clears the current unpublished OTK batch.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount resp = mock.Mock() resp.status_code = requests.codes.ok resp.content = dumps( { "one_time_key_counts": { "signed_curve25519": NotifyMatrix.default_e2ee_otk_count, } } ).encode() mock_post.return_value = resp obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj._e2ee_account = MatrixOlmAccount() obj.access_token = "tok" obj.user_id = "@u:h" obj.device_id = "DEV" obj.home_server = "h" # Pre-generate one-time keys so we can verify they are marked published. obj._e2ee_account.one_time_keys_payload( obj.user_id, obj.device_id, count=3 ) assert obj._e2ee_account._otks assert obj._e2ee_upload_keys() is True assert obj._e2ee_account._otks == {} # Server OTK count from the response is persisted for threshold checks. assert obj.store.get("e2ee_otk_server_count") == ( NotifyMatrix.default_e2ee_otk_count ) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_whoami(mock_post, mock_get, mock_put): """_whoami() resolves user_id and device_id from GET /account/whoami.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r obj = NotifyMatrix( host="h", password="rawtoken", targets=["#r"], e2ee=True, ) obj.access_token = "rawtoken" obj.home_server = "h" # Success: server returns user_id and device_id; home_server is # extracted from user_id so DM targets resolve correctly. mock_get.return_value = _mk_resp( {"user_id": "@tok:h", "device_id": "TOKDEV"} ) assert obj._whoami() is True assert obj.user_id == "@tok:h" assert obj.device_id == "TOKDEV" assert obj.home_server == "h" assert obj.store.get("user_id") == "@tok:h" assert obj.store.get("device_id") == "TOKDEV" assert obj.store.get("home_server") == "h" # user_id without homeserver component (@alice, no colon) -> home_server # is not extracted (branch where len(parts) != 2 is not taken). obj.user_id = None obj.home_server = None mock_get.return_value = _mk_resp( {"user_id": "@noserver", "device_id": "DEV2"} ) assert obj._whoami() is True assert obj.user_id == "@noserver" assert obj.home_server is None # no colon -> not extracted # home_server already known -> not overwritten obj.home_server = "existing.h" mock_get.return_value = _mk_resp( {"user_id": "@tok:other.h", "device_id": "DEV3"} ) assert obj._whoami() is True assert obj.home_server == "existing.h" # Failure: server returns non-200 obj.user_id = None obj.device_id = None mock_get.return_value = _mk_resp({}, code=401) assert obj._whoami() is False @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_send_cached_token_recovers_home_server( mock_post, mock_get, mock_put ): """Cached auth without home_server should be repaired via _whoami().""" obj = NotifyMatrix( host="matrix.org", user="apprise", password="pass", targets=["#general"], ) obj.access_token = "cached-token" obj.user_id = None obj.device_id = None obj.home_server = None whoami_called = {"count": 0} def _fake_whoami(): whoami_called["count"] += 1 obj.user_id = "@apprise:matrix.org" obj.device_id = "DEV42" obj.home_server = "matrix.org" return True with ( mock.patch.object(obj, "_room_join", return_value="!room:matrix.org"), mock.patch.object(obj, "_whoami", side_effect=_fake_whoami), mock.patch.object( obj, "_fetch", return_value=(True, {"event_id": "$1"}, 200) ), ): assert obj.send("body", title="test") is True assert whoami_called["count"] == 1 assert obj.home_server == "matrix.org" assert obj.user_id == "@apprise:matrix.org" @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_get_megolm_rotation(mock_post, mock_get, mock_put): """_e2ee_get_megolm rotates session after threshold.""" from apprise.plugins.matrix.e2ee import ( MEGOLM_ROTATION_MSGS, MatrixMegOlmSession, ) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) # Seed a session that is already past the rotation threshold stale = MatrixMegOlmSession() stale._counter = MEGOLM_ROTATION_MSGS obj.store.set("e2ee_megolm_!r:h", stale.to_dict()) obj.store.set("e2ee_key_shared_!r:h", True) new_session = obj._e2ee_get_megolm("!r:h") assert new_session.session_id != stale.session_id # The shared flag should be cleared on rotation assert obj.store.get("e2ee_key_shared_!r:h") is None # Legacy cached sessions without the current version marker are # invalidated and replaced with a fresh session. legacy = stale.to_dict() del legacy["version"] obj.store.set("e2ee_megolm_!r:h", legacy) obj.store.set("e2ee_key_shared_!r:h", stale.session_id) replaced = obj._e2ee_get_megolm("!r:h") assert replaced.session_id != stale.session_id assert obj.store.get("e2ee_key_shared_!r:h") is None @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_room_encrypted(mock_post, mock_get, mock_put): """_e2ee_room_encrypted: cached, GET success, GET failure paths.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj.access_token = "tok" obj.home_server = "h" # Cache hit -- no GET fired obj.store.set("e2ee_room_enc_!r:h", True) assert obj._e2ee_room_encrypted("!r:h") is True assert mock_get.call_count == 0 # Cache miss, GET returns 200 with content -> True obj.store.clear("e2ee_room_enc_!r:h") mock_get.return_value = _mk_resp({"algorithm": "m.megolm.v1.aes-sha2"}) assert obj._e2ee_room_encrypted("!r:h") is True assert mock_get.call_count == 1 # Cache is now populated assert obj.store.get("e2ee_room_enc_!r:h") is True # Cache miss, GET returns 404 -> False obj.store.clear("e2ee_room_enc_!r:h") mock_get.return_value = _mk_resp({}, code=404) assert obj._e2ee_room_encrypted("!r:h") is False assert obj.store.get("e2ee_room_enc_!r:h") is False @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_room_members(mock_post, mock_get, mock_put): """_e2ee_room_members handles HTTP failures and empty rooms.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj.access_token = "tok" obj.home_server = "h" # joined_members HTTP failure -> None mock_get.return_value = _mk_resp({}, code=500) assert obj._e2ee_room_members("!r:h") is None # No joined members -> empty dict mock_get.return_value = _mk_resp({"joined": {}}) assert obj._e2ee_room_members("!r:h") == {} # keys/query failure -> None mock_get.return_value = _mk_resp({"joined": {"@u:h": {}}}) mock_post.return_value = _mk_resp({}, code=500) assert obj._e2ee_room_members("!r:h") is None # Unsigned device key -> device is rejected (empty result for that uid) from apprise.plugins.matrix.e2ee import MatrixOlmAccount unsigned_resp = { "device_keys": { "@u:h": { "DEV": { "keys": { "curve25519:DEV": "IK", "ed25519:DEV": "SK", } # no "signatures" field } } } } mock_get.return_value = _mk_resp({"joined": {"@u:h": {}}}) mock_post.return_value = _mk_resp(unsigned_resp) result = obj._e2ee_room_members("!r:h") assert result["@u:h"] == {} # Properly signed device key -> device included acct = MatrixOlmAccount() signed_dev = acct.device_keys_payload("@u:h", "DEV") query_resp = {"device_keys": {"@u:h": {"DEV": signed_dev}}} mock_get.return_value = _mk_resp({"joined": {"@u:h": {}}}) mock_post.return_value = _mk_resp(query_resp) result = obj._e2ee_room_members("!r:h") assert "@u:h" in result assert result["@u:h"]["DEV"]["curve25519"] == acct.identity_key @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_share_room_key_branches( mock_post, mock_get, mock_put ): """_e2ee_share_room_key edge cases.""" from apprise.plugins.matrix.e2ee import ( MatrixMegOlmSession, MatrixOlmAccount, ) def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj.access_token = "tok" obj.home_server = "h" obj.user_id = "@sender:h" obj.device_id = "SDEV" obj._e2ee_account = MatrixOlmAccount() session = MatrixMegOlmSession() # members failure -> False with mock.patch.object(obj, "_e2ee_room_members", return_value=None): assert obj._e2ee_share_room_key("!r:h", session) is False # empty members -> True (nothing to send) with mock.patch.object(obj, "_e2ee_room_members", return_value={}): assert obj._e2ee_share_room_key("!r:h", session) is True # keys/claim failure -> False members = {"@other:h": {"DEV": {"curve25519": "IK", "ed25519": "SK"}}} mock_post.return_value = _mk_resp({}, code=500) with mock.patch.object(obj, "_e2ee_room_members", return_value=members): assert obj._e2ee_share_room_key("!r:h", session) is False # keys/claim succeeds but server reports failures{} for remote servers failures_resp = { "one_time_keys": {}, "failures": {"remote.example": {"errcode": "M_UNREACHABLE"}}, } mock_post.return_value = _mk_resp(failures_resp) with mock.patch.object(obj, "_e2ee_room_members", return_value=members): assert obj._e2ee_share_room_key("!r:h", session) is True # own device is skipped (sender uid + device match) own_members = { "@sender:h": {"SDEV": {"curve25519": "IK", "ed25519": "SK"}} } otk_resp = { "one_time_keys": { "@sender:h": {"SDEV": {"signed_curve25519:KID": _rand_b64_32()}} } } mock_post.return_value = _mk_resp(otk_resp) mock_put.return_value = _mk_resp({}) with mock.patch.object( obj, "_e2ee_room_members", return_value=own_members ): # All devices skipped -> no PUT; still returns True assert obj._e2ee_share_room_key("!r:h", session) is True # missing curve25519 key -> device skipped no_ik = {"@other:h": {"DEV": {"curve25519": "", "ed25519": "SK"}}} with mock.patch.object(obj, "_e2ee_room_members", return_value=no_ik): mock_post.return_value = _mk_resp( {"one_time_keys": {"@other:h": {"DEV": {}}}} ) assert obj._e2ee_share_room_key("!r:h", session) is True # dict-valued OTK with valid signature succeeds end-to-end recipient = MatrixOlmAccount() their_ik = recipient.identity_key their_otk_b64 = _rand_b64_32() signed_otk_dict = _make_signed_otk( recipient, "@other:h", "DEV", their_otk_b64 ) dict_members = { "@other:h": { "DEV": { "curve25519": their_ik, "ed25519": recipient.signing_key, } } } dict_otk = { "one_time_keys": { "@other:h": {"DEV": {"signed_curve25519:KID": signed_otk_dict}} } } mock_post.return_value = _mk_resp(dict_otk) mock_put.return_value = _mk_resp({}) with mock.patch.object( obj, "_e2ee_room_members", return_value=dict_members ): assert obj._e2ee_share_room_key("!r:h", session) is True payload = loads(mock_put.call_args.kwargs["data"]) dev_payload = payload["messages"]["@other:h"]["DEV"] assert dev_payload["sender_key"] == obj._e2ee_account.identity_key # recipient_key and org.matrix.msgid are non-standard fields # that were removed; verify they are absent assert "recipient_key" not in dev_payload assert "org.matrix.msgid" not in dev_payload # dict-valued OTK with no signatures -> OTK rejected, device skipped unsigned_otk_dict = {"key": their_otk_b64} # no "signatures" field unsigned_otk_resp = { "one_time_keys": { "@other:h": {"DEV": {"signed_curve25519:KID": unsigned_otk_dict}} } } mock_post.return_value = _mk_resp(unsigned_otk_resp) with mock.patch.object( obj, "_e2ee_room_members", return_value=dict_members ): # No valid OTK -> no messages built -> True (nothing to send) assert obj._e2ee_share_room_key("!r:h", session) is True # Plain-string OTK for signed_curve25519 -> rejected (not a KeyObject) str_members = { "@other:h": { "DEV": { "curve25519": their_ik, "ed25519": recipient.signing_key, } } } str_otk = { "one_time_keys": { "@other:h": {"DEV": {"signed_curve25519:KID": their_otk_b64}} } } mock_post.return_value = _mk_resp(str_otk) with mock.patch.object( obj, "_e2ee_room_members", return_value=str_members ): # No valid KeyObject OTK -> no messages built -> True (nothing sent) assert obj._e2ee_share_room_key("!r:h", session) is True # OTK key not prefixed with "signed_curve25519:" -> skipped, no OTK found bad_key_otk = { "one_time_keys": {"@other:h": {"DEV": {"ed25519:KID": their_otk_b64}}} } mock_post.return_value = _mk_resp(bad_key_otk) with mock.patch.object( obj, "_e2ee_room_members", return_value=str_members ): assert obj._e2ee_share_room_key("!r:h", session) is True # OTK entry is not a dict (unexpected server response format) non_dict_otk = {"one_time_keys": {"@other:h": {"DEV": "invalid_format"}}} mock_post.return_value = _mk_resp(non_dict_otk) with mock.patch.object( obj, "_e2ee_room_members", return_value=str_members ): assert obj._e2ee_share_room_key("!r:h", session) is True # OTK value is an integer (unknown format) -> skipped non_str_otk = { "one_time_keys": {"@other:h": {"DEV": {"signed_curve25519:KID": 42}}} } mock_post.return_value = _mk_resp(non_str_otk) with mock.patch.object( obj, "_e2ee_room_members", return_value=str_members ): assert obj._e2ee_share_room_key("!r:h", session) is True # create_outbound_session raises -> device is skipped gracefully # Use a properly signed OTK so verification passes and the Olm call # is actually reached. create_fail_signed = _make_signed_otk( recipient, "@other:h", "DEV", their_otk_b64 ) create_fail_otk = { "one_time_keys": { "@other:h": {"DEV": {"signed_curve25519:KID": create_fail_signed}} } } mock_post.return_value = _mk_resp(create_fail_otk) with ( mock.patch.object( obj, "_e2ee_room_members", return_value=dict_members ), mock.patch.object( obj._e2ee_account, "create_outbound_session", side_effect=Exception("olm error"), ), ): assert obj._e2ee_share_room_key("!r:h", session) is True # sendToDevice PUT fails -> returns False (key-shared flag not set) # Uses a signed OTK so the full Olm path is exercised before the PUT full_signed_otk = _make_signed_otk( recipient, "@other:h", "DEV", their_otk_b64 ) full_otk_resp = { "one_time_keys": { "@other:h": {"DEV": {"signed_curve25519:KID": full_signed_otk}} } } mock_post.return_value = _mk_resp(full_otk_resp) mock_put.return_value = _mk_resp({}, code=500) with mock.patch.object( obj, "_e2ee_room_members", return_value=dict_members ): assert obj._e2ee_share_room_key("!r:h", session) is False # token-as-password mode: transaction_id is a UUID, not an int. # Previously, the unconditional `+= 1` raised TypeError (UUID + int). # With the guard in place the UUID is used as-is and the send succeeds. import uuid as _uuid_mod uuid_obj = NotifyMatrix( host="h", password="rawtoken", targets=["#r"], e2ee=True ) uuid_obj.access_token = "rawtoken" uuid_obj.password = "rawtoken" uuid_obj.transaction_id = _uuid_mod.uuid4() # UUID, not int uuid_obj.home_server = "h" uuid_obj.user_id = "@tok:h" uuid_obj.device_id = "TOKDEV" uuid_obj._e2ee_account = MatrixOlmAccount() uuid_session = MatrixMegOlmSession() uuid_otk = _make_signed_otk(recipient, "@other:h", "DEV", their_otk_b64) uuid_claim = { "one_time_keys": { "@other:h": {"DEV": {"signed_curve25519:KID": uuid_otk}} } } mock_post.return_value = _mk_resp(uuid_claim) mock_put.return_value = _mk_resp({}) with mock.patch.object( uuid_obj, "_e2ee_room_members", return_value=dict_members ): # Must not raise TypeError; transaction_id stays as UUID assert uuid_obj._e2ee_share_room_key("!r:h", uuid_session) is True assert isinstance(uuid_obj.transaction_id, _uuid_mod.UUID) @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_replenish_otks(mock_post): """_e2ee_replenish_otks: threshold logic, diagnostics, and error paths.""" from apprise.plugins.matrix.e2ee import MatrixOlmAccount otk_count = NotifyMatrix.default_e2ee_otk_count otk_threshold = NotifyMatrix.default_e2ee_otk_replenish_threshold def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r def _make_obj(): obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True ) obj.user_id = "@u:h" obj.device_id = "DEV" obj._e2ee_account = MatrixOlmAccount() return obj # --- missing credentials: no network call, returns False --- obj = _make_obj() obj.user_id = None assert obj._e2ee_replenish_otks() is False assert mock_post.call_count == 0 obj = _make_obj() obj.device_id = None assert obj._e2ee_replenish_otks() is False assert mock_post.call_count == 0 # --- pool sufficient: skip upload --- # Store a count well above threshold; claimed_count=1 -> remaining is # above default_e2ee_otk_replenish_threshold so no upload should happen. obj = _make_obj() obj.store.set("e2ee_otk_server_count", otk_threshold + otk_count) result = obj._e2ee_replenish_otks(claimed_count=1, skipped_no_otk=0) assert result is True assert mock_post.call_count == 0 # --- pool low: remaining < threshold triggers upload --- obj = _make_obj() obj.store.set("e2ee_otk_server_count", otk_threshold - 1) mock_post.return_value = _mk_resp( {"one_time_key_counts": {"signed_curve25519": otk_count}} ) assert obj._e2ee_replenish_otks(claimed_count=0, skipped_no_otk=0) is True assert mock_post.call_count == 1 assert obj.store.get("e2ee_otk_server_count") == otk_count assert obj.store.get("e2ee_account") is not None mock_post.reset_mock() # --- unknown server count (no store entry): replenish as precaution --- obj = _make_obj() # e2ee_otk_server_count not set -> unknown mock_post.return_value = _mk_resp( {"one_time_key_counts": {"signed_curve25519": otk_count}} ) assert obj._e2ee_replenish_otks(claimed_count=0, skipped_no_otk=0) is True assert mock_post.call_count == 1 mock_post.reset_mock() # --- skipped_no_otk > 0: always replenishes regardless of count --- obj = _make_obj() obj.store.set("e2ee_otk_server_count", otk_threshold + otk_count) mock_post.return_value = _mk_resp( {"one_time_key_counts": {"signed_curve25519": otk_count}} ) assert obj._e2ee_replenish_otks(claimed_count=0, skipped_no_otk=2) is True assert mock_post.call_count == 1 mock_post.reset_mock() # --- upload failure: returns False, logs warning --- obj = _make_obj() obj.store.set("e2ee_otk_server_count", 0) mock_post.return_value = _mk_resp({}, code=500) assert obj._e2ee_replenish_otks(claimed_count=0, skipped_no_otk=0) is False mock_post.reset_mock() # --- response missing one_time_key_counts: new count defaults to 0 --- obj = _make_obj() obj.store.set("e2ee_otk_server_count", 0) mock_post.return_value = _mk_resp({}) # no one_time_key_counts key assert obj._e2ee_replenish_otks(claimed_count=0, skipped_no_otk=0) is True assert obj.store.get("e2ee_otk_server_count") == 0 mock_post.reset_mock() @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_send_to_room_formats( mock_post, mock_get, mock_put ): """_e2ee_send_to_room: HTML/Markdown format paths and key-share fail.""" from apprise.common import NotifyFormat from apprise.plugins.matrix.e2ee import ( MatrixMegOlmSession, MatrixOlmAccount, ) def _mk_resp(d): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps(d).encode() return r mock_put.return_value = _mk_resp({}) def _make_obj(fmt): obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj.notify_format = fmt obj.access_token = "tok" obj.home_server = "h" obj.user_id = "@u:h" obj.device_id = "DEV" obj._e2ee_account = MatrixOlmAccount() return obj # HTML format obj = _make_obj(NotifyFormat.HTML) session = MatrixMegOlmSession() obj.store.set("e2ee_megolm_!r:h", session.to_dict()) obj.store.set("e2ee_key_shared_!r:h", session.session_id) assert ( obj._e2ee_send_to_room( "!r:h", "body", "

    title

    ", NotifyType.INFO ) is True ) # Markdown format obj = _make_obj(NotifyFormat.MARKDOWN) session = MatrixMegOlmSession() obj.store.set("e2ee_megolm_!r:h", session.to_dict()) obj.store.set("e2ee_key_shared_!r:h", session.session_id) assert ( obj._e2ee_send_to_room("!r:h", "**bold**", "title", NotifyType.INFO) is True ) # key-share flags set after successful share (no pre-seeded flag) obj = _make_obj(NotifyFormat.TEXT) session = MatrixMegOlmSession() obj.store.set("e2ee_megolm_!r:h", session.to_dict()) with mock.patch.object(obj, "_e2ee_share_room_key", return_value=True): assert ( obj._e2ee_send_to_room("!r:h", "body", "", NotifyType.INFO) is True ) # token-as-password mode: access_token == password skips txn increment obj = _make_obj(NotifyFormat.TEXT) obj.access_token = "same" obj.password = "same" session2 = MatrixMegOlmSession() obj.store.set("e2ee_megolm_!r:h", session2.to_dict()) obj.store.set("e2ee_key_shared_!r:h", session2.session_id) assert obj._e2ee_send_to_room("!r:h", "body", "", NotifyType.INFO) is True # key-share failure aborts send obj = _make_obj(NotifyFormat.TEXT) obj.store.clear("e2ee_key_shared_!r:h") with mock.patch.object(obj, "_e2ee_share_room_key", return_value=False): assert ( obj._e2ee_send_to_room("!r:h", "body", "", NotifyType.INFO) is False ) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_setup_failure(mock_post, mock_get, mock_put): """When E2EE setup fails, send falls back to unencrypted.""" resp = mock.Mock() resp.status_code = requests.codes.ok resp.content = dumps( { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV", "room_id": "!r:h", } ).encode() # upload keys call fails fail_resp = mock.Mock() fail_resp.status_code = 500 fail_resp.content = dumps({}).encode() mock_put.return_value = resp mock_post.side_effect = [ resp, # login fail_resp, # keys/upload -> failure resp, # join resp, # logout ] mock_get.return_value = resp obj = NotifyMatrix( host="h", user="u", password="pass", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) # Falls back to unencrypted -> send succeeds assert obj.send(body="fallback") is True @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_attachment_encrypted( mock_post, mock_get, mock_put ): """E2EE mode encrypts and delivers attachments.""" import tempfile login_resp = { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV", } upload_resp = {"content_uri": "mxc://h/enc123"} def _mk_resp(d): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps(d).encode() return r mock_post.side_effect = [ _mk_resp(login_resp), _mk_resp({}), # keys/upload _mk_resp({"room_id": "!r:h"}), # join _mk_resp(upload_resp), # encrypted attachment upload _mk_resp({}), # logout ] mock_get.return_value = _mk_resp({"joined": {}}) mock_put.return_value = _mk_resp({}) with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: f.write(b"test attachment") attach_path = f.name try: attach = AppriseAttachment() attach.add(attach_path) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) from apprise.plugins.matrix.e2ee import MatrixMegOlmSession _s = MatrixMegOlmSession() obj.store.set("e2ee_room_enc_!r:h", True) obj.store.set("e2ee_megolm_!r:h", _s.to_dict()) obj.store.set("e2ee_key_shared_!r:h", _s.session_id) assert obj.send(body="body", attach=attach) is True # Two PUTs: encrypted message + encrypted attachment event assert mock_put.call_count == 2 finally: os.unlink(attach_path) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_send_attachment_errors( mock_post, mock_get, mock_put ): """_e2ee_send_attachment error branches: OSError, network, HTTP fail.""" import tempfile from apprise.plugins.matrix.e2ee import ( MatrixMegOlmSession, MatrixOlmAccount, ) def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r obj = NotifyMatrix( host="h", user="u", password="pass", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) obj.access_token = "tok" obj.home_server = "h" obj.user_id = "@u:h" obj.device_id = "DEV" obj._e2ee_account = MatrixOlmAccount() session = MatrixMegOlmSession() # OSError reading file -> False with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: f.write(b"data") tmp_path = f.name try: class _BadAttach: path = tmp_path name = "f.txt" mimetype = "text/plain" def __len__(self): return 0 with mock.patch("builtins.open", side_effect=OSError("no read")): assert ( obj._e2ee_send_attachment(_BadAttach(), "!r:h", session) is False ) # Network error during upload -> False with mock.patch( "requests.post", side_effect=requests.RequestException("boom"), ): assert ( obj._e2ee_send_attachment(_BadAttach(), "!r:h", session) is False ) # HTTP error from media server -> False with mock.patch("requests.post", return_value=_mk_resp({}, code=500)): assert ( obj._e2ee_send_attachment(_BadAttach(), "!r:h", session) is False ) # Upload succeeds but response body is not valid JSON -> treated as # missing content_uri -> False bad_json_resp = mock.Mock() bad_json_resp.status_code = requests.codes.ok bad_json_resp.content = b"not-json" with mock.patch("requests.post", return_value=bad_json_resp): assert ( obj._e2ee_send_attachment(_BadAttach(), "!r:h", session) is False ) # base_url raises -> False with mock.patch( "apprise.plugins.matrix.base.NotifyMatrix.base_url", new_callable=mock.PropertyMock, side_effect=Exception("discovery fail"), ): assert ( obj._e2ee_send_attachment(_BadAttach(), "!r:h", session) is False ) # No access_token -> Authorization header omitted, upload still works obj_noauth = NotifyMatrix( host="h", user="u", password="pass", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) obj_noauth.access_token = None obj_noauth.home_server = "h" obj_noauth.user_id = "@u:h" obj_noauth.device_id = "DEV" obj_noauth._e2ee_account = MatrixOlmAccount() sess_noauth = MatrixMegOlmSession() with mock.patch( "requests.post", return_value=_mk_resp({"content_uri": "mxc://h/y"}), ): mock_put.return_value = _mk_resp({}) assert ( obj_noauth._e2ee_send_attachment( _BadAttach(), "!r:h", sess_noauth ) is True ) # access_token == password -> transaction ID not incremented obj2 = NotifyMatrix( host="h", user="u", password="tok", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) obj2.access_token = "tok" obj2.password = "tok" obj2.home_server = "h" obj2.user_id = "@u:h" obj2.device_id = "DEV" obj2._e2ee_account = MatrixOlmAccount() sess2 = MatrixMegOlmSession() txn_before = obj2.transaction_id upload_r = _mk_resp({"content_uri": "mxc://h/x"}) put_r = _mk_resp({}) with mock.patch("requests.post", return_value=upload_r): mock_put.return_value = put_r result = obj2._e2ee_send_attachment(_BadAttach(), "!r:h", sess2) assert result is True assert obj2.transaction_id == txn_before # Image mimetype -> is_image branch (no 'filename' field added) class _ImageAttach: path = tmp_path name = "photo.png" mimetype = "image/png" def __len__(self): return 4 with mock.patch("requests.post", return_value=upload_r): mock_put.return_value = put_r result = obj2._e2ee_send_attachment(_ImageAttach(), "!r:h", sess2) assert result is True finally: os.unlink(tmp_path) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_send_attachment_invalid( mock_post, mock_get, mock_put ): """Invalid attachment and _e2ee_send_attachment failure mark has_error.""" import tempfile login_resp = { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV", } def _mk_resp(d): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps(d).encode() return r mock_post.side_effect = [ _mk_resp(login_resp), _mk_resp({}), # keys/upload _mk_resp({"room_id": "!r:h"}), # join _mk_resp({}), # logout ] mock_get.return_value = _mk_resp({}) mock_put.return_value = _mk_resp({}) with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: f.write(b"data") attach_path = f.name try: from apprise.plugins.matrix.e2ee import MatrixMegOlmSession obj = NotifyMatrix( host="h", user="u", password="pass", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) _s = MatrixMegOlmSession() obj.store.set("e2ee_room_enc_!r:h", True) obj.store.set("e2ee_megolm_!r:h", _s.to_dict()) obj.store.set("e2ee_key_shared_!r:h", _s.session_id) attach = AppriseAttachment() attach.add(attach_path) # _e2ee_send_attachment fails -> send returns False with mock.patch.object( obj, "_e2ee_send_attachment", return_value=False ): assert obj.send(body="body", attach=attach) is False # Invalid attachment (falsy) in list -> has_error set and loop breaks mock_post.reset_mock() mock_post.side_effect = [ _mk_resp(login_resp), _mk_resp({}), # keys/upload _mk_resp({"room_id": "!r:h"}), # join _mk_resp({}), # logout ] obj2 = NotifyMatrix( host="h", user="u", password="pass", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) _s2 = MatrixMegOlmSession() obj2.store.set("e2ee_room_enc_!r:h", True) obj2.store.set("e2ee_megolm_!r:h", _s2.to_dict()) obj2.store.set("e2ee_key_shared_!r:h", _s2.session_id) # Add a non-existent file to get a falsy attachment bad_attach = AppriseAttachment() bad_attach.add("/nonexistent/path/file.txt") with mock.patch.object(obj2, "_e2ee_send_to_room", return_value=True): assert obj2.send(body="body", attach=bad_attach) is False finally: os.unlink(attach_path) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_send_room_failure(mock_post, mock_get, mock_put): """E2EE path returns False when _e2ee_send_to_room fails.""" login_resp = { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV", } def _mk_resp(d): r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps(d).encode() return r mock_post.side_effect = [ _mk_resp(login_resp), _mk_resp({}), # keys/upload _mk_resp({"room_id": "!r:h"}), # join _mk_resp({}), # logout ] mock_get.return_value = _mk_resp({}) mock_put.return_value = _mk_resp({}) from apprise.plugins.matrix.e2ee import MatrixMegOlmSession obj = NotifyMatrix( host="h", user="u", password="pass", targets=["!r:h"], e2ee=True, secure=True, discovery=False, ) obj.store.set("e2ee_room_enc_!r:h", True) _s = MatrixMegOlmSession() obj.store.set("e2ee_megolm_!r:h", _s.to_dict()) obj.store.set("e2ee_key_shared_!r:h", _s.session_id) with mock.patch.object(obj, "_e2ee_send_to_room", return_value=False): assert obj.send(body="fail") is False @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_register_with_device_id(mock_post, mock_get, mock_put): """Registration response that includes device_id persists it.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "REG_DEV", } ).encode() mock_post.return_value = r obj = NotifyMatrix(host="h", discovery=False) assert obj._register() is True assert obj.device_id == "REG_DEV" assert obj.store.get("device_id") == "REG_DEV" @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_login_reuses_device_id(mock_post, mock_get, mock_put): """Login payload reuses the stored device ID when present.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV_REUSED", } ).encode() mock_post.return_value = r obj = NotifyMatrix(host="h", user="u", password="pass", discovery=False) obj.device_id = "DEV_REUSED" assert obj._login() is True payload = mock_post.call_args.kwargs["data"] assert '"device_id": "DEV_REUSED"' in payload @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_register_reuses_device_id( mock_post, mock_get, mock_put ): """Register payload reuses the stored device ID when present.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( { "access_token": "tok", "user_id": "@u:h", "home_server": "h", "device_id": "DEV_REUSED", } ).encode() mock_post.return_value = r obj = NotifyMatrix(host="h", user="u", password="pass", discovery=False) obj.device_id = "DEV_REUSED" assert obj._register() is True payload = mock_post.call_args.kwargs["data"] assert '"device_id": "DEV_REUSED"' in payload @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_account_bad_store_data(): """Corrupt e2ee_account in store is silently ignored.""" obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, ) obj.store.set("e2ee_account", {"ik": "NOTVALID", "sk": "NOTVALID"}) # Re-instantiate with the same store to trigger the restore path obj2 = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, store=obj.store, ) # Bad data is suppressed; account is None until setup is called assert obj2._e2ee_account is None def test_plugin_matrix_del_suppresses_logout_exceptions(): """__del__ should never raise if logout fails during GC cleanup.""" obj = NotifyMatrix(host="h", user="u", password="pass", targets=["#r"]) obj.access_token = "tok" with mock.patch.object(obj, "_logout", side_effect=RuntimeError("boom")): obj.__del__() # --------------------------------------------------------------------------- # DM / direct-message tests # --------------------------------------------------------------------------- def test_plugin_matrix_dm_target_separation(): """@user targets are separated from room targets at init time.""" obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#general", "@alice", "@bob:other.com", "!room:h"], discovery=False, ) assert "#general" in obj.rooms assert "!room:h" in obj.rooms assert "@alice" in obj.users assert "@bob:other.com" in obj.users assert len(obj.rooms) == 2 assert len(obj.users) == 2 # __len__ accounts for both rooms and users assert len(obj) == 4 def test_plugin_matrix_dm_url_roundtrip(): """@user targets survive a url() -> parse_url() -> NotifyMatrix cycle.""" obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#room", "@alice"], discovery=False, secure=True, ) url = obj.url() # @ is URL-encoded to %40 inside the path segment assert "%40alice" in url result = NotifyMatrix.parse_url(url) assert result is not None obj2 = NotifyMatrix(**result) assert "@alice" in obj2.users assert any("room" in r for r in obj2.rooms) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_room_find_cached(mock_post, mock_get, mock_put): """_dm_room_find_or_create returns cached room ID without HTTP calls.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r mock_post.return_value = _mk_resp({}) mock_get.return_value = _mk_resp({}) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, ) obj.access_token = "tok" obj.user_id = "@u:h" obj.home_server = "h" # Seed the cache obj.store.set("dm_room_@alice:h", "!dm123:h") room_id = obj._dm_room_find_or_create("@alice") assert room_id == "!dm123:h" # No HTTP calls should have been made assert mock_get.call_count == 0 assert mock_post.call_count == 0 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_room_find_via_mdirect(mock_post, mock_get, mock_put): """_dm_room_find_or_create finds existing room from m.direct data.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r mdirect = {"@alice:h": ["!existing_dm:h"]} mock_get.return_value = _mk_resp(mdirect) mock_post.return_value = _mk_resp({}) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, ) obj.access_token = "tok" obj.user_id = "@u:h" obj.home_server = "h" room_id = obj._dm_room_find_or_create("@alice") assert room_id == "!existing_dm:h" # Result is now cached assert obj.store.get("dm_room_@alice:h") == "!existing_dm:h" # No createRoom POST should have been made assert mock_post.call_count == 0 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_room_create_new(mock_post, mock_get, mock_put): """_dm_room_find_or_create creates a new room when none exists.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r # GET m.direct returns empty (no existing DM for alice) mock_get.return_value = _mk_resp({}) # First POST = createRoom, second = (not called) mock_post.return_value = _mk_resp({"room_id": "!new_dm:h"}) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, ) obj.access_token = "tok" obj.user_id = "@u:h" obj.home_server = "h" room_id = obj._dm_room_find_or_create("@alice") assert room_id == "!new_dm:h" # Should be cached assert obj.store.get("dm_room_@alice:h") == "!new_dm:h" # m.direct PUT should have been called to update the mapping assert mock_put.call_count >= 1 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_room_create_failure(mock_post, mock_get, mock_put): """_dm_room_find_or_create returns None when createRoom fails.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r mock_get.return_value = _mk_resp({}) mock_post.return_value = _mk_resp( {"errcode": "M_FORBIDDEN"}, code=requests.codes.forbidden ) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, ) obj.access_token = "tok" obj.user_id = "@u:h" obj.home_server = "h" assert obj._dm_room_find_or_create("@alice") is None @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_room_create_no_room_id( mock_post, mock_get, mock_put ): """_dm_room_find_or_create returns None when createRoom omits room_id.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r mock_get.return_value = _mk_resp({}) # createRoom returns 200 but no room_id key mock_post.return_value = _mk_resp({}) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, ) obj.access_token = "tok" obj.user_id = "@u:h" obj.home_server = "h" assert obj._dm_room_find_or_create("@alice") is None def test_plugin_matrix_dm_invalid_user(): """_dm_room_find_or_create returns None for a malformed user target.""" obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], discovery=False, ) obj.access_token = "tok" obj.user_id = "@u:h" obj.home_server = "h" # No @ prefix -- not a valid user target assert obj._dm_room_find_or_create("notauser") is None @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_send_notification(mock_post, mock_get, mock_put): """Sending to @user target resolves DM room and delivers message.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r mock_get.return_value = _mk_resp({}) mock_put.return_value = _mk_resp({}) mock_post.side_effect = [ _mk_resp( {"access_token": "tok", "user_id": "@u:h", "home_server": "h"} ), # login _mk_resp({"room_id": "!new_dm:h"}), # createRoom _mk_resp({}), # logout ] obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, e2ee=False, ) # Pre-seed DM room so _room_join succeeds def _join_side(*a, **kw): return "!new_dm:h" with ( mock.patch.object(obj, "_room_join", side_effect=_join_side), mock.patch.object( obj, "_dm_room_find_or_create", return_value="!new_dm:h" ), ): assert obj.send(body="hello DM") is True @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_only_users_no_rooms_skips_joined( mock_post, mock_get, mock_put ): """When only @user targets are present, _joined_rooms is not queried.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r mock_get.return_value = _mk_resp({}) mock_put.return_value = _mk_resp({}) mock_post.side_effect = [ _mk_resp( {"access_token": "tok", "user_id": "@u:h", "home_server": "h"} ), # login _mk_resp({}), # logout ] obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, e2ee=False, ) with ( mock.patch.object(obj, "_joined_rooms", return_value=[]) as mock_jr, mock.patch.object(obj, "_dm_room_find_or_create", return_value=None), ): obj.send(body="hi") # _joined_rooms must NOT have been called assert mock_jr.call_count == 0 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_room_create_mdirect_get_fails( mock_post, mock_get, mock_put ): """_dm_room_find_or_create still creates room if m.direct GET fails.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r # GET fails (404) mock_get.return_value = _mk_resp({}, code=404) mock_post.return_value = _mk_resp({"room_id": "!fallback:h"}) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, ) obj.access_token = "tok" obj.user_id = "@u:h" obj.home_server = "h" room_id = obj._dm_room_find_or_create("@alice") assert room_id == "!fallback:h" @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_room_create_no_user_id( mock_post, mock_get, mock_put ): """_dm_room_find_or_create creates room when user_id is None (skips the m.direct GET and PUT).""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r mock_get.return_value = _mk_resp({}) mock_post.return_value = _mk_resp({"room_id": "!nodm:h"}) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], discovery=False, ) obj.access_token = "tok" # user_id intentionally left as None so the m.direct branch is skipped obj.home_server = "h" room_id = obj._dm_room_find_or_create("@alice") assert room_id == "!nodm:h" # No GET and no PUT for m.direct since user_id is None assert mock_get.call_count == 0 @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_dm_room_create_e2ee(mock_post, mock_get, mock_put): """_dm_room_find_or_create with e2ee=True/secure=True embeds initial_state and pre-seeds the room encryption cache.""" from apprise.plugins.matrix.base import MATRIX_E2EE_SUPPORT if not MATRIX_E2EE_SUPPORT: pytest.skip("cryptography package not installed") def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(d).encode() return r mock_get.return_value = _mk_resp({}) mock_post.return_value = _mk_resp({"room_id": "!dm_e2ee:h"}) mock_put.return_value = _mk_resp({}) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["@alice"], e2ee=True, secure=True, discovery=False, ) obj.access_token = "tok" obj.user_id = "@u:h" obj.home_server = "h" captured_payloads = [] _orig_fetch = obj._fetch def _capture_fetch(path, payload=None, **kw): if path == "/createRoom": captured_payloads.append(payload or {}) return _orig_fetch(path, payload=payload, **kw) obj._fetch = _capture_fetch room_id = obj._dm_room_find_or_create("@alice") assert room_id == "!dm_e2ee:h" # Verify initial_state was sent with m.room.encryption assert len(captured_payloads) == 1 init_state = captured_payloads[0].get("initial_state", []) assert any(s.get("type") == "m.room.encryption" for s in init_state) # Room encryption cache must be pre-seeded assert obj.store.get("e2ee_room_enc_!dm_e2ee:h") is True # --------------------------------------------------------------------------- # home_server recovery-from-user_id coverage tests # --------------------------------------------------------------------------- def test_plugin_matrix_init_recovers_home_server_from_user_id(tmpdir): """__init__ extracts home_server from a cached user_id when home_server was not stored (older cache entries that predate the home_server field).""" # A disk-backed asset ensures two separate NotifyMatrix instances that # share the same url_id() also share the same persistent store file. asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir), ) # First instance: simulate a login that wrote user_id/access_token but # did NOT write home_server (older Apprise behaviour). obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], asset=asset ) obj.store.set("user_id", "@u:recovered.com") obj.store.set("access_token", "tok") # Flush to disk before the second instance reads it. obj.store.flush() # Second instance with the same credentials (same url_id) reads the store. # The recovery branch (lines 506-509) should derive home_server from # the stored user_id. obj2 = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], asset=asset ) assert obj2.home_server == "recovered.com" def test_plugin_matrix_init_no_home_server_recovery_without_colon(tmpdir): """__init__ leaves home_server as None when the cached user_id has no colon -- the recovery split yields a single part and the assignment is skipped.""" asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir), ) obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], asset=asset ) # user_id with no colon -- split(":", 1) produces only one element. obj.store.set("user_id", "nocolon") obj.store.set("access_token", "tok") obj.store.flush() obj2 = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], asset=asset ) # No colon in user_id means home_server cannot be extracted. assert obj2.home_server is None @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_whoami_recovers_home_server_from_user_id( mock_post, mock_get, mock_put ): """_whoami() extracts home_server from user_id when the server does not return a home_server field and self.home_server is unset.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( {"user_id": "@u:whoami.example.com", "device_id": "DEVX"} ).encode() mock_get.return_value = r obj = NotifyMatrix(host="h", user="u", password="pass", discovery=False) obj.access_token = "tok" obj.home_server = None assert obj._whoami() is True assert obj.home_server == "whoami.example.com" def test_plugin_matrix_room_create_recovers_home_server_from_user_id(): """_room_create() falls back to extracting home_server from user_id when both the room alias and self.home_server carry no homeserver part.""" obj = NotifyMatrix(host="h", user="u", password="pass", targets=["#r"]) obj.access_token = "tok" obj.home_server = None obj.user_id = "@u:create.example.com" # Capture the /createRoom POST so we can verify the room name used. captured = {} def _fake_fetch(path, payload=None, **kw): if path == "/createRoom": captured["payload"] = payload return True, {"room_id": "!newroom:create.example.com"}, 200 return False, {}, 404 with mock.patch.object(obj, "_fetch", side_effect=_fake_fetch): room_id = obj._room_create("#bare_alias") assert room_id == "!newroom:create.example.com" # The room name in the request must use the homeserver from user_id. assert captured["payload"]["room_alias_name"] == "bare_alias" def test_plugin_matrix_room_create_returns_none_without_home_server(): """_room_create() returns None when neither the alias, self.home_server, nor self.user_id can supply a homeserver component.""" obj = NotifyMatrix(host="h", user="u", password="pass", targets=["#r"]) obj.access_token = "tok" obj.home_server = None # user_id with no colon -> extraction yields nothing. obj.user_id = "nocolon" result = obj._room_create("#bare_alias") assert result is None def test_plugin_matrix_room_id_recovers_home_server_from_user_id(): """_room_id() falls back to extracting home_server from user_id when both the alias and self.home_server carry no homeserver part.""" obj = NotifyMatrix(host="h", user="u", password="pass", targets=["#r"]) obj.access_token = "tok" obj.home_server = None obj.user_id = "@u:lookup.example.com" def _fake_fetch(path, payload=None, **kw): return True, {"room_id": "!found:lookup.example.com"}, 200 with mock.patch.object(obj, "_fetch", side_effect=_fake_fetch): room_id = obj._room_id("#bare_alias") assert room_id == "!found:lookup.example.com" def test_plugin_matrix_room_id_returns_none_without_home_server(): """_room_id() returns None when neither the alias, self.home_server, nor self.user_id can supply a homeserver component.""" obj = NotifyMatrix(host="h", user="u", password="pass", targets=["#r"]) obj.access_token = "tok" obj.home_server = None obj.user_id = "nocolon" result = obj._room_id("#bare_alias") assert result is None def test_plugin_matrix_enforce_byte_budget(): """The byte budget shrinks Unicode without looping on empty text.""" # Give both Matrix body representations the same emoji-heavy content. payload = { "body": "\U0001f600" * 1000, "formatted_body": "\U0001f600" * 1000, } # Apply a deliberately small budget so both fields must shrink. NotifyMatrix._matrix_enforce_byte_budget( payload, 500, keys=("formatted_body", "body") ) # Measure the JSON exactly as Matrix serializes it. assert len(dumps(payload, ensure_ascii=False).encode("utf-8")) <= 500 # Empty candidates exercise the helper's no-progress exit path. empty_payload = {"body": "", "formatted_body": ""} # A tiny impossible budget must return instead of looping forever. result = NotifyMatrix._matrix_enforce_byte_budget( empty_payload, 10, keys=("formatted_body", "body") ) assert result == {"body": "", "formatted_body": ""} @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_emoji_byte_budget(mock_post, mock_get, mock_put): """Emoji-heavy messages remain within Matrix's byte limit.""" # Reuse a successful Matrix response for login, lookup, and room joins. response_obj = { "room_id": "!abc123:localhost", "room_alias": "#abc123:localhost", "joined_rooms": ["!abc123:localhost"], "access_token": "abcd1234", "home_server": "localhost", } # Every mocked Matrix request receives the same successful response. request = mock.Mock() request.content = dumps(response_obj) request.status_code = requests.codes.ok # Cover each HTTP verb used by the direct Matrix send path. mock_get.return_value = request mock_post.return_value = request mock_put.return_value = request # Configure rich output so the payload contains two body fields. kwargs = NotifyMatrix.parse_url( "matrix://user:passwd@hostname/#abcd?format=html" ) obj = NotifyMatrix(**kwargs) # Send directly to exercise the byte-budget safety net. assert obj.send(body="\U0001f600" * 20000) is True # Inspect the final room event submitted through PUT. payload = loads(mock_put.call_args.kwargs["data"]) # Confirm the wire representation stays inside Matrix's safe allowance. encoded_len = len(dumps(payload, ensure_ascii=False).encode("utf-8")) assert encoded_len <= MATRIX_CONTENT_BYTE_LIMIT # Invalid Unicode is replaced instead of raising during serialization. assert obj.send(body="\ud800") is True # The invalid code point becomes a valid placeholder in the JSON body. wire_payload = mock_put.call_args.kwargs["data"] assert loads(wire_payload)["body"] == "?" def test_plugin_matrix_e2ee_body_limit(): """Matrix selects limits for formatting, encryption, and webhooks.""" # Begin with an explicitly unencrypted direct Matrix connection. obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=False, secure=True, ) # Plain output receives the larger single-body allowance. assert obj.e2ee is False assert obj.body_maxlen == obj.body_maxlen_default # Rich text carries a plain fallback beside its formatted body. obj.notify_format = NotifyFormat.HTML assert obj.body_maxlen == obj.body_maxlen_formatted # Non-TLS connections cannot use encryption even when requested. obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, secure=False, ) # The setting remains visible, but the normal formatted limit is used. assert obj.e2ee is True obj.notify_format = NotifyFormat.HTML assert obj.body_maxlen == obj.body_maxlen_formatted # A secure connection may use E2EE when crypto support is installed. obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True, secure=True, ) assert obj.e2ee is True obj.notify_format = NotifyFormat.HTML # Installed crypto support activates the smaller encrypted rich limit. if CRYPTOGRAPHY_AVAILABLE: assert obj.body_maxlen == obj.body_maxlen_e2ee_formatted assert obj.body_maxlen < obj.body_maxlen_formatted # Plain encrypted content only needs one Matrix body field. obj.notify_format = NotifyFormat.TEXT assert obj.body_maxlen == obj.body_maxlen_e2ee else: # Encryption also requires the optional cryptography package. assert obj.body_maxlen == obj.body_maxlen_formatted # Webhooks keep the original limit regardless of output format. obj.mode = MatrixWebhookMode.HOOKSHOT assert obj.body_maxlen == obj.body_maxlen_webhook @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_html_plain_fallback(mock_post, mock_get, mock_put): """HTML messages include plain text without repeating their markup.""" # Supply the minimum successful responses needed for a direct room send. response_obj = { "room_id": "!abc123:localhost", "room_alias": "#abc123:localhost", "joined_rooms": ["!abc123:localhost"], "access_token": "abcd1234", "home_server": "localhost", } # Return success for each mocked Matrix endpoint. request = mock.Mock() request.content = dumps(response_obj) request.status_code = requests.codes.ok mock_get.return_value = request mock_post.return_value = request mock_put.return_value = request # Select HTML as Matrix's single configured v1 output format. kwargs = NotifyMatrix.parse_url( "matrix://user:passwd@hostname/#abcd?format=html" ) obj = NotifyMatrix(**kwargs) # Known input provenance allows Matrix to build a plain fallback. assert ( obj.send( title="Title", body="Bold text", body_format=NotifyFormat.HTML, ) is True ) # Read the unencrypted Matrix event submitted to the room. payload = loads(mock_put.call_args.kwargs["data"]) # Preserve the original markup in Matrix's formatted representation. assert payload["formatted_body"] == "

    Title

    Bold text" # The plain-text fallback must not carry the raw markup a second time. assert "" not in payload["body"] assert "Bold text" in payload["body"] # No input format is v1's pass-through signal. assert obj.send(body="Upstream HTML") is True # Re-read the most recent PUT to verify pass-through behavior. payload = loads(mock_put.call_args.kwargs["data"]) assert payload["body"] == "Upstream HTML" @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_matrix_html_split_preserves_body( mock_post, mock_get, mock_put ): """HTML overflow splits before payload sizing so content is preserved.""" # Make login, discovery, and room operations succeed for every chunk. response_obj = { "room_id": "!abc123:localhost", "room_alias": "#abc123:localhost", "joined_rooms": ["!abc123:localhost"], "access_token": "abcd1234", "home_server": "localhost", } # Share one stable mock response across all Matrix HTTP methods. request = mock.Mock() request.content = dumps(response_obj) request.status_code = requests.codes.ok mock_get.return_value = request mock_post.return_value = request mock_put.return_value = request # Disable E2EE so this test targets the unencrypted HTML tier. kwargs = NotifyMatrix.parse_url( "matrix://user:passwd@hostname/#abcd?format=html&e2ee=no" ) # Build a body that requires exactly three framework chunks. obj = NotifyMatrix(**kwargs) body = "x" * (obj.body_maxlen * 2 + 100) # Use the normal notification path so the framework performs the split. assert ( obj.notify( body=body, body_format=NotifyFormat.HTML, overflow=OverflowMode.SPLIT, ) is True ) # Every original character remains across the resulting Matrix events. payloads = [loads(call.kwargs["data"]) for call in mock_put.call_args_list] # The conservative formatted limit should produce three events. assert len(payloads) == 3 # Joining the formatted bodies must recreate the original input. assert "".join(payload["formatted_body"] for payload in payloads) == body # Each individual room event must remain within the byte allowance. assert all( len(dumps(payload, ensure_ascii=False).encode("utf-8")) <= MATRIX_CONTENT_BYTE_LIMIT for payload in payloads ) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") @pytest.mark.skipif(not CRYPTOGRAPHY_AVAILABLE, reason="Requires cryptography") def test_plugin_matrix_e2ee_html_plain_fallback(mock_post, mock_get, mock_put): """Encrypted HTML messages also provide a plain-text fallback.""" from apprise.common import NotifyFormat from apprise.plugins.matrix.e2ee import ( MatrixMegOlmSession, MatrixOlmAccount, ) def _mk_resp(d): # Build a successful Matrix response with JSON content. r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps(d).encode() return r # Room sends only need an empty successful response body here. mock_put.return_value = _mk_resp({}) # Prepare a logged-in Matrix object with E2EE enabled. obj = NotifyMatrix( host="h", user="u", password="pass", targets=["#r"], e2ee=True ) # Force the rich-output branch being exercised by this regression. obj.notify_format = NotifyFormat.HTML # Populate the identifiers normally learned during Matrix login. obj.access_token = "tok" obj.home_server = "h" obj.user_id = "@u:h" obj.device_id = "DEV" # Supply an Olm account so the outer event has a sender key. obj._e2ee_account = MatrixOlmAccount() # Seed a MegOLM session that has already been shared with the room. session = MatrixMegOlmSession() obj.store.set("e2ee_megolm_!r:h", session.to_dict()) obj.store.set("e2ee_key_shared_!r:h", session.session_id) # Capture the event before encryption so its fallback can be checked. captured = {} # Retain the real encryption function after capturing its input. original_encrypt = MatrixMegOlmSession.encrypt def _spy_encrypt(self, event): # Save the plaintext event immediately before MegOLM encryption. captured["event"] = event return original_encrypt(self, event) with mock.patch.object(MatrixMegOlmSession, "encrypt", _spy_encrypt): assert ( obj._e2ee_send_to_room( "!r:h", "Bold text", "Title", NotifyType.INFO, NotifyFormat.HTML, ) is True ) # Inspect both representations inside the captured plaintext event. msg_content = captured["event"]["content"] # The rich representation retains all supplied HTML. assert msg_content["formatted_body"] == "

    Title

    Bold text" # The fallback remains readable without carrying duplicate markup. assert "" not in msg_content["body"] assert "Bold text" in msg_content["body"] # Reject unexpected envelope growth before the homeserver returns a 413. # Record network activity before introducing an oversized device ID. put_count = mock_put.call_count # Simulate unexpected server metadata that consumes the entire budget. obj.device_id = "d" * MATRIX_CONTENT_BYTE_LIMIT assert ( obj._e2ee_send_to_room( "!r:h", "Body", "", NotifyType.INFO, NotifyFormat.HTML ) is False ) # Rejection must happen locally without another room request. assert mock_put.call_count == put_count caronc-apprise-182f859/tests/test_plugin_mattermost.py000066400000000000000000001057421524161175200232550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment from apprise.plugins.mattermost import MattermostMode, NotifyMattermost # Attachment test fixtures TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ("mmost://", {"instance": None}), ("mmosts://", {"instance": None}), ("mmost://:@/", {"instance": None}), ( "mmosts://localhost", { # Thrown because there was no webhook id specified "instance": TypeError, }, ), ( "mmost://localhost/3ccdd113474722377935511fc85d3dd4", { "instance": NotifyMattermost, }, ), ( ( "mmost://localhost/3ccdd113474722377935511fc85d3dd4" "?icon_url=http://localhost/test.png" ), { "instance": NotifyMattermost, }, ), ( "mmost://user@localhost/3ccdd113474722377935511fc85d3dd4?channel=test", { "instance": NotifyMattermost, }, ), ( ( "mmost://user@localhost/3ccdd113474722377935511fc85d3dd4" "?channels=test" ), { "instance": NotifyMattermost, }, ), ( "mmost://user@localhost/3ccdd113474722377935511fc85d3dd4?to=test", { "instance": NotifyMattermost, # Our expected url(privacy=True) startswith() response: "privacy_url": "mmost://user@localhost/3...4/", }, ), ( ( "mmost://localhost/3ccdd113474722377935511fc85d3dd4" "?to=test&image=True" ), {"instance": NotifyMattermost}, ), ( ( # Team defined implies bot mode "mmost://localhost/3ccdd113474722377935511fc85d3dd4" "?to=test&team=chester" ), { "instance": NotifyMattermost, # Provide valid upload response so the attachment test passes "requests_response_text": '{"file_infos": [{"id": "abc"}]}', }, ), ( ( # sets botname on webhook "mmost://localhost/3ccdd113474722377935511fc85d3dd4" "?to=general&botname=foobar" ), {"instance": NotifyMattermost}, ), ( ( "mmost://team@localhost/3ccdd113474722377935511fc85d3dd4" "?channel=$!garbag3^&mode=bot" ), { "instance": NotifyMattermost, # We will fail to notify anyone due to the bad entry "notify_response": False, }, ), ( ( "mmost://localhost/3ccdd113474722377935511fc85d3dd4" "?to=test&image=False" ), {"instance": NotifyMattermost}, ), ( ( "mmost://localhost/3ccdd113474722377935511fc85d3dd4" "?to=test&image=True" ), { "instance": NotifyMattermost, # don't include an image by default "include_image": False, }, ), ( "mmost://localhost:8080/3ccdd113474722377935511fc85d3dd4", { "instance": NotifyMattermost, # Our expected url(privacy=True) startswith() response: "privacy_url": "mmost://localhost:8080/3...4/", }, ), ( "mmost://localhost:8080/3ccdd113474722377935511fc85d3dd4", { "instance": NotifyMattermost, }, ), ( "mmost://localhost:invalid-port/3ccdd113474722377935511fc85d3dd4", { "instance": None, }, ), ( "mmosts://localhost/3ccdd113474722377935511fc85d3dd4", { "instance": NotifyMattermost, }, ), ( ( "https://mattermost.example.com/hooks/" "3ccdd113474722377935511fc85d3dd4" ), { "instance": NotifyMattermost, # Our expected url(privacy=True) startswith() response: "privacy_url": "mmosts://mattermost.example.com/3...4/", }, ), # Test our paths ( "mmosts://localhost/a/path/3ccdd113474722377935511fc85d3dd4", {"instance": NotifyMattermost}, ), ( "mmosts://localhost/////3ccdd113474722377935511fc85d3dd4///", {"instance": NotifyMattermost}, ), # Mode parsing (prefix support) ("mmost://localhost/token?mode=w", {"instance": NotifyMattermost}), ( "mmost://localhost/token?mode=b&to=channel-id-1", { "instance": NotifyMattermost, # Provide valid upload response so the attachment test passes "requests_response_text": '{"file_infos": [{"id": "abc"}]}', }, ), ( "mmost://localhost/token?mode=invalid", { # invalid mode is detected in __init__ "instance": TypeError, }, ), ( "mmosts://localhost/a/path/3ccdd113474722377935511fc85d3dd4", { "instance": NotifyMattermost, }, ), ( "mmosts://localhost/////3ccdd113474722377935511fc85d3dd4///", { "instance": NotifyMattermost, }, ), ( "mmost://localhost/3ccdd113474722377935511fc85d3dd4", { "instance": NotifyMattermost, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "mmost://localhost/3ccdd113474722377935511fc85d3dd4", { "instance": NotifyMattermost, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "mmost://localhost/3ccdd113474722377935511fc85d3dd4", { "instance": NotifyMattermost, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) @pytest.fixture def request_get_mock(mocker): """Prepare requests.get mock.""" mock_get = mocker.patch("requests.get") mock_get.return_value = requests.Request() mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.content = b'{"id":"abc123"}' return mock_get @pytest.fixture def request_post_mock(mocker): """Prepare requests mock.""" mock_post = mocker.patch("requests.post") mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"" return mock_post def test_plugin_mattermost_urls(): """NotifyMattermost() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_mattermost_edge_cases(): """NotifyMattermost() Edge Cases.""" # Invalid Authorization Token with pytest.raises(TypeError): NotifyMattermost(None) with pytest.raises(TypeError): NotifyMattermost(" ") def test_plugin_mattermost_len_webhook_and_bot( request_post_mock, request_get_mock ): """NotifyMattermost() __len__() behaviour.""" # webhook: no channels -> 1 obj = Apprise.instantiate("mmost://localhost/token") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.WEBHOOK assert len(obj) == 1 # webhook: channels -> count obj = Apprise.instantiate("mmost://localhost/token?channels=a,b,c") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.WEBHOOK assert len(obj) == 3 # bot: channels are channel_id values -> count obj = Apprise.instantiate("mmost://localhost/token?mode=bot&to=id1,id2") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT assert len(obj) == 2 assert obj.notify("test") is True assert "mode=bot" in obj.url() # bot: channels are channel_id values -> count obj = Apprise.instantiate("mmost://localhost/token?mode=bot&to=#chan,id1") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT # our #chan isn't added to the list because no team was provided assert len(obj) == 1 assert obj.notify("test") is True assert "mode=bot" in obj.url() obj = Apprise.instantiate("mmost://localhost/token?mode=bot&to=#chan") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT # No one to notify (but we always return a minimum of 1) assert len(obj) == 1 assert obj.notify("test") is False assert "mode=bot" in obj.url() obj = Apprise.instantiate( "mmost://team@localhost/token?mode=bot&to=#chan,id1" ) assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT assert len(obj) == 2 # We can look up the team now assert obj.notify("test") is True assert "mode=bot" in obj.url() # Second call to notify() pulls from cache assert obj.notify("test") is True obj = Apprise.instantiate( "mmost://team@localhost/token?mode=bot&to=#chan,id1" ) assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT # Invalid response request_get_mock.return_value.content = b"}" assert obj.notify("test") is False obj = Apprise.instantiate( "mmost://team@localhost/token?mode=bot&to=#chan,id1" ) assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT # empty response request_get_mock.return_value.content = b"{}" assert obj.notify("test") is False obj = Apprise.instantiate( "mmost://team@localhost/token?mode=bot&to=#chan,id1" ) assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT # upstream inquiry failure request_get_mock.side_effect = requests.RequestException assert obj.notify("test") is False def test_plugin_mattermost_channels(request_post_mock): """NotifyMattermost() Channel Testing.""" # Test channels with/without hashtag (#) user = "user1" token = "token" channels = ["#one", "two"] # Instantiate our URL obj = Apprise.instantiate( "mmost://{user}@localhost:8065/{token}?channels={channels}".format( user=user, token=token, channels=",".join(channels) ) ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title") is True assert request_post_mock.called is True assert request_post_mock.call_count == 2 assert request_post_mock.call_args_list[0][0][0].startswith( "http://localhost:8065/hooks/token" ) # Our Posted JSON Object posted_json = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert "username" in posted_json assert "channel" in posted_json assert "text" in posted_json assert posted_json["username"] == "user1" assert posted_json["channel"] == "one" assert posted_json["text"] == "title\r\nbody" # Our second Posted JSON Object posted_json = json.loads(request_post_mock.call_args_list[1][1]["data"]) assert posted_json["username"] == "user1" assert posted_json["channel"] == "two" assert posted_json["text"] == "title\r\nbody" def test_mattermost_post_default_port(request_post_mock): # Test token token = "token" # Instantiate our URL obj = Apprise.instantiate(f"mmosts://mattermost.example.com/{token}") assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title") is True # Make sure we don't use port if not provided assert request_post_mock.called is True assert request_post_mock.call_count == 1 assert request_post_mock.call_args_list[0][0][0].startswith( "https://mattermost.example.com/hooks/token" ) # Our Posted JSON Object posted_json = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert "text" in posted_json assert posted_json["text"] == "title\r\nbody" def test_mattermost_icon_override(request_post_mock): # Test token token = "token" icon_url = "http://localhost/test.png" # Instantiate our URL with an icon override obj = Apprise.instantiate( "mmost://mattermost.example.com/{token}?icon_url={icon_url}".format( token=token, icon_url=icon_url, ) ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title") is True assert request_post_mock.called is True assert request_post_mock.call_count == 1 # Our Posted JSON Object posted_json = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert posted_json["icon_url"] == icon_url def test_plugin_mattermost_webhook_payload_variants(request_post_mock, mocker): """Webhook mode covers icon_url vs include_image branches, and optional channel.""" # Force image_url() to be deterministic for coverage mocker.patch.object( NotifyMattermost, "image_url", return_value="http://img/ok.png" ) # Case 1: include_image=True (default) and no icon_url -> icon_url from obj = Apprise.instantiate("mmost://user@localhost/token?to=test") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.WEBHOOK assert obj.notify(body="body", title="title") is True posted_json = json.loads(request_post_mock.call_args_list[-1][1]["data"]) assert posted_json["username"] == "user" assert posted_json["channel"] == "test" assert posted_json["text"] == "title\r\nbody" assert posted_json["icon_url"] == "http://img/ok.png" # Case 2: icon_url overrides include_image request_post_mock.reset_mock() obj = Apprise.instantiate( "mmost://user@localhost/token?to=test&icon_url=http://x/icon.png" ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title") is True posted_json = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert posted_json["icon_url"] == "http://x/icon.png" # Case 3: no channels specified -> payload has no channel key request_post_mock.reset_mock() obj = Apprise.instantiate("mmost://user@localhost/token") assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title") is True posted_json = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert "channel" not in posted_json def test_plugin_mattermost_webhook_http_error_and_exception( request_post_mock, mocker ): """Webhook mode error paths.""" obj = Apprise.instantiate("mmost://localhost/token?to=test") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.WEBHOOK # HTTP error request_post_mock.return_value.status_code = requests.codes.bad_request assert obj.notify(body="body", title="title") is False # Request exception request_post_mock.reset_mock() request_post_mock.side_effect = requests.RequestException("boom") assert obj.notify(body="body", title="title") is False def test_plugin_mattermost_bot_mode_success_and_payload(request_post_mock): """Bot mode success path, headers and payload.""" bearer = "bearerToken" channel_id = "channel-id-123" obj = Apprise.instantiate( f"mmosts://mattermost.example.com/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT # Mattermost returns 201 Created for /api/v4/posts request_post_mock.return_value.status_code = requests.codes.created assert obj.notify(body="body", title="title") is True assert request_post_mock.called is True assert request_post_mock.call_count == 1 url = request_post_mock.call_args_list[0][0][0] assert url.startswith("https://mattermost.example.com/api/v4/posts") headers = request_post_mock.call_args_list[0][1]["headers"] assert headers.get("Authorization") == f"Bearer {bearer}" posted_json = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert posted_json["channel_id"] == channel_id assert posted_json["message"] == "title\r\nbody" assert "text" not in posted_json def test_plugin_mattermost_bot_mode_requires_channel_id(request_post_mock): """Bot mode requires at least one channel_id target.""" bearer = "bearerToken" obj = Apprise.instantiate(f"mmost://localhost/{bearer}?mode=bot") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT request_post_mock.return_value.status_code = requests.codes.created assert obj.notify(body="body", title="title") is False assert request_post_mock.called is False def test_plugin_mattermost_bot_mode_http_error_and_exception( request_post_mock, ): """Bot mode error paths.""" bearer = "bearerToken" obj = Apprise.instantiate(f"mmost://localhost/{bearer}?mode=bot&to=id1") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT # HTTP error (not 201) request_post_mock.return_value.status_code = requests.codes.unauthorized assert obj.notify(body="body", title="title") is False # Request exception request_post_mock.reset_mock() request_post_mock.side_effect = requests.RequestException("boom") assert obj.notify(body="body", title="title") is False def test_plugin_mattermost_bot_channel_lookup_success( request_post_mock, request_get_mock ): """Bot mode resolves #channel via team lookup.""" bearer = "bearerToken" team = "myteam" channel = "#general" channel_id = "cid123" # Mock channel lookup response request_get_mock.return_value.content = json.dumps( {"id": channel_id} ).encode("utf-8") obj = Apprise.instantiate( "mmost://localhost/{bearer}?mode=bot&team={team}&to={chan}".format( bearer=bearer, team=team, chan=channel ) ) assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT request_post_mock.return_value.status_code = requests.codes.created assert obj.notify(body="body", title="title") is True assert request_get_mock.called is True get_url = request_get_mock.call_args_list[0][0][0] assert "/api/v4/teams/name/" in get_url assert "/channels/name/" in get_url posted_json = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert posted_json["channel_id"] == channel_id assert posted_json["message"] == "title\r\nbody" def test_plugin_mattermost_bot_channel_lookup_partial_success( request_post_mock, request_get_mock ): """One lookup fails, one succeeds, overall result is False.""" bearer = "bearerToken" team = "myteam" def side_effect(*args, **kwargs): r = requests.Request() url = args[0] if url.endswith("/channels/name/good"): r.status_code = requests.codes.ok r.content = b'{"id":"cid-good"}' else: r.status_code = requests.codes.not_found r.content = b"" return r request_get_mock.side_effect = side_effect obj = Apprise.instantiate( "mmost://localhost/{bearer}?mode=bot&team={team}&to=#good,#bad".format( bearer=bearer, team=team ) ) assert isinstance(obj, NotifyMattermost) request_post_mock.return_value.status_code = requests.codes.created assert obj.notify(body="body", title="title") is False assert request_post_mock.call_count == 1 posted_json = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert posted_json["channel_id"] == "cid-good" def test_plugin_mattermost_webhook_attachment_warning(request_post_mock): """Webhook mode has attachment_support=False; pipeline drops attach.""" obj = Apprise.instantiate("mmost://localhost/token") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.WEBHOOK assert obj.attachment_support is False attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Pipeline silently drops the attachment; text message still succeeds assert obj.notify(body="body", title="title", attach=attach) is True assert request_post_mock.call_count == 1 # No file upload should have been attempted assert "files" not in str(request_post_mock.call_args) def test_plugin_mattermost_webhook_botname_in_url(request_post_mock): """Webhook botname is the {user}@ URL prefix; ?botname= is an alias.""" # Both input forms resolve to the same object obj = Apprise.instantiate("mmost://mybot@localhost/token?to=general") obj2 = Apprise.instantiate( "mmost://localhost/token?botname=mybot&to=general" ) assert isinstance(obj, NotifyMattermost) assert isinstance(obj2, NotifyMattermost) assert obj.mode == MattermostMode.WEBHOOK assert obj.user == "mybot" assert obj2.user == "mybot" # url() uses the {user}@ prefix -- consistent with all Apprise plugins url = obj.url() assert "mybot@" in url assert "botname" not in url # Round-trip preserves the botname obj3 = Apprise.instantiate(url) assert isinstance(obj3, NotifyMattermost) assert obj3.user == "mybot" assert obj3.mode == MattermostMode.WEBHOOK # Webhook payload carries username = botname request_post_mock.return_value.status_code = requests.codes.ok assert obj.notify(body="body", title="title") is True posted = json.loads(request_post_mock.call_args_list[0][1]["data"]) assert posted["username"] == "mybot" def test_plugin_mattermost_bot_team_in_url(request_post_mock): """Bot mode team is the {user}@ URL prefix; ?team= is an alias.""" obj = Apprise.instantiate("mmost://myteam@localhost/token?mode=bot&to=id1") assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT assert obj.user == "myteam" # url() uses {user}@ prefix for the team too url = obj.url() assert "myteam@" in url assert "mode=bot" in url def test_plugin_mattermost_bot_attachments(request_post_mock): """Bot mode uploads attachments and includes file_ids in the post.""" bearer = "bearerToken" channel_id = "channel-id-123" file_id = "uploaded-file-id-abc" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) def _mk_resp(content, code=requests.codes.ok): r = requests.Request() r.status_code = code r.content = content return r upload_resp = _mk_resp( json.dumps({"file_infos": [{"id": file_id}]}).encode("utf-8") ) post_resp = _mk_resp(b"{}", requests.codes.created) request_post_mock.side_effect = [upload_resp, post_resp] obj = Apprise.instantiate( f"mmost://localhost/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.mode == MattermostMode.BOT assert obj.notify(body="body", title="title", attach=attach) is True assert request_post_mock.call_count == 2 # First call is the file upload upload_call = request_post_mock.call_args_list[0] assert "/api/v4/files" in upload_call[0][0] assert upload_call[1]["data"] == {"channel_id": channel_id} assert "files" in upload_call[1]["files"] # Second call is the post; it must include file_ids post_call = request_post_mock.call_args_list[1] assert "/api/v4/posts" in post_call[0][0] post_json = json.loads(post_call[1]["data"]) assert post_json["file_ids"] == [file_id] assert post_json["message"] == "title\r\nbody" assert post_json["channel_id"] == channel_id def test_plugin_mattermost_bot_multi_attach_multi_target(request_post_mock): """Multiple attachments and multiple targets each get their own uploads.""" bearer = "bearerToken" ch1 = "channel-001" ch2 = "channel-002" fid1 = "fid-001" fid2 = "fid-002" attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) def _mk_upload(fid): r = requests.Request() r.status_code = requests.codes.ok r.content = json.dumps({"file_infos": [{"id": fid}]}).encode("utf-8") return r def _mk_post(): r = requests.Request() r.status_code = requests.codes.created r.content = b"{}" return r # 2 attachments x 2 targets = 4 upload calls + 2 post calls request_post_mock.side_effect = [ _mk_upload(fid1), _mk_upload(fid2), _mk_post(), _mk_upload(fid1), _mk_upload(fid2), _mk_post(), ] obj = Apprise.instantiate( "mmost://localhost/{b}?mode=bot&to={c1},{c2}".format( b=bearer, c1=ch1, c2=ch2 ) ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title", attach=attach) is True assert request_post_mock.call_count == 6 # Verify file_ids for each post post1 = json.loads(request_post_mock.call_args_list[2][1]["data"]) assert post1["file_ids"] == [fid1, fid2] assert post1["channel_id"] == ch1 post2 = json.loads(request_post_mock.call_args_list[5][1]["data"]) assert post2["file_ids"] == [fid1, fid2] assert post2["channel_id"] == ch2 def test_plugin_mattermost_bot_attachment_upload_http_error( request_post_mock, ): """Upload HTTP error marks the whole target as failed.""" bearer = "bearerToken" channel_id = "channel-id-123" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) r = requests.Request() r.status_code = requests.codes.internal_server_error r.content = b"" request_post_mock.return_value = r obj = Apprise.instantiate( f"mmost://localhost/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title", attach=attach) is False # Only the upload was attempted; no post was made assert request_post_mock.call_count == 1 assert "/api/v4/files" in request_post_mock.call_args_list[0][0][0] def test_plugin_mattermost_bot_attachment_request_exception( request_post_mock, ): """RequestException during upload marks the target as failed.""" bearer = "bearerToken" channel_id = "channel-id-123" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) request_post_mock.side_effect = requests.RequestException("boom") obj = Apprise.instantiate( f"mmost://localhost/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title", attach=attach) is False assert request_post_mock.call_count == 1 def test_plugin_mattermost_bot_attachment_ioerror(request_post_mock): """OSError reading the attachment marks the target as failed.""" bearer = "bearerToken" channel_id = "channel-id-123" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) with mock.patch( "apprise.attachment.file.AttachFile.open", side_effect=OSError("disk error"), ): obj = Apprise.instantiate( f"mmost://localhost/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title", attach=attach) is False # No HTTP request should have been made assert request_post_mock.call_count == 0 def test_plugin_mattermost_bot_attachment_valueerror(request_post_mock): """ValueError from a closed stream is handled the same as OSError.""" bearer = "bearerToken" channel_id = "channel-id-123" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) with mock.patch( "apprise.attachment.file.AttachFile.open", side_effect=ValueError("I/O operation on closed file"), ): obj = Apprise.instantiate( f"mmost://localhost/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title", attach=attach) is False assert request_post_mock.call_count == 0 def test_plugin_mattermost_bot_attachment_bad_response(request_post_mock): """Unparseable upload response marks the target as failed.""" bearer = "bearerToken" channel_id = "channel-id-123" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) r = requests.Request() r.status_code = requests.codes.ok r.content = b"not-json{{{" request_post_mock.return_value = r obj = Apprise.instantiate( f"mmost://localhost/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title", attach=attach) is False assert request_post_mock.call_count == 1 def test_plugin_mattermost_bot_attachment_no_file_id(request_post_mock): """Upload response with empty file_id marks the target as failed.""" bearer = "bearerToken" channel_id = "channel-id-123" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) r = requests.Request() r.status_code = requests.codes.ok # file_infos present but id is empty r.content = json.dumps({"file_infos": [{"id": ""}]}).encode("utf-8") request_post_mock.return_value = r obj = Apprise.instantiate( f"mmost://localhost/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title", attach=attach) is False def test_plugin_mattermost_bot_attachment_invalid(request_post_mock): """An inaccessible attachment path marks the target as failed.""" bearer = "bearerToken" channel_id = "channel-id-123" attach = AppriseAttachment("/path/does/not/exist.gif") obj = Apprise.instantiate( f"mmost://localhost/{bearer}?mode=bot&to={channel_id}" ) assert isinstance(obj, NotifyMattermost) assert obj.notify(body="body", title="title", attach=attach) is False # No upload request should be made for an invalid attachment assert request_post_mock.call_count == 0 def test_plugin_mattermost_bot_attachment_partial_target_failure( request_post_mock, ): """Attachment upload failure on one target does not prevent others.""" bearer = "bearerToken" ch1 = "channel-001" ch2 = "channel-002" fid = "fid-001" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) def _mk_upload_ok(): r = requests.Request() r.status_code = requests.codes.ok r.content = json.dumps({"file_infos": [{"id": fid}]}).encode("utf-8") return r def _mk_upload_fail(): r = requests.Request() r.status_code = requests.codes.internal_server_error r.content = b"" return r def _mk_post(): r = requests.Request() r.status_code = requests.codes.created r.content = b"{}" return r # ch1: upload ok, post ok; ch2: upload fails request_post_mock.side_effect = [ _mk_upload_ok(), _mk_post(), _mk_upload_fail(), ] obj = Apprise.instantiate( "mmost://localhost/{b}?mode=bot&to={c1},{c2}".format( b=bearer, c1=ch1, c2=ch2 ) ) assert isinstance(obj, NotifyMattermost) # One target succeeded, one failed -> overall False assert obj.notify(body="body", title="title", attach=attach) is False # 1 upload + 1 post (ch1) + 1 upload-fail (ch2) = 3 calls assert request_post_mock.call_count == 3 def test_plugin_mattermost_bot_attachment_memory_multi_target( request_post_mock, ): """Attachment bytes are pre-read once so every target gets a fresh stream. Simulates an attachment whose open() raises ValueError on the second call (as AttachMemory does when its BytesIO is closed after the first read). Our pre-read approach calls open() exactly once per attachment before the target loop, then wraps the bytes in a fresh io.BytesIO for each target upload. """ bearer = "bearerToken" ch1 = "channel-001" ch2 = "channel-002" fid = "fid-001" attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Simulate a stream that closes after the first read: raise ValueError # on any open() call beyond the first. _orig_open = attach.attachments[0].open open_calls = [0] def _open_once(*args, **kwargs): open_calls[0] += 1 if open_calls[0] > 1: raise ValueError("stream already closed") return _orig_open(*args, **kwargs) def _mk_upload(): r = requests.Request() r.status_code = requests.codes.ok r.content = json.dumps({"file_infos": [{"id": fid}]}).encode("utf-8") return r def _mk_post(): r = requests.Request() r.status_code = requests.codes.created r.content = b"{}" return r # 1 attachment x 2 targets = 2 upload + 2 post calls request_post_mock.side_effect = [ _mk_upload(), _mk_post(), _mk_upload(), _mk_post(), ] obj = Apprise.instantiate( "mmost://localhost/{b}?mode=bot&to={c1},{c2}".format( b=bearer, c1=ch1, c2=ch2 ) ) assert isinstance(obj, NotifyMattermost) with mock.patch.object(attach.attachments[0], "open", _open_once): assert obj.notify(body="body", title="title", attach=attach) is True # open() was called exactly once (pre-read), not once per target assert open_calls[0] == 1 # Both targets received their uploads assert request_post_mock.call_count == 4 post1 = json.loads(request_post_mock.call_args_list[1][1]["data"]) assert post1["file_ids"] == [fid] assert post1["channel_id"] == ch1 post2 = json.loads(request_post_mock.call_args_list[3][1]["data"]) assert post2["file_ids"] == [fid] assert post2["channel_id"] == ch2 caronc-apprise-182f859/tests/test_plugin_messagebird.py000066400000000000000000000114601524161175200233340ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.messagebird import NotifyMessageBird logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "msgbird://", { # No hostname/apikey specified "instance": TypeError, }, ), ( "msgbird://{}/abcd".format("a" * 25), { # invalid characters in source phone number "instance": TypeError, }, ), ( "msgbird://{}/123".format("a" * 25), { # invalid source phone number "instance": TypeError, }, ), ( "msgbird://{}/15551232000".format("a" * 25), { # target phone number becomes who we text too; all is good "instance": NotifyMessageBird, # Our expected url(privacy=True) startswith() response: "privacy_url": "msgbird://a...a/15551232000", }, ), ( "msgbird://{}/15551232000/abcd".format("a" * 25), { # valid credentials "instance": NotifyMessageBird, # Since there are no targets specified we expect a False return on # send() "notify_response": False, }, ), ( "msgbird://{}/15551232000/123".format("a" * 25), { # valid credentials "instance": NotifyMessageBird, # Since there are no targets specified we expect a False return on # send() "notify_response": False, }, ), ( "msgbird://{}/?from=15551233000&to=15551232000".format("a" * 25), { # reference to to= and from= "instance": NotifyMessageBird, }, ), ( "msgbird://{}/15551232000".format("a" * 25), { "instance": NotifyMessageBird, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "msgbird://{}/15551232000".format("a" * 25), { "instance": NotifyMessageBird, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "msgbird://{}/15551232000".format("a" * 25), { "instance": NotifyMessageBird, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_messagebird_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_messagebird_edge_cases(mock_post): """NotifyMessageBird() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens # authkey = '{}'.format('a' * 24) source = "+1 (555) 123-3456" # No apikey specified with pytest.raises(TypeError): NotifyMessageBird(apikey=None, source=source) with pytest.raises(TypeError): NotifyMessageBird(apikey=" ", source=source) caronc-apprise-182f859/tests/test_plugin_misskey.py000066400000000000000000000101141524161175200225260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from helpers import AppriseURLTester from apprise.plugins.misskey import NotifyMisskey logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ################################## # NotifyMisskey ################################## ( "misskey://", { # Missing Everything :) "instance": None, }, ), ( "misskey://:@/", { "instance": None, }, ), ( "misskey://hostname", { # Missing Access Token "instance": TypeError, }, ), ( "misskey://access_token@hostname", { # We're good; it's a simple notification "instance": NotifyMisskey, }, ), ( "misskeys://access_token@hostname", { # We're good; it's another simple notification "instance": NotifyMisskey, # Our expected url(privacy=True) startswith() response: "privacy_url": "misskeys://a...n@hostname/", }, ), ( "misskey://hostname/?token=abcd123", { # Our access token can be provided as a token= variable "instance": NotifyMisskey, # Our expected url(privacy=True) startswith() response: "privacy_url": "misskey://a...3@hostname", }, ), ( "misskeys://access_token@hostname:8443", { # A custom port specified "instance": NotifyMisskey, }, ), ( "misskey://access_token@hostname?visibility=invalid", { # An invalid visibility "instance": TypeError, }, ), ( "misskeys://access_token@hostname?visibility=specified", { # Specified a different visiblity "instance": NotifyMisskey, }, ), ( "misskeys://access_token@hostname", { "instance": NotifyMisskey, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "misskeys://access_token@hostname", { "instance": NotifyMisskey, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_misskey_urls(): """NotifyMisskey() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_mqtt.py000066400000000000000000000367701524161175200220470ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import re import ssl import sys from unittest.mock import ANY, Mock, call import pytest import apprise from apprise.plugins.mqtt import NotifyMQTT # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) @pytest.fixture def mqtt_client_mock(mocker): """Mocks an MQTT client and response and returns the mocked client.""" if "paho" not in sys.modules: raise pytest.skip("Requires that `paho-mqtt` is installed") # Establish mock of the `publish()` response object. publish_result = Mock( **{ "rc": 0, "is_published.return_value": True, } ) # Establish mock of the `Client()` object. mock_client = Mock( **{ "connect.return_value": 0, "reconnect.return_value": 0, "is_connected.return_value": True, "publish.return_value": publish_result, } ) mocker.patch("paho.mqtt.client.Client", return_value=mock_client) return mock_client @pytest.mark.skipif( "paho" in sys.modules, reason="Requires that `paho-mqtt` is NOT installed" ) def test_plugin_mqtt_paho_import_error(): """Verify `NotifyMQTT` is disabled when `paho.mqtt.client` fails loading.""" # without the library, the object can't be instantiated obj = apprise.Apprise.instantiate("mqtt://user:pass@localhost/my/topic") assert obj is None def test_plugin_mqtt_default_success(mqtt_client_mock): """Verify `NotifyMQTT` succeeds and has appropriate default settings.""" # Instantiate the notifier. obj = apprise.Apprise.instantiate( "mqtt://localhost:1234/my/topic", suppress_exceptions=False ) assert isinstance(obj, NotifyMQTT) # We only loaded 1 topic assert len(obj) == 1 assert obj.url().startswith("mqtt://localhost:1234/my/topic") # Genrate the URL Identifier assert isinstance(obj.url_id(), str) # Verify default settings. assert r"qos=0" in obj.url() assert re.search(r"version=v3.1.1", obj.url()) assert r"session=no" in obj.url() assert r"client_id=" not in obj.url() # Verify notification succeeds. assert obj.notify(body="test=test") is True # Send another notification (a new connection isn't attempted to be # established as one already exists) assert obj.notify(body="foo=bar") is True # Verify the right calls have been made to the MQTT client object. assert mqtt_client_mock.mock_calls == [ call.max_inflight_messages_set(200), call.connect("localhost", port=1234, keepalive=30), call.loop_start(), call.is_connected(), call.publish("my/topic", payload="test=test", qos=0, retain=False), call.publish().is_published(), call.is_connected(), call.publish("my/topic", payload="foo=bar", qos=0, retain=False), call.publish().is_published(), ] def test_plugin_mqtt_multiple_topics_success(mqtt_client_mock): """Verify submission to multiple MQTT topics.""" # Designate multiple topic targets. obj = apprise.Apprise.instantiate( "mqtt://localhost/my/topic,my/other/topic", suppress_exceptions=False ) # Verify we have loaded 2 topics assert len(obj) == 2 assert isinstance(obj, NotifyMQTT) assert obj.url().startswith("mqtt://localhost") assert r"my/topic" in obj.url() assert r"my/other/topic" in obj.url() assert obj.notify(body="test=test") is True # Verify the right calls have been made to the MQTT client object. assert mqtt_client_mock.mock_calls == [ call.max_inflight_messages_set(200), call.connect("localhost", port=1883, keepalive=30), call.loop_start(), call.is_connected(), call.publish("my/topic", payload="test=test", qos=0, retain=False), call.publish().is_published(), call.is_connected(), call.publish( "my/other/topic", payload="test=test", qos=0, retain=False ), call.publish().is_published(), ] def test_plugin_mqtt_to_success(mqtt_client_mock): """Verify `NotifyMQTT` succeeds with the `to=` parameter.""" # Leverage the `to=` argument to identify the topic. obj = apprise.Apprise.instantiate( "mqtt://localhost?to=my/topic", suppress_exceptions=False ) assert isinstance(obj, NotifyMQTT) assert obj.url().startswith("mqtt://localhost/my/topic") # Verify default settings. assert r"qos=0" in obj.url() assert re.search(r"version=v3.1.1", obj.url()) # Verify notification succeeds. assert obj.notify(body="test=test") is True def test_plugin_mqtt_valid_settings_success(mqtt_client_mock): """Verify settings as URL parameters will be accepted.""" # Instantiate the notifier. obj = apprise.Apprise.instantiate( "mqtt://localhost/my/topic?qos=1&version=v3.1", suppress_exceptions=False, ) assert isinstance(obj, NotifyMQTT) assert obj.url().startswith("mqtt://localhost") assert r"qos=1" in obj.url() assert re.search(r"version=v3.1", obj.url()) def test_plugin_mqtt_invalid_settings_failure(mqtt_client_mock): """Verify notifier instantiation croaks on invalid settings.""" # Test case for invalid/unsupported MQTT version. with pytest.raises(TypeError): apprise.Apprise.instantiate( "mqtt://localhost?version=v1.0.0.0", suppress_exceptions=False ) # Test case for invalid/unsupported `qos`. with pytest.raises(TypeError): apprise.Apprise.instantiate( "mqtt://localhost?qos=123", suppress_exceptions=False ) with pytest.raises(TypeError): apprise.Apprise.instantiate( "mqtt://localhost?qos=invalid", suppress_exceptions=False ) def test_plugin_mqtt_bad_url_failure(mqtt_client_mock): """Verify notifier is disabled when using an invalid URL.""" obj = apprise.Apprise.instantiate("mqtt://", suppress_exceptions=False) assert obj is None def test_plugin_mqtt_no_topic_failure(mqtt_client_mock): """Verify notification fails when no topic is given.""" obj = apprise.Apprise.instantiate( "mqtt://localhost", suppress_exceptions=False ) assert isinstance(obj, NotifyMQTT) assert obj.notify(body="test=test") is False def test_plugin_mqtt_tls_connect_success(mqtt_client_mock): """Verify TLS encrypted connections work.""" obj = apprise.Apprise.instantiate( "mqtts://user:pass@localhost/my/topic", suppress_exceptions=False ) assert isinstance(obj, NotifyMQTT) assert obj.url().startswith("mqtts://user:pass@localhost/my/topic") assert obj.notify(body="test=test") is True # Verify the right calls have been made to the MQTT client object. assert mqtt_client_mock.mock_calls == [ call.max_inflight_messages_set(200), call.username_pw_set("user", password="pass"), call.tls_set( ca_certs=ANY, certfile=None, keyfile=None, cert_reqs=ssl.VerifyMode.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS, ciphers=None, ), call.tls_insecure_set(False), call.connect("localhost", port=8883, keepalive=30), call.loop_start(), call.is_connected(), call.publish("my/topic", payload="test=test", qos=0, retain=False), call.publish().is_published(), ] def test_plugin_mqtt_tls_no_certificates_failure(mqtt_client_mock, mocker): """Verify TLS does not work without access to CA root certificates.""" # Clear CA certificates. mocker.patch.object(NotifyMQTT, "CA_CERTIFICATE_FILE_LOCATIONS", []) obj = apprise.Apprise.instantiate( "mqtts://user:pass@localhost/my/topic", suppress_exceptions=False ) assert isinstance(obj, NotifyMQTT) logger: Mock = mocker.spy(obj, "logger") # Verify notification fails w/o CA certificates. assert obj.notify(body="test=test") is False assert logger.mock_calls == [ call.error( "MQTT secure communication can not be verified, " "CA certificates file missing" ) ] def test_plugin_mqtt_tls_no_verify_success(mqtt_client_mock): """Verify TLS encrypted connections work with `verify=False`.""" # A single user (not password) + no verifying of host obj = apprise.Apprise.instantiate( "mqtts://user:pass@localhost/my/topic?verify=False", suppress_exceptions=False, ) assert isinstance(obj, NotifyMQTT) assert obj.notify(body="test=test") is True # Chain validation must be disabled via CERT_NONE (not just hostname # skipping via tls_insecure_set) so self-signed certs are accepted. assert ( call.tls_set( ca_certs=None, certfile=None, keyfile=None, cert_reqs=ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLS, ciphers=None, ) in mqtt_client_mock.mock_calls ) assert call.tls_insecure_set(True) in mqtt_client_mock.mock_calls def test_plugin_mqtt_tls_no_verify_no_ca_success(mqtt_client_mock, mocker): """Verify TLS with verify=False succeeds without CA certificates.""" # Clear CA certificates -- with verify=False this should not block us mocker.patch.object(NotifyMQTT, "CA_CERTIFICATE_FILE_LOCATIONS", []) obj = apprise.Apprise.instantiate( "mqtts://user:pass@localhost/my/topic?verify=False", suppress_exceptions=False, ) assert isinstance(obj, NotifyMQTT) # Should succeed without CA certificates when verify is disabled assert obj.notify(body="test=test") is True assert ( call.tls_set( ca_certs=None, certfile=None, keyfile=None, cert_reqs=ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLS, ciphers=None, ) in mqtt_client_mock.mock_calls ) def test_plugin_mqtt_session_client_id_success(mqtt_client_mock): """Verify handling `session=yes` and `client_id=` works.""" obj = apprise.Apprise.instantiate( "mqtt://user@localhost/my/topic?session=yes&client_id=apprise", suppress_exceptions=False, ) assert isinstance(obj, NotifyMQTT) assert obj.url().startswith("mqtt://user@localhost") assert r"my/topic" in obj.url() assert r"client_id=apprise" in obj.url() assert r"session=yes" in obj.url() assert r"retain=no" in obj.url() assert obj.notify(body="test=test") is True def test_plugin_mqtt_retain(mqtt_client_mock): """Verify handling of Retain Message Flag.""" obj = apprise.Apprise.instantiate( "mqtt://user@localhost/my/topic?retain=yes", suppress_exceptions=False ) assert isinstance(obj, NotifyMQTT) assert obj.url().startswith("mqtt://user@localhost") assert r"my/topic" in obj.url() assert r"session=no" in obj.url() assert r"retain=yes" in obj.url() assert obj.notify(body="test=test") is True def test_plugin_mqtt_connect_failure(mqtt_client_mock): """Verify `NotifyMQTT` fails when MQTT `connect()` fails.""" # Emulate a situation where the `connect()` method fails. mqtt_client_mock.connect.return_value = 2 obj = apprise.Apprise.instantiate( "mqtt://localhost/my/topic", suppress_exceptions=False ) # Verify notification fails. assert obj.notify(body="test=test") is False def test_plugin_mqtt_reconnect_failure(mqtt_client_mock): """Verify `NotifyMQTT` fails when MQTT `reconnect()` fails.""" # Emulate a situation where MQTT reconnect fails. mqtt_client_mock.reconnect.return_value = 2 mqtt_client_mock.is_connected.return_value = False obj = apprise.Apprise.instantiate( "mqtt://localhost/my/topic", suppress_exceptions=False ) # Verify notification fails. assert obj.notify(body="test=test") is False def test_plugin_mqtt_publish_failure(mqtt_client_mock): """Verify `NotifyMQTT` fails when MQTT `publish()` fails.""" # Emulate a situation where the `publish()` method fails. mqtt_response = mqtt_client_mock.publish.return_value mqtt_response.rc = 2 obj = apprise.Apprise.instantiate( "mqtt://localhost/my/topic", suppress_exceptions=False ) # Verify notification fails. assert obj.notify(body="test=test") is False def test_plugin_mqtt_exception_failure(mqtt_client_mock): """Verify `NotifyMQTT` fails when an exception happens.""" obj = apprise.Apprise.instantiate( "mqtt://localhost/my/topic", suppress_exceptions=False ) # Emulate a situation where `connect()` raises an exception. mqtt_client_mock.connect.return_value = None # Verify notification fails for every exception type that connect() or # the TLS setup can raise (most-specific to least-specific order). for side_effect in ( ssl.CertificateError, ssl.SSLError, ConnectionError, OSError, ValueError, ): mqtt_client_mock.connect.side_effect = side_effect assert obj.notify(body="test=test") is False def test_plugin_mqtt_not_published_failure(mqtt_client_mock, mocker): """Verify `NotifyMQTT` fails there if the message has not been published.""" # Speed up testing by making `NotifyMQTT` not block anywhere. mocker.patch.object(NotifyMQTT, "socket_read_timeout", 0.00025) mocker.patch.object(NotifyMQTT, "mqtt_block_time_sec", 0) # Emulate a situation where `is_published()` returns `False`. mqtt_response = mqtt_client_mock.publish.return_value mqtt_response.is_published.return_value = False obj = apprise.Apprise.instantiate( "mqtt://localhost/my/topic", suppress_exceptions=False ) # Verify notification fails. assert obj.notify(body="test=test") is False def test_plugin_mqtt_not_published_recovery_success(mqtt_client_mock): """Verify `NotifyMQTT` success after recovering from is_published==False.""" # Emulate a situation where `is_published()` returns `False`. mqtt_response = mqtt_client_mock.publish.return_value mqtt_response.is_published.return_value = None mqtt_response.is_published.side_effect = (False, True) obj = apprise.Apprise.instantiate( "mqtt://localhost/my/topic", suppress_exceptions=False ) # Verify notification fails. assert obj.notify(body="test=test") is True caronc-apprise-182f859/tests/test_plugin_msg91.py000066400000000000000000000167731524161175200220230ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.plugins.msg91 import NotifyMSG91 logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "msg91://", { # No hostname/authkey specified "instance": TypeError, }, ), ( "msg91://-", { # Invalid AuthKey "instance": TypeError, }, ), ( "msg91://{}".format("a" * 23), { # valid AuthKey but no Template ID "instance": TypeError, }, ), ( "msg91://{}@{}".format("t" * 20, "a" * 23), { # Valid entry but no targets "instance": NotifyMSG91, # Since there are no targets specified we expect a False return on # send() "notify_response": False, }, ), ( "msg91://{}@{}/abcd".format("t" * 20, "a" * 23), { # No number to notify "instance": NotifyMSG91, # Since there are no targets specified we expect a False return on # send() "notify_response": False, }, ), ( "msg91://{}@{}/15551232000".format("t" * 20, "a" * 23), { # a valid message "instance": NotifyMSG91, # Our expected url(privacy=True) startswith() response: "privacy_url": "msg91://t...t@a...a/15551232000", }, ), ( "msg91://{}@{}/?to=15551232000&short_url=no".format( "t" * 20, "a" * 23 ), { # a valid message "instance": NotifyMSG91, }, ), ( "msg91://{}@{}/15551232000?short_url=yes".format("t" * 20, "a" * 23), { # testing short_url "instance": NotifyMSG91, }, ), ( "msg91://{}@{}/15551232000".format("t" * 20, "a" * 23), { # use get args to acomplish the same thing "instance": NotifyMSG91, }, ), ( "msg91://{}@{}/15551232000".format("t" * 20, "a" * 23), { "instance": NotifyMSG91, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "msg91://{}@{}/15551232000".format("t" * 20, "a" * 23), { "instance": NotifyMSG91, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_msg91_urls(): """NotifyMSG91() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_msg91_edge_cases(mock_post): """NotifyMSG91() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens target = "+1 (555) 123-3456" # No authkey specified with pytest.raises(TypeError): NotifyMSG91(template="1234", authkey=None, targets=target) with pytest.raises(TypeError): NotifyMSG91(template="1234", authkey=" ", targets=target) with pytest.raises(TypeError): NotifyMSG91(template=" ", authkey="a" * 23, targets=target) with pytest.raises(TypeError): NotifyMSG91(template=None, authkey="a" * 23, targets=target) @mock.patch("requests.post") def test_plugin_msg91_keywords(mock_post): """NotifyMSG91() Templating.""" response = mock.Mock() response.content = "" response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response target = "+1 (555) 123-3456" template = "12345" authkey = "{}".format("b" * 32) message_contents = "test" # Variation of initialization without API key obj = Apprise.instantiate( f"msg91://{template}@{authkey}/{target}?:key=value&:mobiles=ignored" ) assert isinstance(obj, NotifyMSG91) assert isinstance(obj.url(), str) # Send Notification assert obj.send(body=message_contents) is True # Validate expected call parameters assert mock_post.call_count == 1 first_call = mock_post.call_args_list[0] # URL and message parameters are the same for both calls assert first_call[0][0] == "https://control.msg91.com/api/v5/flow/" response = loads(first_call[1]["data"]) assert response["template_id"] == template assert response["short_url"] == 0 assert len(response["recipients"]) == 1 # mobiles is not over-ridden as it is a special reserved token assert response["recipients"][0]["mobiles"] == "15551233456" # Our base tokens assert response["recipients"][0]["body"] == message_contents assert response["recipients"][0]["type"] == NotifyType.INFO.value assert response["recipients"][0]["key"] == "value" mock_post.reset_mock() # Play with mapping obj = Apprise.instantiate( f"msg91://{template}@{authkey}/{target}?:body&:type=cat" ) assert isinstance(obj, NotifyMSG91) assert isinstance(obj.url(), str) # Send Notification assert obj.send(body=message_contents) is True # Validate expected call parameters assert mock_post.call_count == 1 first_call = mock_post.call_args_list[0] assert "NotifyType." not in first_call[1]["data"] # URL and message parameters are the same for both calls assert first_call[0][0] == "https://control.msg91.com/api/v5/flow/" response = loads(first_call[1]["data"]) assert response["template_id"] == template assert response["short_url"] == 0 assert len(response["recipients"]) == 1 assert response["recipients"][0]["mobiles"] == "15551233456" assert "body" not in response["recipients"][0] assert "type" not in response["recipients"][0] assert response["recipients"][0]["cat"] == "info" caronc-apprise-182f859/tests/test_plugin_nextcloud.py000066400000000000000000000501671524161175200230630ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import dumps import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, AppriseAsset, NotifyType, PersistentStoreMode from apprise.plugins.nextcloud import NotifyNextcloud NEXTCLOUD_GOOD_RESPONSE = dumps( { "ocs": { "meta": {"status": "ok", "statuscode": 100}, "data": {"users": ["user1", "user2"]}, } } ) logging.disable(logging.CRITICAL) apprise_url_tests = ( ################################## # NotifyNextcloud ################################## ( "ncloud://:@/", { "instance": None, # Our response expected server response "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://", { "instance": None, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "nclouds://", { # No hostname "instance": None, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://localhost", { # No user specified "instance": NotifyNextcloud, # Since there are no targets specified we expect a False return on # send() "notify_response": False, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost?to=user1,user2&version=invalid", { # An invalid version was specified "instance": TypeError, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost?to=user1,user2&version=0", { # An invalid version was specified "instance": TypeError, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost?to=user1,user2&version=-23", { # An invalid version was specified "instance": TypeError, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://localhost/admin", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost/admin", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost?to=user1,user2", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost?to=user1,user2&version=20", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost?to=user1,user2&version=21", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost?to=user1&version=20&url_prefix=/abcd", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user@localhost?to=user1&version=21&url_prefix=/abcd", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user:pass@localhost/user1/user2", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: "privacy_url": "ncloud://user:****@localhost/@user1/@user2", }, ), ( "ncloud://user:pass@localhost/#group1/#group2/#group1", { # Test groups, but also note a duplicate group provided "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, "privacy_url": "ncloud://user:****@localhost/#group", }, ), ( "ncloud://user:pass@localhost:8080/admin", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "nclouds://user:pass@localhost/admin", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: "privacy_url": "nclouds://user:****@localhost/@admin", }, ), ( "nclouds://user:pass@localhost:8080/admin/", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "nclouds://user:pass@localhost:8080/#group/", { "instance": NotifyNextcloud, # Invalid JSON Response "requests_response_text": "{", # We will fail to make the notify() call due to our bad response "notify_response": False, }, ), ( "ncloud://localhost:8080/admin?+HeaderKey=HeaderValue", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, }, ), ( "ncloud://user:pass@localhost:8081/admin", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "ncloud://user:pass@localhost:8082/admin", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "ncloud://user:pass@localhost:8083/user1/user2/user3", { "instance": NotifyNextcloud, "requests_response_text": NEXTCLOUD_GOOD_RESPONSE, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_nextcloud_urls(): """NotifyNextcloud() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_nextcloud_edge_cases(mock_post): """NotifyNextcloud() Edge Cases.""" # A response robj = mock.Mock() robj.content = "" robj.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = robj # Variation Initializations obj = NotifyNextcloud( host="localhost", user="admin", password="pass", targets="user" ) assert isinstance(obj, NotifyNextcloud) is True assert isinstance(obj.url(), str) is True # An empty body assert obj.send(body="") is True assert "data" in mock_post.call_args_list[0][1] assert "shortMessage" in mock_post.call_args_list[0][1]["data"] # The longMessage argument is not set assert "longMessage" not in mock_post.call_args_list[0][1]["data"] @mock.patch("requests.post") def test_plugin_nextcloud_url_prefix(mock_post): """NotifyNextcloud() URL Prefix Testing.""" response = mock.Mock() response.content = "" response.status_code = requests.codes.ok # Prepare our mock object mock_post.return_value = response # instantiate our object (without a batch mode) obj = Apprise.instantiate( "ncloud://localhost/admin/?version=20&url_prefix=/abcd" ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Not set to batch, so we send 2 different messages assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://localhost/abcd/ocs/v2.php/apps/" "admin_notifications/api/v1/notifications/admin" ) @mock.patch("requests.post") @mock.patch("requests.get") def test_plugin_nextcloud_groups_and_all(mock_get, mock_post): """NotifyNextcloud() Group and All user expansion.""" # Mock POST success post_resp = mock.Mock() post_resp.content = "" post_resp.status_code = requests.codes.ok mock_post.return_value = post_resp # Mock GET responses for group and users listing def get_side_effect(url, *args, **kwargs): resp = mock.Mock() if "/ocs/v1.php/cloud/groups/" in url: # Return JSON for group j = { "ocs": { "meta": {"status": "ok", "statuscode": 100}, "data": {"users": ["user1", "user2"]}, } } resp.status_code = requests.codes.ok resp.json = lambda: j resp.content = dumps(j).encode() return resp elif "/ocs/v1.php/cloud/users" in url: j = { "ocs": { "meta": {"status": "ok", "statuscode": 100}, "data": {"users": ["user1", "user3"]}, } } resp.status_code = requests.codes.ok resp.json = lambda: j resp.content = dumps(j).encode() return resp # default resp.status_code = requests.codes.ok resp.content = b"" resp.json = lambda: {} return resp mock_get.side_effect = get_side_effect # Instantiate with a mix of targets: group, all, and direct user obj = NotifyNextcloud( host="localhost", user="admin", password="pass", targets=["#devs", "all", "user4"], ) assert isinstance(obj, NotifyNextcloud) # Send notification assert ( obj.send(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Expected resolved users (deduplicated): user1, user2, user3, user4 # Hence 4 POST calls assert mock_post.call_count == 4 # Validate calls were made to expected endpoints called_urls = [c[0][0] for c in mock_post.call_args_list] for u in ("user1", "user2", "user3", "user4"): assert any(u in url for url in called_urls) @mock.patch("requests.post") @mock.patch("requests.get") def test_plugin_nextcloud_persistent_storage(mock_get, mock_post, tmpdir): """Testing persistent storage""" post_resp = mock.Mock() post_resp.content = "" post_resp.status_code = requests.codes.ok mock_post.return_value = post_resp # Set up persistent storage asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir), ) def get_side_effect(url, *args, **kwargs): resp = mock.Mock() # Default json() to empty resp.json = lambda: {} if "/ocs/v1.php/cloud/groups" in url: resp.status_code = requests.codes.ok payload = {"ocs": {"data": {"users": ["u1"]}}} resp.json = lambda: payload resp.content = dumps(payload).encode() return resp elif "/ocs/v1.php/cloud/users" in url: resp.status_code = requests.codes.ok payload = {"ocs": {"data": {"users": ["u2"]}}} resp.json = lambda: payload resp.content = dumps(payload).encode() return resp resp.status_code = 500 resp.content = b"" return resp mock_get.side_effect = get_side_effect obj = NotifyNextcloud( host="localhost", user="admin", password="pass", targets=["#devs", "all"], asset=asset, ) # We failed to get our list assert ( obj.send(body="body", title="title", notify_type=NotifyType.INFO) is True ) # User and Group looked up assert mock_get.call_count == 2 # Expect users u1 (group) and u2 (all) assert mock_post.call_count == 2 called_urls = [c[0][0] for c in mock_post.call_args_list] for u in ("u1", "u2"): assert any(u in url for url in called_urls) mock_get.reset_mock() mock_post.reset_mock() obj = NotifyNextcloud( host="localhost", user="admin", password="pass", targets=["#devs"], asset=asset, ) # We succeeded this time assert ( obj.send(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Expect users u1 (group) only and pulled from cache assert mock_get.call_count == 0 assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0].endswith("/u1") @mock.patch("requests.post") @mock.patch("requests.get") def test_plugin_nextcloud_groups_errors_and_dedup(mock_get, mock_post): """Non-200/exception paths return empty lists and dedup still applies.""" post_resp = mock.Mock() post_resp.content = "" post_resp.status_code = requests.codes.ok mock_post.return_value = post_resp # Non-200 for group and users; JSON invalid def get_side_effect(url, *args, **kwargs): resp = mock.Mock() resp.status_code = 401 resp.content = b"" # Return empty/invalid JSON to drive empty path resp.json = lambda: {} return resp mock_get.side_effect = get_side_effect # Provide duplicates alongside failing expansions obj = NotifyNextcloud( host="localhost", user="admin", password="pass", targets=["#devs", "all", "user1", "user1", "user2"], ) # We failed to hit the server for data assert obj.send(body="x", title="y", notify_type=NotifyType.INFO) is False # we have no control over the order, but we know that on the first # GET call, we'd have gotten a 401 response; so we'd have stopped from # that point further assert mock_get.call_count == 1 # Nothing notified assert mock_post.call_count == 0 @mock.patch("requests.post") @mock.patch("requests.get") def test_plugin_nextcloud_req_exception_and_empty_targets(mock_get, mock_post): """RequestException returns empty expansion; direct users send.""" post_resp = mock.Mock() post_resp.content = "" post_resp.status_code = requests.codes.ok mock_post.return_value = post_resp def get_side_effect(url, *args, **kwargs): raise requests.RequestException("boom") mock_get.side_effect = get_side_effect obj = NotifyNextcloud( host="localhost", user="admin", password="pass", targets=["", " ", "#DevTeam", "#", "userX"], ) # Our Group inquiry failed to respond assert obj.send(body="x", title="y", notify_type=NotifyType.INFO) is False assert mock_post.call_count == 0 assert mock_get.call_count == 1 assert ( mock_get.call_args_list[0][0][0] == "http://localhost/ocs/v1.php/cloud/groups/DevTeam" ) assert mock_get.call_args_list[0][1]["params"].get("format") == "json" @mock.patch("requests.post") @mock.patch("requests.get") def test_plugin_nextcloud_json_empty_returns_empty(mock_get, mock_post): """Invalid/empty JSON returns empty; direct users still send.""" post_resp = mock.Mock() post_resp.content = "" post_resp.status_code = requests.codes.ok mock_post.return_value = post_resp def get_side_effect(url, *args, **kwargs): resp = mock.Mock() resp.json = lambda: {} resp.status_code = requests.codes.ok resp.content = b"{}" return resp mock_get.side_effect = get_side_effect obj = NotifyNextcloud( host="localhost", user="admin", password="pass", targets=["#broken", "all", "userZ"], ) # Our notification assert obj.send(body="x", title="y", notify_type=NotifyType.INFO) is True # Only direct userZ posts because both expansions return empty assert mock_get.call_count == 2 assert any( "/cloud/users" in call[0][0] for call in mock_get.call_args_list ) assert any( "/cloud/groups/broken" in call[0][0] for call in mock_get.call_args_list ) # userZ would get a notification assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0].endswith("/userZ") @mock.patch("requests.post") @mock.patch("requests.get") def test_plugin_nextcloud_caching_group_and_all(mock_get, mock_post): """Cache hits avoid repeat OCS lookups.""" # First round of GETs return users for group and all def get_side_effect(url, *args, **kwargs): resp = mock.Mock() resp.status_code = requests.codes.ok if "/ocs/v1.php/cloud/groups" in url: j = { "ocs": { "meta": {"status": "ok", "statuscode": 100}, "data": {"users": ["g1", "g2"]}, } } elif "/ocs/v1.php/cloud/users" in url: j = { "ocs": { "meta": {"status": "ok", "statuscode": 100}, "data": {"users": ["a1", "a2"]}, } } else: j = {"ocs": {"data": {"users": []}}} resp.json = lambda: j resp.content = dumps(j).encode() return resp mock_get.side_effect = get_side_effect post_resp = mock.Mock() post_resp.content = "" post_resp.status_code = requests.codes.ok mock_post.return_value = post_resp obj = NotifyNextcloud( host="localhost", user="admin", password="pass", targets=["#devs", "all", "@joe"], ) # First send: resolves via OCS; expect 2 GETs (group + all) assert obj.send(body="b", title="t", notify_type=NotifyType.INFO) is True assert mock_get.call_count == 2 called = "".join(c[0][0] for c in mock_get.call_args_list) assert "/cloud/groups/" in called and "/cloud/users" in called # we sent 5 notifications assert mock_post.call_count == 5 expected_users = {"a1", "a2", "g1", "g2", "joe"} # Extract the user segment from the URL of each call actual_users = { call[0][0].split("/")[-1] for call in mock_post.call_args_list } # Assert that the set of actual users matches the set of expected users assert actual_users == expected_users # Reset our mock object mock_get.reset_mock() mock_post.reset_mock() assert obj.send(body="b2", title="t2", notify_type=NotifyType.INFO) is True # Cached responses were used to get our user information assert mock_get.call_count == 0 assert mock_post.call_count == 5 # We can re-verify our notifications went as expected: actual_users = { call[0][0].split("/")[-1] for call in mock_post.call_args_list } # Assert that the set of actual users matches the set of expected users assert actual_users == expected_users caronc-apprise-182f859/tests/test_plugin_nextcloudtalk.py000066400000000000000000000164131524161175200237330ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyType from apprise.plugins.nextcloudtalk import NotifyNextcloudTalk logging.disable(logging.CRITICAL) apprise_url_tests = ( ################################## # NotifyNextcloudTalk ################################## ( "nctalk://:@/", { "instance": None, }, ), ( "nctalk://", { "instance": None, }, ), ( "nctalks://", { # No hostname "instance": None, }, ), ( "nctalk://localhost", { # No user and password and roomid specified "instance": TypeError, }, ), ( "nctalk://localhost/roomid", { # No user and password specified "instance": TypeError, }, ), ( "nctalk://user@localhost/roomid", { # No password specified "instance": TypeError, }, ), ( "nctalk://user:pass@localhost", { # No roomid specified "instance": NotifyNextcloudTalk, # Since there are no targets specified we expect a False return on # send() "notify_response": False, }, ), ( "nctalk://user:pass@localhost/roomid1/roomid2", { "instance": NotifyNextcloudTalk, "requests_response_code": requests.codes.created, # Our expected url(privacy=True) startswith() response: "privacy_url": "nctalk://user:****@localhost/roomid1/roomid2", }, ), ( "nctalk://user:pass@localhost:8080/roomid", { "instance": NotifyNextcloudTalk, "requests_response_code": requests.codes.created, }, ), ( "nctalk://user:pass@localhost:8080/roomid?url_prefix=/prefix", { "instance": NotifyNextcloudTalk, "requests_response_code": requests.codes.created, }, ), ( "nctalks://user:pass@localhost/roomid", { "instance": NotifyNextcloudTalk, "requests_response_code": requests.codes.created, # Our expected url(privacy=True) startswith() response: "privacy_url": "nctalks://user:****@localhost/roomid", }, ), ( "nctalks://user:pass@localhost:8080/roomid/", { "instance": NotifyNextcloudTalk, "requests_response_code": requests.codes.created, }, ), ( "nctalk://user:pass@localhost:8080/roomid?+HeaderKey=HeaderValue", { "instance": NotifyNextcloudTalk, "requests_response_code": requests.codes.created, }, ), ( "nctalk://user:pass@localhost:8081/roomid", { "instance": NotifyNextcloudTalk, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "nctalk://user:pass@localhost:8082/roomid", { "instance": NotifyNextcloudTalk, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "nctalk://user:pass@localhost:8083/roomid1/roomid2/roomid3", { "instance": NotifyNextcloudTalk, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_nextcloudtalk_urls(): """NotifyNextcloudTalk() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_nextcloudtalk_edge_cases(mock_post): """NotifyNextcloudTalk() Edge Cases.""" # A response robj = mock.Mock() robj.content = "" robj.status_code = requests.codes.created # Prepare Mock mock_post.return_value = robj # Variation Initializations obj = NotifyNextcloudTalk( host="localhost", user="admin", password="pass", targets="roomid" ) assert isinstance(obj, NotifyNextcloudTalk) is True assert isinstance(obj.url(), str) is True # An empty body assert obj.send(body="") is True assert "data" in mock_post.call_args_list[0][1] assert "message" in mock_post.call_args_list[0][1]["data"] @mock.patch("requests.post") def test_plugin_nextcloud_talk_url_prefix(mock_post): """NotifyNextcloudTalk() URL Prefix Testing.""" response = mock.Mock() response.content = "" response.status_code = requests.codes.created # Prepare our mock object mock_post.return_value = response # instantiate our object (without a batch mode) obj = Apprise.instantiate( "nctalk://user:pass@localhost/admin/?url_prefix=/abcd" ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Not set to batch, so we send 2 different messages assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://localhost/abcd/ocs/v2.php/apps/spreed/api/v1/chat/admin" ) mock_post.reset_mock() # instantiate our object (without a batch mode) obj = Apprise.instantiate( "nctalk://user:pass@localhost/admin/?url_prefix=a/longer/path/abcd/" ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Not set to batch, so we send 2 different messages assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://localhost/a/longer/path/abcd/" "ocs/v2.php/apps/spreed/api/v1/chat/admin" ) caronc-apprise-182f859/tests/test_plugin_notica.py000066400000000000000000000131471524161175200223300ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.notica import NotifyNotica logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "notica://", { "instance": TypeError, }, ), ( "notica://:@/", { "instance": TypeError, }, ), # Native URL ( "https://notica.us/?%s" % ("z" * 6), { "instance": NotifyNotica, # Our expected url(privacy=True) startswith() response: "privacy_url": "notica://z...z/", }, ), # Native URL with additional arguments ( "https://notica.us/?%s&overflow=upstream" % ("z" * 6), { "instance": NotifyNotica, # Our expected url(privacy=True) startswith() response: "privacy_url": "notica://z...z/", }, ), # Token specified ( "notica://%s" % ("a" * 6), { "instance": NotifyNotica, # Our expected url(privacy=True) startswith() response: "privacy_url": "notica://a...a/", }, ), # Self-Hosted configuration ( "notica://localhost/%s" % ("b" * 6), { "instance": NotifyNotica, }, ), ( "notica://user@localhost/%s" % ("c" * 6), { "instance": NotifyNotica, }, ), ( "notica://user:pass@localhost/%s/" % ("d" * 6), { "instance": NotifyNotica, # Our expected url(privacy=True) startswith() response: "privacy_url": "notica://user:****@localhost/d...d", }, ), ( "notica://user:pass@localhost/a/path/%s/" % ("r" * 6), { "instance": NotifyNotica, # Our expected url(privacy=True) startswith() response: "privacy_url": "notica://user:****@localhost/a/path/r...r", }, ), ( "notica://localhost:8080/%s" % ("a" * 6), { "instance": NotifyNotica, }, ), ( "notica://user:pass@localhost:8080/%s" % ("b" * 6), { "instance": NotifyNotica, }, ), ( "noticas://localhost/%s" % ("j" * 6), { "instance": NotifyNotica, "privacy_url": "noticas://localhost/j...j", }, ), ( "noticas://user:pass@localhost/%s" % ("e" * 6), { "instance": NotifyNotica, # Our expected url(privacy=True) startswith() response: "privacy_url": "noticas://user:****@localhost/e...e", }, ), ( "noticas://localhost:8080/path/%s" % ("5" * 6), { "instance": NotifyNotica, "privacy_url": "noticas://localhost:8080/path/5...5", }, ), ( "noticas://user:pass@localhost:8080/%s" % ("6" * 6), { "instance": NotifyNotica, }, ), ( "notica://%s" % ("b" * 6), { "instance": NotifyNotica, # don't include an image by default "include_image": False, }, ), # Test Header overrides ( "notica://localhost:8080//%s/?+HeaderKey=HeaderValue" % ("7" * 6), { "instance": NotifyNotica, }, ), ( "notica://%s" % ("c" * 6), { "instance": NotifyNotica, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "notica://%s" % ("d" * 7), { "instance": NotifyNotica, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "notica://%s" % ("e" * 8), { "instance": NotifyNotica, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_notica_urls(): """NotifyNotica() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_notifiarr.py000066400000000000000000000215431524161175200230470ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import NotifyType from apprise.plugins.notifiarr import NotifyNotifiarr logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "notifiarr://:@/", { "instance": TypeError, }, ), ( "notifiarr://", { "instance": TypeError, }, ), ( "notifiarr://apikey", { "instance": NotifyNotifiarr, # Response will fail due to no targets defined "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://a...y", }, ), ( "notifiarr://apikey/1234/?event=invalid", { "instance": TypeError, }, ), ( "notifiarr://apikey/%%invalid%%", { "instance": NotifyNotifiarr, # Response will fail due to no targets defined "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://a...y", }, ), ( "notifiarr://apikey/#123", { "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://a...y/#123", }, ), ( "notifiarr://apikey/123?image=No", { "instance": NotifyNotifiarr, }, ), ( "notifiarr://apikey/123?image=yes", { "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://a...y/#123", }, ), ( "notifiarr://apikey/?to=123,432", { "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://a...y/#123/#432", }, ), ( "notifiarr://apikey/?to=123,432&event=1234", { # Test event "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://a...y/#123/#432", }, ), ( "notifiarr://123/?apikey=myapikey", { "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://m...y/#123", }, ), ( "notifiarr://123/?key=myapikey", { # Support key= "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://m...y/#123", }, ), ( "notifiarr://123/?apikey=myapikey&image=yes", { "instance": NotifyNotifiarr, }, ), ( "notifiarr://123/?apikey=myapikey&image=no", { "instance": NotifyNotifiarr, }, ), ( "notifiarr://123/?apikey=myapikey&source=My%20System", { "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://m...y/#123", }, ), ( "notifiarr://123/?apikey=myapikey&from=My%20System", { "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://m...y/#123", }, ), ( "notifiarr://?apikey=myapikey", { # No Channel or host "instance": NotifyNotifiarr, # Response will fail due to no targets defined "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://m...y/", }, ), ( "notifiarr://invalid?apikey=myapikey", { # No Channel or host "instance": NotifyNotifiarr, # invalid channel "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://m...y/", }, ), ( "notifiarr://123/325/?apikey=myapikey", { "instance": NotifyNotifiarr, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifiarr://m...y/#123/#325", }, ), ( "notifiarr://apikey/123/", { "instance": NotifyNotifiarr, }, ), ( "notifiarr://apikey/123", { "instance": NotifyNotifiarr, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "notifiarr://apikey/123", { "instance": NotifyNotifiarr, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "notifiarr://apikey/123", { "instance": NotifyNotifiarr, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_notifiarr_urls(): """NotifyNotifiarr() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_notifiarr_notifications(mock_post): """NotifyNotifiarr() Notifications/Ping Support.""" # Test our header parsing when not lead with a header body = cleandoc(""" # Heading @everyone and @admin, wake and meet our new user <@123> and <@987>; Attention Roles: <@&456> and <@&765> """) # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok # Prepare Mock return object mock_post.return_value = response results = NotifyNotifiarr.parse_url("notifiarr://apikey/12345") instance = NotifyNotifiarr(**results) assert isinstance(instance, NotifyNotifiarr) response = instance.send(body=body) assert response is True assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert "NotifyType." not in details[1]["data"] assert details[0][0] == "https://notifiarr.com/api/v1/notification/apprise" payload = loads(details[1]["data"]) # First role and first user stored assert payload == { "source": "Apprise", "type": NotifyType.INFO.value, "notification": {"update": False, "name": "Apprise", "event": ""}, "discord": { "color": "#3AA3E3", "ping": { # Only supports 1 entry each; so first one is parsed "pingUser": "123", "pingRole": "456", }, "text": { "title": "", "content": "πŸ‘‰ @everyone @admin <@123> <@987> <@&456> <@&765>", "description": ( "# Heading\n@everyone and @admin, wake and meet our new " "user <@123> and <@987>;\nAttention Roles: <@&456> and " "<@&765>\n " ), "footer": "Apprise Notifications", }, "ids": {"channel": 12345}, }, } caronc-apprise-182f859/tests/test_plugin_notifico.py000066400000000000000000000404511524161175200226630ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from urllib.parse import urlparse from helpers import AppriseURLTester import pytest import requests from apprise.common import NotifyType from apprise.plugins.notifico import NotificoMode, NotifyNotifico logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "notifico://", { "instance": TypeError, }, ), ( "notifico://:@/", { "instance": TypeError, }, ), ( "notifico://1234", { # Just a project id provided (no message token) "instance": TypeError, }, ), ( "notifico://abcd/ckhrjW8w672m6HG", { # an invalid project id provided (not all digits) "instance": TypeError, }, ), ( "notifico://1234/ckhrjW8w672m6HG", { # A project id and message hook provided (official mode) "instance": NotifyNotifico, }, ), ( "notifico://1234/ckhrjW8w672m6HG?prefix=no", { # Disable our prefix "instance": NotifyNotifico, }, ), ( "notifico://1234/ckhrjW8w672m6HG?color=yes", { "instance": NotifyNotifico, "notify_type": "info", }, ), ( "notifico://1234/ckhrjW8w672m6HG?color=yes", { "instance": NotifyNotifico, "notify_type": "success", }, ), ( "notifico://1234/ckhrjW8w672m6HG?color=yes", { "instance": NotifyNotifico, "notify_type": "warning", }, ), ( "notifico://1234/ckhrjW8w672m6HG?color=yes", { "instance": NotifyNotifico, "notify_type": "failure", }, ), ( "notifico://1234/ckhrjW8w672m6HG?color=yes", { "instance": NotifyNotifico, "notify_type": "invalid", }, ), ( "notifico://1234/ckhrjW8w672m6HG?color=no", { # Test our color flag by having it set to off "instance": NotifyNotifico, # Our expected url(privacy=True) startswith() response: "privacy_url": "notifico://1...4/c...G", }, ), # Native URL (official endpoint) ( "https://n.tkte.ch/h/2144/uJmKaBW9WFk42miB146ci3Kj", { "instance": NotifyNotifico, }, ), # Self-hosted HTTP ( "notifico://example.com/1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, }, ), # Self-hosted HTTPS ( "notificos://example.com/1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, "privacy_url": "notificos://example.com/1...4/c...G", }, ), # Self-hosted with port and color ( "notifico://user@example.com:20/1234/ckhrjW8w672m6HG?color=yes", { "instance": NotifyNotifico, "notify_type": "info", }, ), # Self-hosted HTTPS with auth ( "notificos://user:pass@example.com/1234/ckhrjW8w672m6HG?color=yes", { "instance": NotifyNotifico, "notify_type": "success", "privacy_url": "notificos://user:****@example.com/1...4/c...G", }, ), # Self-hosted: project and token supplied as query params ( "notificos://user:pass@example.com/" "?project=1234&token=ckhrjW8w672m6HG&color=yes", { "instance": NotifyNotifico, "notify_type": "success", "privacy_url": "notificos://user:****@example.com/1...4/c...G", }, ), # Self-hosted: invalid project_id in path ( "notifico://example.com/abcd/ckhrjW8w672m6HG", { "instance": TypeError, }, ), ( "notifico://1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, # don't include an image by default "include_image": False, }, ), # Official: HTTP error responses ( "notifico://1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "notifico://1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "notifico://1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, # Throws a series of i/o exceptions with this flag set # and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # Self-hosted: HTTP error responses ( "notifico://example.com/1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "notifico://example.com/1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, "response": False, "requests_response_code": 999, }, ), ( "notifico://example.com/1234/ckhrjW8w672m6HG", { "instance": NotifyNotifico, "test_requests_exceptions": True, }, ), ) def test_plugin_notifico_urls(): """NotifyNotifico() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") def test_plugin_notifico_official_mode(mock_get): """NotifyNotifico() official-mode init and round-trip.""" # Prepare a successful response response = mock.Mock() response.status_code = requests.codes.ok mock_get.return_value = response # Valid official-mode instance obj = NotifyNotifico( project_id="2144", msghook="uJmKaBW9WFk42miB146ci3Kj", ) assert obj.mode == NotificoMode.OFFICIAL assert not obj.host # url() round-trip preserves identity url = obj.url() assert "2144" in url assert "uJmKaBW9WFk42miB146ci3Kj" in url result = NotifyNotifico.parse_url(url) assert result is not None obj2 = NotifyNotifico(**result) assert obj2.url_identifier == obj.url_identifier # send() succeeds and hits the official n.tkte.ch endpoint assert obj.send(body="test") is True assert mock_get.call_count == 1 call_url = mock_get.call_args[0][0] assert "n.tkte.ch" in call_url assert "2144" in call_url assert "uJmKaBW9WFk42miB146ci3Kj" in call_url @mock.patch("requests.get") def test_plugin_notifico_selfhosted_http(mock_get): """NotifyNotifico() self-hosted HTTP mode.""" response = mock.Mock() response.status_code = requests.codes.ok mock_get.return_value = response obj = NotifyNotifico( project_id="1234", msghook="ckhrjW8w672m6HG", host="myhost.local", ) assert obj.mode == NotificoMode.SELFHOSTED assert obj.host == "myhost.local" assert obj.secure is False # url() encodes the hostname url = obj.url() assert url.startswith("notifico://myhost.local/1234/ckhrjW8w672m6HG") # Round-trip preserves identity result = NotifyNotifico.parse_url(url) assert result is not None obj2 = NotifyNotifico(**result) assert obj2.url_identifier == obj.url_identifier # send() uses HTTP against the self-hosted host assert obj.send(body="test") is True call_url = mock_get.call_args[0][0] parsed = urlparse(call_url) assert parsed.scheme == "http" assert parsed.hostname == "myhost.local" assert "1234" in call_url assert "ckhrjW8w672m6HG" in call_url @mock.patch("requests.get") def test_plugin_notifico_selfhosted_https(mock_get): """NotifyNotifico() self-hosted HTTPS mode with auth and port.""" response = mock.Mock() response.status_code = requests.codes.ok mock_get.return_value = response # Parse a notificos:// URL with credentials and non-default port result = NotifyNotifico.parse_url( "notificos://user:secret@myhost.local:8443/9999/AbCdEfGh" ) assert result is not None obj = NotifyNotifico(**result) assert obj.mode == NotificoMode.SELFHOSTED assert obj.secure is True assert obj.host == "myhost.local" assert obj.port == 8443 assert obj.user == "user" # url() emits notificos:// with the non-default port url = obj.url() assert url.startswith("notificos://user:") assert "myhost.local:8443" in url assert "9999" in url # Privacy URL hides the password privacy = obj.url(privacy=True) assert "****" in privacy # send() uses HTTPS against the self-hosted host assert obj.send(body="test") is True call_url = mock_get.call_args[0][0] parsed = urlparse(call_url) assert parsed.scheme == "https" assert parsed.hostname == "myhost.local" assert parsed.port == 8443 assert "9999" in call_url assert "AbCdEfGh" in call_url @mock.patch("requests.get") def test_plugin_notifico_invalid_params(mock_get): """NotifyNotifico() invalid init parameters raise TypeError.""" # Invalid project_id (not all digits) with pytest.raises(TypeError): NotifyNotifico(project_id="abc", msghook="validhook") # Invalid msghook (contains special characters) with pytest.raises(TypeError): NotifyNotifico(project_id="1234", msghook="bad hook!") # None project_id with pytest.raises(TypeError): NotifyNotifico(project_id=None, msghook="validhook") # None msghook with pytest.raises(TypeError): NotifyNotifico(project_id="1234", msghook=None) # No HTTP calls should have been made assert mock_get.call_count == 0 @mock.patch("requests.get") def test_plugin_notifico_parse_url(mock_get): """NotifyNotifico() parse_url edge cases.""" # Official mode: numeric host is the project_id result = NotifyNotifico.parse_url("notifico://1234/hookABC") assert result is not None assert result["project_id"] == "1234" assert result["msghook"] == "hookABC" assert not result["host"] # Official mode must clear port/user/password even when present # in the URL (e.g. notifico://user:pass@1234:8080/hook); # these fields have no meaning for the official n.tkte.ch endpoint result = NotifyNotifico.parse_url("notifico://user:pass@1234:8080/hookABC") assert result is not None assert result["project_id"] == "1234" assert result["msghook"] == "hookABC" assert not result["host"] assert result["port"] is None assert result["user"] is None assert result["password"] is None # Self-hosted mode: hostname in host position result = NotifyNotifico.parse_url("notifico://myhost.example/5678/hookXYZ") assert result is not None assert result["project_id"] == "5678" assert result["msghook"] == "hookXYZ" assert result["host"] == "myhost.example" # Query-param aliases override path-based values result = NotifyNotifico.parse_url( "notificos://example.com/?project=9999&token=hookQQQ" ) assert result is not None assert result["project_id"] == "9999" assert result["msghook"] == "hookQQQ" # Color and prefix default to True result = NotifyNotifico.parse_url("notifico://1234/hook") assert result["color"] is True assert result["prefix"] is True # Explicit color=no and prefix=no result = NotifyNotifico.parse_url( "notifico://1234/hook?color=no&prefix=no" ) assert result["color"] is False assert result["prefix"] is False # parse_url returns None only when parse_url itself cannot parse the URL assert NotifyNotifico.parse_url(None) is None assert mock_get.call_count == 0 @mock.patch("requests.get") def test_plugin_notifico_send_http_errors(mock_get): """NotifyNotifico() handles HTTP error responses gracefully.""" obj = NotifyNotifico(project_id="1234", msghook="validHook") # HTTP 500 response = mock.Mock() response.status_code = requests.codes.internal_server_error response.content = b"" mock_get.return_value = response assert obj.send(body="test") is False # Unrecognised HTTP status response.status_code = 999 assert obj.send(body="test") is False # Network exception mock_get.side_effect = requests.RequestException("connection refused") assert obj.send(body="test") is False @mock.patch("requests.get") def test_plugin_notifico_send_notify_types(mock_get): """NotifyNotifico() sends all four notification types successfully.""" response = mock.Mock() response.status_code = requests.codes.ok mock_get.return_value = response obj = NotifyNotifico( project_id="1234", msghook="validHook", color=True, prefix=True, ) for notify_type in ( NotifyType.INFO, NotifyType.SUCCESS, NotifyType.WARNING, NotifyType.FAILURE, ): mock_get.reset_mock() assert obj.send(body="msg", notify_type=notify_type) is True assert mock_get.call_count == 1 @mock.patch("requests.get") def test_plugin_notifico_color_prefix_flags(mock_get): """NotifyNotifico() color=False strips IRC codes; prefix=False omits it.""" response = mock.Mock() response.status_code = requests.codes.ok mock_get.return_value = response # color=False, prefix=False -- payload should be the raw body obj = NotifyNotifico( project_id="1234", msghook="validHook", color=False, prefix=False, ) assert obj.send(body="plain text") is True payload = mock_get.call_args[1]["params"]["payload"] assert payload == "plain text" # color=True, prefix=True -- payload contains the prefix bracket mock_get.reset_mock() obj2 = NotifyNotifico( project_id="1234", msghook="validHook", color=True, prefix=True, ) assert obj2.send(body="colored text", notify_type=NotifyType.INFO) is True payload2 = mock_get.call_args[1]["params"]["payload"] assert "colored text" in payload2 assert "[" in payload2 @mock.patch("requests.get") def test_plugin_notifico_native_url(mock_get): """NotifyNotifico() parse_native_url() parses n.tkte.ch URLs.""" response = mock.Mock() response.status_code = requests.codes.ok mock_get.return_value = response # Standard native URL result = NotifyNotifico.parse_native_url( "https://n.tkte.ch/h/2144/uJmKaBW9WFk42miB146ci3Kj" ) assert result is not None obj = NotifyNotifico(**result) assert obj.mode == NotificoMode.OFFICIAL assert obj.project_id == "2144" assert obj.msghook == "uJmKaBW9WFk42miB146ci3Kj" assert obj.send(body="test") is True # Non-matching URL returns None assert ( NotifyNotifico.parse_native_url("https://other.host/h/1/abc") is None ) caronc-apprise-182f859/tests/test_plugin_notifyre.py000066400000000000000000000647601524161175200227210ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import json import logging import os from unittest import mock from urllib.parse import urlparse from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.notifyre import NotifyNotifyre, NotifyreMode logging.disable(logging.CRITICAL) # Attachment directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # A valid API key used across test cases API_KEY = "abcdef1234567890" # Successful API response GOOD_RESPONSE = { "success": True, "statusCode": 200, "payload": {"smsMessageID": "msg-001", "friendlyID": "ABC-001"}, "message": "OK", "errors": [], } # Failure API response (HTTP 200 but success=false) FAIL_RESPONSE = { "success": False, "statusCode": 400, "payload": None, "message": "Invalid recipient", "errors": ["recipient format error"], } # Our Testing URLs apprise_url_tests = ( ( "notifyre://", { # No API key "instance": TypeError, }, ), ( "notifyre://:@/", { # Empty credentials "instance": TypeError, }, ), ( "notifyre://{}".format(API_KEY), { # No targets -- loads but notify returns False "instance": NotifyNotifyre, "notify_response": False, }, ), ( "notifyre://{}/+15551234567".format(API_KEY), { # Valid SMS notification "instance": NotifyNotifyre, "privacy_url": "notifyre://a...0/+15551234567/?mode=sms", }, ), ( "notifyre://{}/+15551234567/+15559876543".format(API_KEY), { # Multiple targets "instance": NotifyNotifyre, "privacy_url": ( "notifyre://a...0/+15551234567/+15559876543/?mode=sms" ), }, ), ( "notifyre://{}/+15551234567?mode=sms".format(API_KEY), { # Explicit SMS mode "instance": NotifyNotifyre, }, ), ( "notifyre://{}/+15551234567?mode=fax".format(API_KEY), { # Fax mode "instance": NotifyNotifyre, "privacy_url": "notifyre://a...0/+15551234567/?mode=fax", }, ), ( "notifyre://{}/+15551234567?mode=fax&from=+15559876543".format( API_KEY ), { # Fax mode with source number "instance": NotifyNotifyre, }, ), ( "notifyre://{}/+15551234567?mode=invalid".format(API_KEY), { # Invalid mode "instance": TypeError, }, ), ( "notifyre://{}/+15551234567".format(API_KEY), { # HTTP 500 failure "instance": NotifyNotifyre, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "notifyre://{}/+15551234567".format(API_KEY), { # Non-standard HTTP error code "instance": NotifyNotifyre, "response": False, "requests_response_code": 999, }, ), ( "notifyre://{}/+15551234567".format(API_KEY), { # RequestException "instance": NotifyNotifyre, "test_requests_exceptions": True, }, ), ( "notifyre://{}/+15551234567?mode=fax".format(API_KEY), { # Fax -- HTTP 500 failure "instance": NotifyNotifyre, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "notifyre://{}/+15551234567?mode=fax".format(API_KEY), { # Fax -- non-standard HTTP error code "instance": NotifyNotifyre, "response": False, "requests_response_code": 999, }, ), ( "notifyre://{}/+15551234567?mode=fax".format(API_KEY), { # Fax -- RequestException "instance": NotifyNotifyre, "test_requests_exceptions": True, }, ), ( "notifyre://{}/+15551234567?campaign=MyCampaign".format(API_KEY), { # SMS with custom campaign name "instance": NotifyNotifyre, }, ), ( "notifyre://{}/+15551234567?mode=fax&template=MyTpl".format(API_KEY), { # Fax with template name "instance": NotifyNotifyre, }, ), ( ( "notifyre://{}/+15551234567?mode=fax&hq=no&ref=CR123&header=CONF" ).format(API_KEY), { # Fax with hq=no, client ref, and header "instance": NotifyNotifyre, }, ), ) def test_plugin_notifyre_urls(mock_post): """AppriseURLTester coverage for all URL entries.""" AppriseURLTester(tests=apprise_url_tests).run_all() test_plugin_notifyre_urls = mock.patch("requests.post")( test_plugin_notifyre_urls ) def test_plugin_notifyre_init(): """Initialization and validation coverage.""" # Missing API key with pytest.raises(TypeError): NotifyNotifyre(apikey=None, targets=["+15551234567"]) # Empty API key with pytest.raises(TypeError): NotifyNotifyre(apikey="", targets=["+15551234567"]) # Invalid mode with pytest.raises(TypeError): NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], mode="invalid" ) # Invalid source phone number with pytest.raises(TypeError): NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], source="not-a-phone" ) # Valid initialization -- SMS (default) obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"]) assert obj.mode == NotifyreMode.SMS assert obj.source is None assert len(obj.targets) == 1 assert obj.body_maxlen == 160 # New parameter defaults assert obj.campaign == obj.app_id assert obj.template == "" assert obj.ref == "" assert obj.hq is True assert obj.header == "" # Custom campaign name obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], campaign="MyCampaign" ) assert obj.campaign == "MyCampaign" # Empty/whitespace campaign falls back to app_id obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], campaign=" " ) assert obj.campaign == obj.app_id # Fax-specific parameters obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], mode="fax", template="MyTemplate", ref="CR-001", hq=False, header="CONFIDENTIAL", ) assert obj.template == "MyTemplate" assert obj.ref == "CR-001" assert obj.hq is False assert obj.header == "CONFIDENTIAL" # hq parsed from string "no" obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], mode="fax", hq="no" ) assert obj.hq is False # hq parsed from string "yes" obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], mode="fax", hq="yes" ) assert obj.hq is True # Valid initialization -- explicit SMS mode obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"], mode="sms") assert obj.mode == NotifyreMode.SMS assert obj.body_maxlen == 160 # Valid initialization -- fax mode obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"], mode="fax") assert obj.mode == NotifyreMode.FAX assert obj.body_maxlen == 32768 # Valid initialization -- fax with source obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], mode="fax", source="+15559876543", ) assert obj.source == "+15559876543" # Invalid phone targets are silently dropped obj = NotifyNotifyre(apikey=API_KEY, targets=["not-valid", "+15551234567"]) assert len(obj.targets) == 1 # No valid targets -- plugin loads but send() will fail obj = NotifyNotifyre(apikey=API_KEY, targets=["not-valid"]) assert len(obj.targets) == 0 @mock.patch("requests.post") def test_plugin_notifyre_sms_send(mock_post): """SMS send success and failure paths.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = json.dumps(d).encode() return r # Successful send mock_post.return_value = _mk_resp(GOOD_RESPONSE) obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"]) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is True assert mock_post.call_count == 1 # Verify the correct endpoint was used call_url = mock_post.call_args[0][0] assert urlparse(call_url).hostname == "api.notifyre.com" # HTTP error response mock_post.reset_mock() mock_post.return_value = _mk_resp({}, code=requests.codes.bad_request) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False # Unknown HTTP error mock_post.reset_mock() mock_post.return_value = _mk_resp({}, code=999) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False # API-level failure (success=false on HTTP 200) mock_post.reset_mock() mock_post.return_value = _mk_resp(FAIL_RESPONSE) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False # Unparseable response body on HTTP 200 is treated as success mock_post.reset_mock() r = mock.Mock() r.status_code = requests.codes.ok r.content = b"not-json" mock_post.return_value = r assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is True # RequestException mock_post.reset_mock() mock_post.side_effect = requests.RequestException("connection refused") assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False @mock.patch("requests.post") def test_plugin_notifyre_sms_no_targets(mock_post): """SMS send with no valid targets returns False without HTTP call.""" obj = NotifyNotifyre(apikey=API_KEY, targets=["not-valid"]) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_notifyre_sms_with_source(mock_post): """SMS send includes the source (from) number in the payload.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = json.dumps(d).encode() return r mock_post.return_value = _mk_resp(GOOD_RESPONSE) obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], source="+15559876543", ) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is True # Verify 'from' was included in the posted payload posted = json.loads(mock_post.call_args[1]["data"]) assert posted["from"] == "+15559876543" @mock.patch("requests.post") def test_plugin_notifyre_sms_attachment_warning(mock_post): """SMS mode warns when attachments are provided but still sends.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = json.dumps(d).encode() return r mock_post.return_value = _mk_resp(GOOD_RESPONSE) obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"]) attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) # Should warn but still succeed (attachments are ignored in SMS mode) assert ( obj.notify( body="Hello", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_notifyre_fax_send(mock_post): """Fax send success and failure paths.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = json.dumps(d).encode() return r # Successful send (body text as only document) mock_post.return_value = _mk_resp(GOOD_RESPONSE) obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"], mode="fax") assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is True assert mock_post.call_count == 1 call_url = mock_post.call_args[0][0] assert urlparse(call_url).hostname == "api.notifyre.com" # HTTP error mock_post.reset_mock() mock_post.return_value = _mk_resp({}, code=requests.codes.bad_request) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False # Unknown HTTP error mock_post.reset_mock() mock_post.return_value = _mk_resp({}, code=999) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False # API-level failure (success=false on HTTP 200) mock_post.reset_mock() mock_post.return_value = _mk_resp(FAIL_RESPONSE) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False # Unparseable JSON on HTTP 200 -- treated as success mock_post.reset_mock() r = mock.Mock() r.status_code = requests.codes.ok r.content = b"not-json" mock_post.return_value = r assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is True # RequestException mock_post.reset_mock() mock_post.side_effect = requests.RequestException("timeout") assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is False @mock.patch("requests.post") def test_plugin_notifyre_fax_attachments(mock_post): """Fax attachment handling -- all guard branches.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = json.dumps(d).encode() return r obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"], mode="fax") # Successful send with a valid attachment mock_post.return_value = _mk_resp(GOOD_RESPONSE) attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert ( obj.notify( body="Fax body", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 1 # Inaccessible file -- Guard 1 mock_post.reset_mock() attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) with mock.patch("os.path.isfile", return_value=False): assert ( obj.notify( body="Fax body", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 0 # OSError on open -- Guard 2 mock_post.reset_mock() attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) with mock.patch("builtins.open", side_effect=OSError("perm denied")): assert ( obj.notify( body="Fax body", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 0 # OSError on read -- handle opened but read fails mock_post.reset_mock() attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) mock_fh = mock.MagicMock() mock_fh.__enter__ = mock.Mock(return_value=mock_fh) mock_fh.__exit__ = mock.Mock(return_value=False) mock_fh.read.side_effect = OSError("read error") with mock.patch("builtins.open", return_value=mock_fh): assert ( obj.notify( body="Fax body", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 0 # HTTP error on the attachment POST mock_post.reset_mock() mock_post.side_effect = None mock_post.return_value = _mk_resp({}, code=requests.codes.bad_request) attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert ( obj.notify( body="Fax body", notify_type=NotifyType.INFO, attach=attach, ) is False ) # RequestException on the attachment POST mock_post.reset_mock() mock_post.side_effect = requests.RequestException("timeout") attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert ( obj.notify( body="Fax body", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Multiple attachments -- partial failure closes opened handles mock_post.reset_mock() mock_post.side_effect = None mock_post.return_value = _mk_resp(GOOD_RESPONSE) attach = AppriseAttachment() attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.jpeg")) assert ( obj.notify( body="Multi-page fax", notify_type=NotifyType.INFO, attach=attach, ) is True ) @mock.patch("requests.post") def test_plugin_notifyre_fax_no_content(mock_post): """Fax with empty body and no attachments returns False.""" obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"], mode="fax") # Simulate empty body (post title-merge the framework would send "") result = obj._send_fax("") assert result is False assert mock_post.call_count == 0 def test_plugin_notifyre_body_maxlen(): """body_maxlen property returns the correct limit per mode.""" obj_sms = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"]) assert obj_sms.body_maxlen == 160 obj_fax = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], mode="fax" ) assert obj_fax.body_maxlen == 32768 def test_plugin_notifyre_url_round_trip(): """url() -> parse_url() -> re-instantiate produces an equivalent object.""" # SMS mode obj1 = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"]) result = NotifyNotifyre.parse_url(obj1.url()) obj2 = NotifyNotifyre(**result) assert obj1.url_identifier == obj2.url_identifier assert len(obj1.targets) == len(obj2.targets) # Fax mode with source obj1 = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567", "+15559876543"], mode="fax", source="+15550000001", ) result = NotifyNotifyre.parse_url(obj1.url()) obj2 = NotifyNotifyre(**result) assert obj1.url_identifier == obj2.url_identifier assert len(obj1.targets) == len(obj2.targets) # Fax mode with all optional parameters obj1 = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], mode="fax", campaign="MyCampaign", template="MyTemplate", ref="CR-001", hq=False, header="CONFIDENTIAL", ) result = NotifyNotifyre.parse_url(obj1.url()) obj2 = NotifyNotifyre(**result) assert obj2.campaign == "MyCampaign" assert obj2.template == "MyTemplate" assert obj2.ref == "CR-001" assert obj2.hq is False assert obj2.header == "CONFIDENTIAL" # SMS mode with custom campaign -- campaign appears in URL obj1 = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], campaign="SmsCampaign", ) url = obj1.url() assert "campaign=SmsCampaign" in url result = NotifyNotifyre.parse_url(url) obj2 = NotifyNotifyre(**result) assert obj2.campaign == "SmsCampaign" # Default campaign (== app_id) is NOT included in the URL obj1 = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"]) assert "campaign" not in obj1.url() def test_plugin_notifyre_url_parsing(): """parse_url() handles all supported query-parameter forms.""" # Basic SMS URL result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567".format(API_KEY) ) assert result["apikey"] == API_KEY assert "+15551234567" in result["targets"] # ?to= parameter appends additional targets result = NotifyNotifyre.parse_url( "notifyre://{}/?to=+15551234567".format(API_KEY) ) assert "+15551234567" in result["targets"] # ?mode=fax result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567?mode=fax".format(API_KEY) ) assert result["mode"] == "fax" # ?from= source number result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567?from=%2B15559876543".format(API_KEY) ) assert result["source"] == "+15559876543" # ?campaign= custom campaign name result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567?campaign=MyCampaign".format(API_KEY) ) assert result["campaign"] == "MyCampaign" # ?template= fax template name result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567?template=MyTpl".format(API_KEY) ) assert result["template"] == "MyTpl" # ?ref= client reference result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567?ref=CR-001".format(API_KEY) ) assert result["ref"] == "CR-001" # ?hq=no -> False result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567?hq=no".format(API_KEY) ) assert result["hq"] is False # ?hq=yes -> True result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567?hq=yes".format(API_KEY) ) assert result["hq"] is True # ?header= fax cover page header result = NotifyNotifyre.parse_url( "notifyre://{}/+15551234567?header=CONF".format(API_KEY) ) assert result["header"] == "CONF" # A URL with a different schema is not matched by parse_native_url assert NotifyNotifyre.parse_native_url("https://notifyre.com/") is None def test_plugin_notifyre_privacy_url(): """Privacy URL masks the API key.""" obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"]) priv = obj.url(privacy=True) # API key should be masked assert API_KEY not in priv # Targets should remain visible assert "+15551234567" in priv @mock.patch("requests.post") def test_plugin_notifyre_sms_campaign(mock_post): """campaign parameter appears in the SMS API payload.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = json.dumps(d).encode() return r mock_post.return_value = _mk_resp(GOOD_RESPONSE) # Custom campaign name obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], campaign="MyCampaign", ) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is True posted = json.loads(mock_post.call_args[1]["data"]) assert posted["campaignName"] == "MyCampaign" # Default campaign = app_id mock_post.reset_mock() mock_post.return_value = _mk_resp(GOOD_RESPONSE) obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"]) assert obj.notify(body="Hello", notify_type=NotifyType.INFO) is True posted = json.loads(mock_post.call_args[1]["data"]) assert posted["campaignName"] == obj.app_id @mock.patch("requests.post") def test_plugin_notifyre_fax_params(mock_post): """template, ref, hq, header, and campaign appear in the fax payload.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = json.dumps(d).encode() return r mock_post.return_value = _mk_resp(GOOD_RESPONSE) obj = NotifyNotifyre( apikey=API_KEY, targets=["+15551234567"], mode="fax", campaign="FaxCampaign", template="CoverTpl", ref="CR-999", hq=False, header="CONFIDENTIAL", ) assert obj.notify(body="Fax body", notify_type=NotifyType.INFO) is True posted = json.loads(mock_post.call_args[1]["data"]) assert posted["campaignName"] == "FaxCampaign" assert posted["templateName"] == "CoverTpl" assert posted["clientReference"] == "CR-999" assert posted["isHighQuality"] is False assert posted["header"] == "CONFIDENTIAL" # subject is the full body without truncation assert posted["subject"] == "Fax body" # Defaults: hq=True, empty strings for template/ref/header mock_post.reset_mock() mock_post.return_value = _mk_resp(GOOD_RESPONSE) obj = NotifyNotifyre(apikey=API_KEY, targets=["+15551234567"], mode="fax") assert obj.notify(body="Test", notify_type=NotifyType.INFO) is True posted = json.loads(mock_post.call_args[1]["data"]) assert posted["isHighQuality"] is True assert posted["templateName"] == "" assert posted["clientReference"] == "" assert posted["header"] == "" assert posted["campaignName"] == obj.app_id @mock.patch("requests.post") def test_plugin_notifyre_apprise_integration(mock_post): """Apprise.add() + Apprise.notify() integration for both modes.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = json.dumps(d).encode() return r mock_post.return_value = _mk_resp(GOOD_RESPONSE) # SMS via string URL app = Apprise() assert app.add("notifyre://{}/+15551234567".format(API_KEY)) assert app.notify(title="Title", body="Body") is True # Fax via string URL app = Apprise() assert app.add("notifyre://{}/+15551234567?mode=fax".format(API_KEY)) assert app.notify(title="Title", body="Body") is True caronc-apprise-182f859/tests/test_plugin_ntfy.py000066400000000000000000000676651524161175200220510ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging import os import re from unittest import mock from helpers import AppriseURLTester import requests import apprise from apprise import NotifyFormat from apprise.plugins.ntfy import NotifyNtfy, NtfyPriority logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # For testing our return response GOOD_RESPONSE_TEXT = { "code": "0", "error": "success", } # Our Testing URLs apprise_url_tests = ( ( "ntfy://", { # Initializes okay (as cloud mode) but has no topics to notify "instance": NotifyNtfy, # invalid topics specified (nothing to notify) # as a result the response type will be false "requests_response_text": GOOD_RESPONSE_TEXT, "response": False, }, ), ( "ntfys://", { # Initializes okay (as cloud mode) but has no topics to notify "instance": NotifyNtfy, # invalid topics specified (nothing to notify) # as a result the response type will be false "requests_response_text": GOOD_RESPONSE_TEXT, "response": False, }, ), ( "ntfy://:@/", { # Initializes okay (as cloud mode) but has no topics to notify "instance": NotifyNtfy, # invalid topics specified (nothing to notify) # as a result the response type will be false "requests_response_text": GOOD_RESPONSE_TEXT, "response": False, }, ), # No topics ( "ntfy://user:pass@localhost?mode=private", { "instance": NotifyNtfy, # invalid topics specified (nothing to notify) # as a result the response type will be false "requests_response_text": GOOD_RESPONSE_TEXT, "response": False, }, ), # No valid topics ( "ntfy://user:pass@localhost/#/!/@", { "instance": NotifyNtfy, # invalid topics specified (nothing to notify) # as a result the response type will be false "requests_response_text": GOOD_RESPONSE_TEXT, "response": False, }, ), # user/pass combos ( "ntfy://user@localhost/topic/", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, # Our expected url(privacy=True) startswith() response: "privacy_url": "ntfy://user@localhost/topic", }, ), # Ntfy cloud mode (enforced) ( "ntfy://ntfy.sh/topic1/topic2/", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # No user/pass combo ( "ntfy://localhost/topic1/topic2/", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # A Email Testing ( "ntfy://localhost/topic1/?email=user@gmail.com", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # X-Tags via canonical xtags= parameter ( "ntfy://localhost/topic1/?xtags=tag1,tag2,tag3", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # X-Tags via legacy tags= parameter (backward compat) ( "ntfy://localhost/topic1/?tags=tag1,tag2,tag3", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Actions ( "ntfy://localhost/topic1/?actions=view%2CExample%2Chttp://www.example.com/%3Bview%2CTest%2Chttp://www.test.com/", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Delay ( "ntfy://localhost/topic1/?delay=3600", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Title ( "ntfy://localhost/topic1/?title=A%20Great%20Title", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Click ( "ntfy://localhost/topic1/?click=yes", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Email ( "ntfy://localhost/topic1/?email=user@example.com", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # No images ( "ntfy://localhost/topic1/?image=False", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Over-ride Image Path ( "ntfy://localhost/topic1/?avatar_url=ttp://localhost/test.jpg", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Attach ( "ntfy://localhost/topic1/?attach=http://example.com/file.jpg", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Attach with filename over-ride ( ( "ntfy://localhost/topic1/" "?attach=http://example.com/file.jpg&filename=smoke.jpg" ), {"instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT}, ), # Attach with bad url ( "ntfy://localhost/topic1/?attach=http://-%20", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Auth Token Types (tk_ gets detected as a auth=token) ( "ntfy://tk_abcd123456@localhost/topic1", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, # Our expected url(privacy=True) startswith() response: "privacy_url": "ntfy://t...6@localhost/topic1", }, ), # Force an auth token since lack of tk_ prevents auto-detection ( "ntfy://abcd123456@localhost/topic1?auth=token", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, # Our expected url(privacy=True) startswith() response: "privacy_url": "ntfy://a...6@localhost/topic1", }, ), # Force an auth token since lack of tk_ prevents auto-detection ( "ntfy://:abcd123456@localhost/topic1?auth=token", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, # Our expected url(privacy=True) startswith() response: "privacy_url": "ntfy://a...6@localhost/topic1", }, ), # Token detection already implied when token keyword is set ( "ntfy://localhost/topic1?token=abc1234", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, # Our expected url(privacy=True) startswith() response: "privacy_url": "ntfy://a...4@localhost/topic1", }, ), # Token enforced, but since a user/pass provided, only the pass is kept ( "ntfy://user:token@localhost/topic1?auth=token", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, # Our expected url(privacy=True) startswith() response: "privacy_url": "ntfy://t...n@localhost/topic1", }, ), # Token mode force, but there was no token provided ( "ntfy://localhost/topic1?auth=token", { "instance": NotifyNtfy, # We'll out-right fail to send the notification "response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "ntfy://localhost/topic1", }, ), # Priority ( "ntfy://localhost/topic1/?priority=default", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, # Our expected url(privacy=True) startswith() response: "privacy_url": "ntfy://localhost/topic1", }, ), # Priority higher ( "ntfy://localhost/topic1/?priority=high", { "instance": NotifyNtfy, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # A topic and port identifier ( "ntfy://user:pass@localhost:8080/topic/", { "instance": NotifyNtfy, # The response text is expected to be the following on a success "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # A topic (using the to=) ( "ntfys://user:pass@localhost?to=topic", { "instance": NotifyNtfy, # The response text is expected to be the following on a success "requests_response_text": GOOD_RESPONSE_TEXT, }, ), ( "https://just/a/random/host/that/means/nothing", { # Nothing transpires from this "instance": None }, ), # reference the ntfy.sh url ( "https://ntfy.sh?to=topic", { "instance": NotifyNtfy, # The response text is expected to be the following on a success "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Several topics ( "ntfy://user:pass@topic1/topic2/topic3/?mode=cloud", { "instance": NotifyNtfy, # The response text is expected to be the following on a success "requests_response_text": GOOD_RESPONSE_TEXT, }, ), # Several topics (but do not add ntfy.sh) ( "ntfy://user:pass@ntfy.sh/topic1/topic2/?mode=cloud", { "instance": NotifyNtfy, # The response text is expected to be the following on a success "requests_response_text": GOOD_RESPONSE_TEXT, }, ), ( "ntfys://user:web/token@localhost/topic/?mode=invalid", { # Invalid mode "instance": TypeError, }, ), ( "ntfys://token@localhost/topic/?auth=invalid", { # Invalid Authentication type "instance": TypeError, }, ), # Invalid hostname on localhost/private mode ( "ntfys://user:web@-_/topic1/topic2/?mode=private", { "instance": None, }, ), ( "ntfy://user:pass@localhost:8089/topic/topic2", { "instance": NotifyNtfy, # force a failure using basic mode "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "ntfy://user:pass@localhost:8082/topic", { "instance": NotifyNtfy, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), ( "ntfy://user:pass@localhost:8083/topic1/topic2/", { "instance": NotifyNtfy, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, "requests_response_text": GOOD_RESPONSE_TEXT, }, ), ) def test_plugin_ntfy_chat_urls(): """NotifyNtfy() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_ntfy_attachments(mock_post): """NotifyNtfy() Attachment Checks.""" # Prepare Mock return object response = mock.Mock() response.content = GOOD_RESPONSE_TEXT response.status_code = requests.codes.ok mock_post.return_value = response # Test how the notifications work without attachments as they use the # JSON type posting instead # Reset our mock object mock_post.reset_mock() # Prepare our object obj = apprise.Apprise.instantiate("ntfy://user:pass@localhost:8080/topic") # Send a good attachment assert obj.notify(title="hello", body="world") assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "http://localhost:8080" response = json.loads(mock_post.call_args_list[0][1]["data"]) assert response["topic"] == "topic" assert response["title"] == "hello" assert response["message"] == "world" assert "attach" not in response # Reset our mock object mock_post.reset_mock() # prepare our attachment attach = apprise.AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) # Prepare our object obj = apprise.Apprise.instantiate("ntfy://user:pass@localhost:8084/topic") # Send a good attachment assert obj.notify(body="test", attach=attach) is True # Test our call count; includes both image and message assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "http://localhost:8084/topic" assert mock_post.call_args_list[0][1]["params"]["message"] == "test" assert "title" not in mock_post.call_args_list[0][1]["params"] assert ( mock_post.call_args_list[0][1]["params"]["filename"] == "apprise-test.gif" ) # Reset our mock object mock_post.reset_mock() # Add another attachment so we drop into the area of the PushBullet code # that sends remaining attachments (if more detected) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) # Send our attachments assert obj.notify(body="test", title="wonderful", attach=attach) is True # Test our call count assert mock_post.call_count == 2 # Image + Message sent assert mock_post.call_args_list[0][0][0] == "http://localhost:8084/topic" assert mock_post.call_args_list[0][1]["params"]["message"] == "test" assert mock_post.call_args_list[0][1]["params"]["title"] == "wonderful" assert ( mock_post.call_args_list[0][1]["params"]["filename"] == "apprise-test.gif" ) # Image no 2 (no message) assert mock_post.call_args_list[1][0][0] == "http://localhost:8084/topic" assert "message" not in mock_post.call_args_list[1][1]["params"] assert "title" not in mock_post.call_args_list[1][1]["params"] assert ( mock_post.call_args_list[1][1]["params"]["filename"] == "apprise-test.png" ) # Reset our mock object mock_post.reset_mock() # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") attach = apprise.AppriseAttachment(path) assert obj.notify(body="test", attach=attach) is False # Test our call count assert mock_post.call_count == 0 # prepare our attachment attach = apprise.AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) # Throw an exception on the first call to requests.post() mock_post.return_value = None for side_effect in (requests.RequestException(), OSError()): mock_post.side_effect = side_effect # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False @mock.patch("requests.post") def test_plugin_custom_ntfy_edge_cases(mock_post): """NotifyNtfy() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok response.content = json.dumps(GOOD_RESPONSE_TEXT) # Prepare Mock mock_post.return_value = response results = NotifyNtfy.parse_url( "ntfys://abc---,topic2,~~,,?priority=max&tags=smile,de" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] is None assert results["host"] == "abc---,topic2,~~,," assert results["fullpath"] is None assert results["path"] is None assert results["query"] is None assert results["schema"] == "ntfys" assert results["url"] == "ntfys://abc---,topic2,~~,," assert isinstance(results["qsd:"], dict) is True assert results["qsd"]["priority"] == "max" assert results["qsd"]["tags"] == "smile,de" # Legacy tags= is translated to xtags internally; must not leak as # results["tags"] or the config/base YAML parser would treat it as # an Apprise routing tag and silently drop the notification. assert "tags" not in results assert results["xtags"] == ["de", "smile"] instance = NotifyNtfy(**results) assert isinstance(instance, NotifyNtfy) assert len(instance.topics) == 2 assert "abc---" in instance.topics assert "topic2" in instance.topics # X-Tags header must be set correctly assert instance._NotifyNtfy__tags == ["de", "smile"] results = NotifyNtfy.parse_url( "ntfy://localhost/topic1/" "?attach=http://example.com/file.jpg&filename=smoke.jpg" ) assert isinstance(results, dict) assert results["user"] is None assert results["password"] is None assert results["port"] is None assert results["host"] == "localhost" assert results["fullpath"] == "/topic1/" assert results["path"] == "/topic1/" assert results["query"] is None assert results["schema"] == "ntfy" assert results["url"] == "ntfy://localhost/topic1/" assert results["attach"] == "http://example.com/file.jpg" assert results["filename"] == "smoke.jpg" instance = NotifyNtfy(**results) assert isinstance(instance, NotifyNtfy) assert len(instance.topics) == 1 assert "topic1" in instance.topics assert ( instance.notify( body="body", title="title", notify_type=apprise.NotifyType.INFO ) is True ) # Test our call count assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "http://localhost" response = json.loads(mock_post.call_args_list[0][1]["data"]) assert response["topic"] == "topic1" assert response["message"] == "body" assert response["title"] == "title" assert response["attach"] == "http://example.com/file.jpg" assert response["filename"] == "smoke.jpg" # Reset our mock object mock_post.reset_mock() # Markdown Support results = NotifyNtfy.parse_url("ntfys://topic/?format=markdown") assert isinstance(results, dict) instance = NotifyNtfy(**results) assert ( instance.notify( body="body", title="title", notify_type=apprise.NotifyType.INFO ) is True ) assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "https://ntfy.sh" assert "X-Markdown" in mock_post.call_args_list[0][1]["headers"] @mock.patch("requests.post") @mock.patch("requests.get") def test_plugin_ntfy_config_files(mock_post, mock_get): """NotifyNtfy() Config File Cases.""" content = """ urls: - ntfy://localhost/topic1: - priority: 1 tag: ntfy_int min - priority: "1" tag: ntfy_str_int min - priority: min tag: ntfy_str min # This will take on normal (default) priority - priority: invalid tag: ntfy_invalid - ntfy://localhost/topic2: - priority: 5 tag: ntfy_int max - priority: "5" tag: ntfy_str_int max - priority: emergency tag: ntfy_str max - priority: max tag: ntfy_str max """ # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_get.return_value = requests.Request() mock_get.return_value.status_code = requests.codes.ok # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 8 servers from that # 3x min # 4x max # 1x invalid (so takes on normal priority) assert len(ac.servers()) == 8 assert len(aobj) == 8 assert len(list(aobj.find(tag="min"))) == 3 for s in aobj.find(tag="min"): assert s.priority == NtfyPriority.MIN assert len(list(aobj.find(tag="max"))) == 4 for s in aobj.find(tag="max"): assert s.priority == NtfyPriority.MAX assert len(list(aobj.find(tag="ntfy_str"))) == 3 assert len(list(aobj.find(tag="ntfy_str_int"))) == 2 assert len(list(aobj.find(tag="ntfy_int"))) == 2 assert len(list(aobj.find(tag="ntfy_invalid"))) == 1 assert next(aobj.find(tag="ntfy_invalid")).priority == NtfyPriority.NORMAL # A cloud reference without any identifiers; the ntfy:// (insecure mode) # is not considered during the id generation as ntfys:// is always # implied results = NotifyNtfy.parse_url("ntfy://") obj = NotifyNtfy(**results) new_results = NotifyNtfy.parse_url(obj.url()) obj2 = NotifyNtfy(**new_results) assert obj.url_id() == obj2.url_id() @mock.patch("requests.post") def test_plugin_ntfy_internationalized_urls(mock_post): """NotifyNtfy() Internationalized URL Support.""" # Prepare Mock return object response = mock.Mock() response.content = GOOD_RESPONSE_TEXT response.status_code = requests.codes.ok mock_post.return_value = response # Our input title = "My Title" body = "My Body" # Google Translate promised me this just says 'Apprise Example' (I hope # this is the case πŸ™). Below is a URL requiring encoding so that it # can be correctly passed into an http header: click = "https://ι€šηŸ₯γδΎ‹" # Prepare our object obj = apprise.Apprise.instantiate(f"ntfy://ntfy.sh/topic1?click={click}") # Send our notification assert obj.notify(title=title, body=body) assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "http://ntfy.sh" # Verify that our International URL was correctly escaped assert ( "https://%25E9%2580%259A%25E7%259F%25A5%25E3%2581%25AE%25E4%25BE%258B" in mock_post.call_args_list[0][1]["headers"]["X-Click"] ) # Validate that we did not obstruct our URL in anyway assert apprise.Apprise.instantiate(obj.url()).url() == obj.url() @mock.patch("requests.post") def test_plugin_ntfy_message_to_attach(mock_post): """NotifyNtfy() large messages converted into attachments.""" # Prepare Mock return object response = mock.Mock() response.content = GOOD_RESPONSE_TEXT response.status_code = requests.codes.ok mock_post.return_value = response # Create a very, very big message title = "My Title" body = "b" * NotifyNtfy.ntfy_json_upstream_size_limit for fmt in apprise.NOTIFY_FORMATS: # Prepare our object obj = apprise.Apprise.instantiate( f"ntfy://user:pass@localhost:8080/topic?format={fmt}" ) # Our content will actually transfer as an attachment assert obj.notify(title=title, body=body) assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://localhost:8080/topic" ) response = mock_post.call_args_list[0][1] assert "data" in response assert response["data"].decode("utf-8").startswith(title) assert response["data"].decode("utf-8").endswith(body) assert "params" in response assert "filename" in response["params"] # Our filename is automatically generated (with .txt) assert re.match( r"^[a-z0-9-]+\.txt$", response["params"]["filename"], re.I ) # Reset our mock object mock_post.reset_mock() def test_plugin_ntfy_xtags_no_apprise_tag_collision(): """Regression: ntfy xtags= / tags= must never become an Apprise routing tag. Prior to the xtags= rename (commit that introduced this test), ntfy stored its X-Tags value in results["tags"]. The config/base YAML parser intercepts results["tags"] and converts it to an Apprise routing tag, which caused the plugin to be silently skipped when a notify request arrived without a matching tag filter. """ # --- parse_url: canonical xtags= form --- r = NotifyNtfy.parse_url("ntfy://myhost/mytopic?xtags=warning,critical") assert r is not None assert "tags" not in r, "xtags= must not appear as results['tags']" assert r["xtags"] == ["critical", "warning"] n = NotifyNtfy(**r) assert n._NotifyNtfy__tags == ["critical", "warning"] # apprise routing tag must remain empty assert not n.tags # canonical URL must use xtags= and must not contain a bare tags= param assert "xtags=" in n.url() assert "&tags=" not in n.url() and "?tags=" not in n.url() # --- parse_url: legacy tags= form (backward compat) --- r2 = NotifyNtfy.parse_url("ntfy://myhost/mytopic?tags=info,storage") assert r2 is not None assert "tags" not in r2, "legacy tags= must be re-keyed as xtags, not tags" assert r2["xtags"] == ["info", "storage"] n2 = NotifyNtfy(**r2) assert n2._NotifyNtfy__tags == ["info", "storage"] assert not n2.tags # --- YAML config: tags= in URL must not become an Apprise routing tag --- cfg = apprise.AppriseConfig() cfg.add_config( "urls:\n - ntfy://myhost/mytopic?tags=warning\n", format="yaml", ) a = apprise.Apprise() a.add(cfg) servers = list(cfg.servers()) assert len(servers) == 1 s = servers[0] # X-Tags must be preserved assert s._NotifyNtfy__tags == ["warning"] # No Apprise routing tag should have been created assert not s.tags, ( "tags= in a YAML ntfy URL must not create an Apprise routing tag" ) @mock.patch("requests.post") def test_plugin_ntfy_html_to_markdown_format(mock_post): """NotifyNtfy(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"{}" # Instantiate an ntfy plugin in Markdown mode using a private server aobj = apprise.Apprise() assert aobj.add("ntfy://localhost/topic?format=markdown") # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to ntfy assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown, not as stripped plain text payload = json.loads(mock_post.call_args_list[0][1]["data"]) assert payload["message"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_octopush.py000066400000000000000000000233511524161175200227150ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.plugins.octopush import NotifyOctopush logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "octopush://", { # No API Login or API Key specified "instance": TypeError, }, ), ( "octopush://:@/", { # Invalid API Login "instance": TypeError, }, ), ( "octopush://user@myaccount.com", { # Valid API Login, but no API Key provided "instance": TypeError, }, ), ( "octopush://_/apikey?login=invalid", { # Invalid login "instance": TypeError, }, ), ( "octopush://user@myaccount.com/%20", { # Valid API Login, but invalid API Key provided "instance": TypeError, }, ), ( "octopush://%20:user@myaccount.com/apikey", { # All valid entries, but invalid sender "instance": TypeError, }, ), ( "octopush://user@myaccount.com/apikey", { # All valid entries, but no target phone numbers defined "instance": NotifyOctopush, "response": False, }, ), ( "octopush://user@myaccount.com/apikey/+0987654321", { # A valid url "instance": NotifyOctopush, "requests_response_code": requests.codes.created, # Our expected url(privacy=True) startswith() response: "privacy_url": "octopush://u...m/****/+0987654321", }, ), ( "octopush://sender:user@myaccount.com/apikey/+1111111111", { # A valid url with sender "instance": NotifyOctopush, "requests_response_code": requests.codes.created, # Our expected url(privacy=True) startswith() response: "privacy_url": "octopush://sender:u...m/****/+1111111111", }, ), ( "octopush://?login=user@myaccount.com&key=key&to=9999999999" "&purpose=wholesale", { # Testing valid purpose change "instance": NotifyOctopush, "requests_response_code": requests.codes.created, }, ), ( "octopush://?login=user@myaccount.com&key=key&to=9999999999" "&purpose=invalid", { # Testing invalid purpose change "instance": TypeError, }, ), ( "octopush://?login=user@myaccount.com&key=key&to=9999999999" "&type=premium", { # Testing valid type change "instance": NotifyOctopush, "requests_response_code": requests.codes.created, }, ), ( "octopush://?login=user@myaccount.com&key=key&to=9999999999" "&type=invalid", { # Testing invalid type change "instance": TypeError, }, ), ( "octopush://user@myaccount.com/apikey/+3333333333?replies=yes", { # Test replies "instance": NotifyOctopush, "requests_response_code": requests.codes.created, }, ), ( "octopush://sender:user@myaccount.com/apikey/{}/{}/{}/?batch=yes".format( "1" * 10, "2" * 3, "3" * 11 ), { # batch mode, 2 valid targets (1 is invalid and skipped) "instance": NotifyOctopush, "requests_response_code": requests.codes.created, }, ), ( "octopush://_?key=abc123&login=user@myaccount.com&sender=abc" "&to=2222222222", { # use get args to accomplish the same thing "instance": NotifyOctopush, "requests_response_code": requests.codes.created, # Our expected url(privacy=True) startswith() response: "privacy_url": "octopush://abc:u...m/****/+2222222222", }, ), ( "octopush://user@myaccount.com/apikey/1234567890", { "instance": NotifyOctopush, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "octopush://user@myaccount.com/apikey/1234567890", { "instance": NotifyOctopush, # Throws a series of connection and transfer exceptions when this # flag is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_octopush_urls(): """NotifyOctopush() Apprise URLs.""" assert ( NotifyOctopush.setup_url == "https://appriseit.com/services/octopush/" ) # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_octopush_parse_url_and_validation(): """NotifyOctopush() parse_url() and validation coverage.""" assert NotifyOctopush.parse_url(None) is None with pytest.raises(TypeError): NotifyOctopush( api_login="user@myaccount.com", api_key="apikey", targets=("1234567890",), purpose=" ", ) results = NotifyOctopush.parse_url( "octopush://sender:user@myaccount.com/apikey/1234567890" "?to=2345678901&type=sms_premium&purpose=alert&batch=yes" "&replies=no" ) assert isinstance(results, dict) assert results["api_key"] == "apikey" assert results["api_login"] == "user@myaccount.com" assert results["sender"] == "sender" assert results["targets"] == ["1234567890", "2345678901"] assert results["mtype"] == "sms_premium" assert results["purpose"] == "alert" assert results["batch"] is True assert results["replies"] is False results = NotifyOctopush.parse_url( "octopush://_?login=user@myaccount.com&key=apikey&sender=abc" "&to=3456789012&type=sms_low_cost&purpose=wholesale" "&batch=no&replies=yes" ) assert isinstance(results, dict) assert results["api_key"] == "apikey" assert results["api_login"] == "user@myaccount.com" assert results["sender"] == "abc" assert results["targets"] == ["3456789012"] assert results["mtype"] == "sms_low_cost" assert results["purpose"] == "wholesale" assert results["batch"] is False assert results["replies"] is True @mock.patch("requests.post") def test_plugin_octopush_edge_cases(mock_post): """NotifyOctopush() Edge Cases.""" response = requests.Request() response.status_code = requests.codes.created mock_post.return_value = response obj = Apprise.instantiate( "octopush://sender:user@myaccount.com/apikey/1234567890/2345678901" "?batch=yes&replies=yes&type=low_cost&purpose=wholesale" ) assert isinstance(obj, NotifyOctopush) assert len(obj) == 1 assert obj.targets == ["+1234567890", "+2345678901"] assert obj.mtype == "sms_low_cost" assert obj.purpose == "wholesale" assert obj.replies is True assert obj.notify(body="body", title="title", notify_type=NotifyType.INFO) assert mock_post.call_count == 1 call = mock_post.call_args_list[0] assert call[0][0] == "https://api.octopush.com/v1/public/sms-campaign/send" headers = call[1]["headers"] assert headers["api-login"] == "user@myaccount.com" assert headers["api-key"] == "apikey" assert headers["Content-Type"] == "application/json; charset=utf-8" payload = call[1]["data"] assert '"type": "sms_low_cost"' in payload assert '"purpose": "wholesale"' in payload assert '"with_replies": true' in payload assert '"phone_number": "+1234567890"' in payload assert '"phone_number": "+2345678901"' in payload obj = NotifyOctopush( api_login="user@myaccount.com", api_key="apikey", targets=tuple(str(1000000000 + i) for i in range(501)), batch=True, ) assert len(obj) == 2 mock_post.reset_mock() assert obj.notify(body="body", title="title", notify_type=NotifyType.INFO) assert mock_post.call_count == 2 first_payload = mock_post.call_args_list[0][1]["data"] assert first_payload.count('"phone_number"') == 500 second_payload = mock_post.call_args_list[1][1]["data"] assert second_payload.count('"phone_number"') == 1 caronc-apprise-182f859/tests/test_plugin_office365.py000066400000000000000000001432441524161175200225460ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime from json import dumps, loads # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.office365 import NotifyOffice365 logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ################################## # NotifyOffice365 ################################## ( "o365://", { # Missing tenant, client_id, secret, and targets! "instance": TypeError, }, ), ( "o365://:@/", { # invalid url "instance": TypeError, }, ), ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format( # invalid tenant tenant=",", cid="ab-cd-ef-gh", aid="user@example.com", secret="abcd/123/3343/@jack/test", targets="/".join(["email1@test.ca"]), ), { # Expected failure "instance": TypeError, }, ), ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format( tenant="tenant", # invalid client id cid="ab.", aid="user2@example.com", secret="abcd/123/3343/@jack/test", targets="/".join(["email1@test.ca"]), ), { # Expected failure "instance": TypeError, }, ), ( "o365://{tenant}/{cid}/{secret}/{targets}".format( # email not required if mode is set to self tenant="tenant", cid="ab-cd-ef-gh", secret="abcd/123/3343/@jack/test", targets="/".join(["email1@test.ca"]), ), { # We're valid and good to go "instance": NotifyOffice365, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "mail": "user@example.ca", }, }, ), ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format( tenant="tenant", cid="ab-cd-ef-gh", aid="user@example.edu", secret="abcd/123/3343/@jack/test", targets="/".join(["email1@test.ca"]), ), { # We're valid and good to go "instance": NotifyOffice365, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", # For 'From:' Lookup "mail": "user@example.ca", }, # Our expected url(privacy=True) startswith() response: "privacy_url": ( "azure://user@example.edu/t...t/a...h/****/email1@test.ca/" ), "force_debug": True, }, ), ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format( tenant="tenant", cid="ab-cd-ef-gh", # Source can also be Object ID aid="hg-fe-dc-ba", secret="abcd/123/3343/@jack/test", targets="/".join(["email1@test.ca"]), ), { # We're valid and good to go "instance": NotifyOffice365, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "mail": "user@example.ca", "displayName": "John", }, # Our expected url(privacy=True) startswith() response: "privacy_url": ( "azure://hg-fe-dc-ba/t...t/a...h/****/email1@test.ca/" ), }, ), # ObjectID Specified, but no targets ( "o365://{aid}/{tenant}/{cid}/{secret}/".format( tenant="tenant", cid="ab-cd-ef-gh", # Source can also be Object ID aid="hg-fe-dc-ba", secret="abcd/123/3343/@jack/test", ), { # We're valid and good to go "instance": NotifyOffice365, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "mail": "user@example.ca", }, # No emails detected "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "azure://hg-fe-dc-ba/t...t/a...h/****", }, ), # ObjectID Specified, but no targets ( "o365://{aid}/{tenant}/{cid}/{secret}/".format( tenant="tenant", cid="ab-cd-ef-gh", # Source can also be Object ID aid="hg-fe-dc-ba", secret="abcd/123/3343/@jack/test", ), { # We're valid and good to go "instance": NotifyOffice365, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "userPrincipalName": "user@example.ca", }, # No emails detected "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "azure://hg-fe-dc-ba/t...t/a...h/****", }, ), # test our arguments ( "o365://_/?oauth_id={cid}&oauth_secret={secret}&tenant={tenant}" "&to={targets}&from={aid}".format( tenant="tenant", cid="ab-cd-ef-gh", aid="user@example.ca", secret="abcd/123/3343/@jack/test", targets="email1@test.ca", ), { # We're valid and good to go "instance": NotifyOffice365, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "mail": "user@example.ca", }, # Our expected url(privacy=True) startswith() response: "privacy_url": ( "azure://user@example.ca/t...t/a...h/****/email1@test.ca/" ), }, ), # Test invalid JSON (no tenant defaults to email domain) ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format( tenant="tenant", cid="ab-cd-ef-gh", aid="user@example.com", secret="abcd/123/3343/@jack/test", targets="/".join(["email1@test.ca"]), ), { # We're valid and good to go "instance": NotifyOffice365, # invalid JSON response "requests_response_text": "{", "notify_response": False, }, ), # No Targets specified ( "o365://{aid}/{tenant}/{cid}/{secret}".format( tenant="tenant", cid="ab-cd-ef-gh", aid="user@example.com", secret="abcd/123/3343/@jack/test", ), { # We're valid and good to go "instance": NotifyOffice365, # There were no targets to notify; so we use our own email "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "userPrincipalName": "user@example.ca", }, }, ), ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}".format( tenant="tenant", cid="zz-zz-zz-zz", aid="user@example.com", secret="abcd/abc/dcba/@john/test", targets="/".join(["email1@test.ca"]), ), { "instance": NotifyOffice365, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "o365://{tenant}:{aid}/{cid}/{secret}/{targets}".format( tenant="tenant", cid="01-12-23-34", aid="user@example.com", secret="abcd/321/4321/@test/test", targets="/".join(["email1@test.ca"]), ), { "instance": NotifyOffice365, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # reply_to support β€” plain address ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}" "?reply_to=reply@example.org".format( tenant="tenant", cid="ab-cd-ef-gh", aid="user@example.edu", secret="abcd/123/3343/@jack/test", targets="email1@test.ca", ), { "instance": NotifyOffice365, "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "mail": "user@example.ca", }, "privacy_url": ( "azure://user@example.edu/t...t/a...h/****/email1@test.ca/" ), }, ), # Disable Save Sent Items ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}?savesent=no".format( tenant="tenant", cid="ab-cd-ef-gh", aid="user@example.edu", secret="abcd/123/3343/@jack/test", targets="email1@test.ca", ), { "instance": NotifyOffice365, "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "mail": "user@example.ca", }, "privacy_url": ( "azure://user@example.edu/t...t/a...h/****/email1@test.ca/" ), }, ), # reply_to with URL-encoded (+) spaces in name ( "o365://{aid}/{tenant}/{cid}/{secret}/{targets}" "?reply_to=Reply+Person+reply@example.org".format( tenant="tenant", cid="ab-cd-ef-gh", aid="user@example.edu", secret="abcd/123/3343/@jack/test", targets="email1@test.ca", ), { "instance": NotifyOffice365, "requests_response_text": { "expires_in": 2000, "access_token": "abcd1234", "mail": "user@example.ca", }, "privacy_url": ( "azure://user@example.edu/t...t/a...h/****/email1@test.ca/" ), }, ), ) def test_plugin_office365_urls(): """NotifyOffice365() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_office365_general(mock_get, mock_post): """NotifyOffice365() General Testing.""" # Initialize some generic (but valid) tokens email = "user@example.net" tenant = "ff-gg-hh-ii-jj" client_id = "aa-bb-cc-dd-ee" secret = "abcd/1234/abcd@ajd@/test" targets = "target@example.com" # Prepare Mock return object payload = { "token_type": "Bearer", "expires_in": 6000, "access_token": "abcd1234", # For 'From:' Lookup "mail": "abc@example.ca", # For our Draft Email ID: "id": "draft-id-no", } response = mock.Mock() response.content = dumps(payload) response.status_code = requests.codes.ok mock_post.return_value = response mock_get.return_value = response # Instantiate our object obj = Apprise.instantiate(f"o365://{email}/{tenant}/{secret}/{targets}") assert isinstance(obj, NotifyOffice365) # Test our URL generation assert isinstance(obj.url(), str) # Test our notification assert obj.notify(title="title", body="test") is True # Instantiate our object obj = Apprise.instantiate( "o365://{email}/{tenant}/{client_id}/{secret}/{targets}" "?bcc={bcc}&cc={cc}".format( tenant=tenant, email=email, client_id=client_id, secret=secret, targets=targets, # Test the cc and bcc list (use good and bad email) cc="Chuck Norris cnorris@yahoo.ca, Sauron@lotr.me, invalid@!", bcc="Bruce Willis bwillis@hotmail.com, Frodo@lotr.me invalid@!", ) ) assert isinstance(obj, NotifyOffice365) # Test our URL generation assert isinstance(obj.url(), str) # Test our notification assert obj.notify(title="title", body="test") is True with pytest.raises(TypeError): # No secret NotifyOffice365( email=email, client_id=client_id, tenant=tenant, secret=None, targets=None, ) # One of the targets are invalid obj = NotifyOffice365( email=email, client_id=client_id, tenant=tenant, secret=secret, targets=("Management abc@gmail.com", "garbage"), ) # Test our notification (this will work and only notify abc@gmail.com) assert obj.notify(title="title", body="test") is True # all of the targets are invalid obj = NotifyOffice365( email=email, client_id=client_id, tenant=tenant, secret=secret, targets=("invalid", "garbage"), ) # Test our notification (which will fail because of no entries) assert obj.notify(title="title", body="test") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_office365_authentication(mock_get, mock_post): """NotifyOffice365() Authentication Testing.""" # Initialize some generic (but valid) tokens tenant = "ff-gg-hh-ii-jj" email = "user@example.net" client_id = "aa-bb-cc-dd-ee" secret = "abcd/1234/abcd@ajd@/test" targets = "target@example.com" # Prepare Mock return object authentication_okay = { "token_type": "Bearer", "expires_in": 6000, "access_token": "abcd1234", } authentication_failure = { "error": "invalid_scope", "error_description": "AADSTS70011: Blah... Blah Blah... Blah", "error_codes": [70011], "timestamp": "2020-01-09 02:02:12Z", "trace_id": "255d1aef-8c98-452f-ac51-23d051240864", "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7", } response = mock.Mock() response.content = dumps(authentication_okay) response.status_code = requests.codes.ok mock_post.return_value = response mock_get.return_value = response # Instantiate our object obj = Apprise.instantiate( f"azure://{email}/{tenant}/{client_id}/{secret}/{targets}" ) assert isinstance(obj, NotifyOffice365) # Authenticate assert obj.authenticate() is True # We're already authenticated assert obj.authenticate() is True # Expire our token obj.token_expiry = datetime.now() # Re-authentiate assert obj.authenticate() is True # Change our response response.status_code = 400 # We'll fail to send a notification now... assert obj.notify(title="title", body="test") is False # Expire our token obj.token_expiry = datetime.now() # Set a failure response response.content = dumps(authentication_failure) # We will fail to authenticate at this point assert obj.authenticate() is False # Notifications will also fail in this case assert obj.notify(title="title", body="test") is False # We will fail to authenticate with invalid data invalid_auth_entries = authentication_okay.copy() invalid_auth_entries["expires_in"] = "garbage" response.content = dumps(invalid_auth_entries) response.status_code = requests.codes.ok assert obj.authenticate() is False invalid_auth_entries["expires_in"] = None response.content = dumps(invalid_auth_entries) assert obj.authenticate() is False invalid_auth_entries["expires_in"] = "" response.content = dumps(invalid_auth_entries) assert obj.authenticate() is False del invalid_auth_entries["expires_in"] response.content = dumps(invalid_auth_entries) assert obj.authenticate() is False @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_office365_queries(mock_post, mock_get, mock_put): """NotifyOffice365() General Queries.""" # Initialize some generic (but valid) tokens source = "abc-1234-object-id" tenant = "ff-gg-hh-ii-jj" client_id = "aa-bb-cc-dd-ee" secret = "abcd/1234/abcd@ajd@/test" targets = "target@example.ca" # Prepare Mock return object payload = { "token_type": "Bearer", "expires_in": 6000, "access_token": "abcd1234", # For 'From:' Lookup (email) "mail": "user@example.edu", # For 'From:' Lookup (name) "displayName": "John", # For our Draft Email ID: "id": "draft-id-no", # For FIle Uploads "uploadUrl": "https://my.url.path/", } okay_response = mock.Mock() okay_response.content = dumps(payload) okay_response.status_code = requests.codes.ok mock_post.return_value = okay_response mock_put.return_value = okay_response bad_response = mock.Mock() bad_response.content = dumps(payload) bad_response.status_code = requests.codes.forbidden # Assign our GET a bad response so we fail to look up the user mock_get.return_value = bad_response # # Early exit: source is an Object ID (GUID) and lookup fails # guid_source = "e2a9db46-5714-44c2-87bf-c33bf236f42a" not_found_payload = { "error": { "code": "Request_ResourceNotFound", "message": "Resource not found.", } } not_found_response = mock.Mock() not_found_response.content = dumps(not_found_payload) not_found_response.status_code = requests.codes.not_found # Force the sender lookup to fail for a GUID based source mock_get.return_value = not_found_response obj = Apprise.instantiate( f"azure://{guid_source}/{tenant}/{client_id}{secret}/{targets}" ) assert isinstance(obj, NotifyOffice365) # Notify must exit early since the source Object ID could not be resolved assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # 1) Authenticate (POST token) assert mock_post.call_count == 1 # 2) Attempt user lookup (GET) assert mock_get.call_count == 1 mock_post.reset_mock() mock_get.reset_mock() # Restore GET failure behaviour used by remaining tests mock_get.return_value = bad_response # Instantiate our object obj = Apprise.instantiate( f"azure://{source}/{tenant}/{client_id}{secret}/{targets}" ) assert isinstance(obj, NotifyOffice365) # We can still send a notification even if we can't look up the email assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" ) assert ( mock_post.call_args_list[1][0][0] == "https://graph.microsoft.com/v1.0/users/abc-1234-object-id/sendMail" ) payload = loads(mock_post.call_args_list[1][1]["data"]) assert payload == { "message": { "subject": "title", "body": { "contentType": "HTML", "content": "body", }, "toRecipients": [ {"emailAddress": {"address": "target@example.ca"}} ], }, "saveToSentItems": "true", } mock_post.reset_mock() # Now test a case where we just couldn't get any email details from the # payload returned # Prepare Mock return object temp_payload = { "token_type": "Bearer", "expires_in": 6000, "access_token": "abcd1234", # For our Draft Email ID: "id": "draft-id-no", # For FIle Uploads "uploadUrl": "https://my.url.path/", } bad_response.content = dumps(temp_payload) bad_response.status_code = requests.codes.okay mock_get.return_value = bad_response obj = Apprise.instantiate( f"azure://{source}/{tenant}/{client_id}{secret}/{targets}" ) assert isinstance(obj, NotifyOffice365) # We can still send a notification even if we can't look up the email assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_office365_attachments(mock_post, mock_get, mock_put): """NotifyOffice365() Attachments.""" # Initialize some generic (but valid) tokens source = "user@example.net" tenant = "ff-gg-hh-ii-jj" client_id = "aa-bb-cc-dd-ee" secret = "abcd/1234/abcd@ajd@/test" targets = "target@example.com" # Prepare Mock return object payload = { "token_type": "Bearer", "expires_in": 6000, "access_token": "abcd1234", # For 'From:' Lookup "mail": "user@example.edu", # For our Draft Email ID: "id": "draft-id-no", # For FIle Uploads "uploadUrl": "https://my.url.path/", } okay_response = mock.Mock() okay_response.content = dumps(payload) okay_response.status_code = requests.codes.ok mock_post.return_value = okay_response mock_get.return_value = okay_response mock_put.return_value = okay_response # Instantiate our object obj = Apprise.instantiate( f"azure://{source}/{tenant}/{client_id}{secret}/{targets}" ) assert isinstance(obj, NotifyOffice365) # Test Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" ) assert ( mock_post.call_args_list[0][1]["headers"].get("Content-Type") == "application/x-www-form-urlencoded" ) assert ( mock_post.call_args_list[1][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/sendMail" ) assert ( mock_post.call_args_list[1][1]["headers"].get("Content-Type") == "application/json" ) mock_post.reset_mock() # Test Authentication Failure obj = Apprise.instantiate( "azure://{source}/{tenant}/{client_id}{secret}/{targets}".format( client_id=client_id, tenant=tenant, source="object-id-requiring-lookup", secret=secret, targets=targets, ) ) bad_response = mock.Mock() bad_response.content = dumps(payload) bad_response.status_code = requests.codes.forbidden mock_post.return_value = bad_response assert isinstance(obj, NotifyOffice365) # Authentication will fail assert ( obj.notify( body="auth-fail", title="title", notify_type=NotifyType.INFO ) is False ) assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token" ) mock_post.reset_mock() # # Test invalid attachment # # Instantiate our object obj = Apprise.instantiate( f"azure://{source}/{tenant}/{client_id}{secret}/{targets}" ) assert isinstance(obj, NotifyOffice365) mock_post.return_value = okay_response path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) assert mock_post.call_count == 0 mock_post.reset_mock() with mock.patch("base64.b64encode", side_effect=OSError()): # We can't send the message if we fail to parse the data assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 0 mock_post.reset_mock() # # Test case where we can't authenticate # obj = Apprise.instantiate( f"azure://{source}/{tenant}/{client_id}{secret}/{targets}" ) # Force a smaller attachment size forcing us to create an attachment obj.outlook_attachment_inline_max = 50 assert isinstance(obj, NotifyOffice365) path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) mock_post.return_value = bad_response assert obj.upload_attachment(attach[0], "id") is False assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token" ) mock_post.reset_mock() mock_post.side_effect = (okay_response, bad_response) mock_post.return_value = None assert obj.upload_attachment(attach[0], "id") is False assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token" ) assert ( mock_post.call_args_list[1][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/" + "message/id/attachments/createUploadSession" ) mock_post.reset_mock() # Return our status mock_post.side_effect = None # Prepare Mock return object payload_no_upload_url = { "token_type": "Bearer", "expires_in": 6000, "access_token": "abcd1234", # For 'From:' Lookup "mail": "user@example.edu", # For our Draft Email ID: "id": "draft-id-no", } tmp_response = mock.Mock() tmp_response.content = dumps(payload_no_upload_url) tmp_response.status_code = requests.codes.ok mock_post.return_value = tmp_response assert obj.upload_attachment(attach[0], "id") is False assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/" + "message/id/attachments/createUploadSession" ) mock_post.reset_mock() # Return our status mock_post.side_effect = None mock_post.return_value = okay_response obj = Apprise.instantiate( f"azure://{source}/{tenant}/{client_id}{secret}/{targets}" ) # Force a smaller attachment size forcing us to create an attachment obj.outlook_attachment_inline_max = 50 assert isinstance(obj, NotifyOffice365) # We now have to prepare sepparate session attachments using draft emails assert ( obj.notify( body="body", title="title-test", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Large Attachments assert mock_post.call_count == 4 assert ( mock_post.call_args_list[0][0][0] == "https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token" ) assert ( mock_post.call_args_list[1][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/messages" ) assert ( mock_post.call_args_list[2][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/" + "message/draft-id-no/attachments/createUploadSession" ) assert ( mock_post.call_args_list[3][0][0] == "https://graph.microsoft.com/v1.0/users/" + f"{source}/messages/draft-id-no/send" ) mock_post.reset_mock() # # Handle another case where can't upload the attachment at all # path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") bad_attach = AppriseAttachment(path) assert obj.upload_attachment(bad_attach[0], "id") is False mock_post.reset_mock() # # Handle test case where we can't send the draft email after everything # has been prepared # mock_post.return_value = None mock_post.side_effect = (okay_response, okay_response, bad_response) assert ( obj.notify( body="body", title="title-test", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 3 assert ( mock_post.call_args_list[0][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/messages" ) assert ( mock_post.call_args_list[1][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/" + "message/draft-id-no/attachments/createUploadSession" ) assert ( mock_post.call_args_list[2][0][0] == "https://graph.microsoft.com/v1.0/users/" + f"{source}/messages/draft-id-no/send" ) mock_post.reset_mock() mock_post.side_effect = None mock_post.return_value = okay_response # # Handle test case where we can not upload chunks # mock_put.return_value = bad_response # We now have to prepare sepparate session attachments using draft emails assert ( obj.notify( body="body", title="title-no-chunk", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/messages" ) assert ( mock_post.call_args_list[1][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/" + "message/draft-id-no/attachments/createUploadSession" ) mock_put.return_value = okay_response mock_post.reset_mock() # Prepare Mock return object payload_missing_id = { "token_type": "Bearer", "expires_in": 6000, "access_token": "abcd1234", # For 'From:' Lookup "mail": "user@example.edu", # For FIle Uploads "uploadUrl": "https://my.url.path/", } temp_response = mock.Mock() temp_response.content = dumps(payload_missing_id) temp_response.status_code = requests.codes.ok mock_post.return_value = temp_response # We could not acquire an attachment id, so we'll fail to send our # notification assert ( obj.notify( body="body", title="title-test", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Large Attachments assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://graph.microsoft.com/v1.0/users/user@example.net/messages" ) mock_post.reset_mock() # Reset attachment size obj.outlook_attachment_inline_max = 50 * 1024 * 1024 assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # already authenticated assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == f"https://graph.microsoft.com/v1.0/users/{source}/sendMail" ) mock_post.reset_mock() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_office365_reply_to(mock_post, mock_get): """NotifyOffice365() reply_to parameter support.""" tenant = "ff-gg-hh-ii-jj" email = "user@example.net" client_id = "aa-bb-cc-dd-ee" secret = "abcd/1234/abcd@ajd@/test" targets = "target@example.com" payload = { "token_type": "Bearer", "expires_in": 6000, "access_token": "abcd1234", "mail": "user@example.net", } okay_response = mock.Mock() okay_response.content = dumps(payload) okay_response.status_code = requests.codes.ok mock_post.return_value = okay_response mock_get.return_value = okay_response # named reply_to via direct instantiation obj = NotifyOffice365( source=email, tenant=tenant, client_id=client_id, secret=secret, targets=[targets], reply_to=["Reply Person "], ) assert isinstance(obj, NotifyOffice365) assert len(obj.reply_to) == 1 assert "reply@example.org" in obj.reply_to # url() round-trip includes reply_to url = obj.url() assert "reply_to=" in url # send() adds replyTo with name to the payload assert obj.notify(title="title", body="test") is True assert mock_post.call_count == 2 sent_payload = loads(mock_post.call_args_list[1][1]["data"]) assert "replyTo" in sent_payload["message"] assert ( sent_payload["message"]["replyTo"][0]["emailAddress"]["address"] == "reply@example.org" ) assert ( sent_payload["message"]["replyTo"][0]["emailAddress"]["name"] == "Reply Person" ) mock_post.reset_mock() # reply_to without a name via URL query string obj2 = Apprise.instantiate( "o365://{email}/{tenant}/{client_id}/{secret}/{targets}" "?reply_to=reply@example.org".format( tenant=tenant, email=email, client_id=client_id, secret=secret, targets=targets, ) ) assert isinstance(obj2, NotifyOffice365) assert obj2.notify(title="t", body="b") is True sent_payload = loads(mock_post.call_args_list[1][1]["data"]) assert "replyTo" in sent_payload["message"] assert "name" not in sent_payload["message"]["replyTo"][0]["emailAddress"] mock_post.reset_mock() # invalid reply_to address is dropped silently obj3 = NotifyOffice365( source=email, tenant=tenant, client_id=client_id, secret=secret, targets=[targets], reply_to=["not-a-valid-email"], ) assert isinstance(obj3, NotifyOffice365) assert len(obj3.reply_to) == 0 # No replyTo in payload when reply_to is empty assert obj3.notify(title="t", body="b") is True sent_payload = loads(mock_post.call_args_list[1][1]["data"]) assert "replyTo" not in sent_payload["message"] mock_post.reset_mock() # parse_url picks up ?reply_to= parsed = NotifyOffice365.parse_url( "o365://{email}/{tenant}/{client_id}/{secret}/{targets}" "?reply_to=reply@example.org".format( tenant=tenant, email=email, client_id=client_id, secret=secret, targets=targets, ) ) assert parsed is not None assert "reply_to" in parsed assert "reply@example.org" in parsed["reply_to"] @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_office365_personal_mode(mock_post, mock_get): """NotifyOffice365() personal mode (refresh_token / consumer accounts).""" from apprise.plugins.office365 import ( Office365Mode, ) client_id = "aa-bb-cc-dd-ee" seed_token = "seed-refresh-token-abc123" new_token = "rotated-refresh-token-xyz789" access_token = "access-token-live" auth_payload = { "token_type": "Bearer", "expires_in": 3600, "access_token": access_token, "refresh_token": new_token, } okay_response = mock.Mock() okay_response.content = dumps(auth_payload) okay_response.status_code = requests.codes.ok bad_response = mock.Mock() bad_response.content = dumps({"error": "bad"}) bad_response.status_code = requests.codes.internal_server_error mock_post.return_value = okay_response mock_get.return_value = okay_response # # Auto-detection: @live.com triggers personal mode # obj = NotifyOffice365( source="user@live.com", client_id=client_id, secret=seed_token, ) assert isinstance(obj, NotifyOffice365) assert obj.mode == Office365Mode.PERSONAL assert obj.tenant is None assert obj.from_email == "user@live.com" assert obj.source_is_object_id is False # All known personal domains trigger personal mode for domain in ("hotmail.com", "outlook.com", "live.com", "msn.com"): o = NotifyOffice365( source=f"user@{domain}", client_id=client_id, secret=seed_token, ) assert o.mode == Office365Mode.PERSONAL, ( f"expected personal mode for {domain}" ) # # Explicit mode=personal on a non-personal domain # obj_explicit = NotifyOffice365( source="user@example.com", client_id=client_id, secret=seed_token, mode=Office365Mode.PERSONAL, ) assert obj_explicit.mode == Office365Mode.PERSONAL # # Explicit mode=org on a personal domain β€” still requires valid email # source (no tenant needed for the personal flow, but org requires tenant) # with pytest.raises(TypeError): # tenant is None but mode=org demands a valid tenant NotifyOffice365( source="user@live.com", client_id=client_id, secret=seed_token, mode=Office365Mode.ORG, ) # # Personal mode requires source to be a valid email # with pytest.raises(TypeError): NotifyOffice365( source="not-an-email", client_id=client_id, secret=seed_token, mode=Office365Mode.PERSONAL, ) # # Invalid mode string raises TypeError # with pytest.raises(TypeError): NotifyOffice365( source="user@live.com", client_id=client_id, secret=seed_token, mode="invalid-mode", ) # # send() uses me/sendMail and me/messages (not users/{source}/...) # obj = NotifyOffice365( source="user@live.com", client_id=client_id, secret=seed_token, targets=["target@example.com"], ) assert obj.mode == Office365Mode.PERSONAL assert obj.notify(title="title", body="body") is True # POST 0: token refresh POST 1: sendMail assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" ) assert ( mock_post.call_args_list[1][0][0] == "https://graph.microsoft.com/v1.0/me/sendMail" ) mock_post.reset_mock() # # Refresh token rotation: store holds the new token; url() emits it # stored = obj.store.get("refresh_token") assert stored == new_token generated_url = obj.url() assert new_token in generated_url assert seed_token not in generated_url assert "mode=personal" in generated_url # Privacy URL masks the token private_url = obj.url(privacy=True) assert new_token not in private_url assert seed_token not in private_url # # Auth failure (bad token response) propagates correctly # mock_post.return_value = bad_response obj2 = NotifyOffice365( source="user@hotmail.com", client_id=client_id, secret=seed_token, ) assert obj2.notify(title="t", body="b") is False mock_post.reset_mock() mock_post.return_value = okay_response # # Auth response missing expires_in # broken_payload = {"access_token": access_token} broken_response = mock.Mock() broken_response.content = dumps(broken_payload) broken_response.status_code = requests.codes.ok mock_post.return_value = broken_response obj3 = NotifyOffice365( source="user@live.com", client_id=client_id, secret=seed_token, ) assert obj3.notify(title="t", body="b") is False mock_post.reset_mock() mock_post.return_value = okay_response # # parse_url: personal domain auto-detects without mode= param # parsed = NotifyOffice365.parse_url( f"o365://user@live.com/{client_id}/{seed_token}/" ) assert parsed is not None assert parsed["mode"] == Office365Mode.PERSONAL assert parsed["tenant"] is None assert parsed["client_id"] == client_id assert parsed["secret"] == seed_token # Reconstructed object from parse_url obj4 = Apprise.instantiate( f"o365://user@live.com/{client_id}/{seed_token}/" ) assert isinstance(obj4, NotifyOffice365) assert obj4.mode == Office365Mode.PERSONAL # # parse_url: explicit ?mode=personal on non-personal domain # parsed2 = NotifyOffice365.parse_url( f"o365://user@example.com/{client_id}/{seed_token}/?mode=personal" ) assert parsed2 is not None assert parsed2["mode"] == Office365Mode.PERSONAL # # parse_url: explicit ?mode=org on personal domain # parsed3 = NotifyOffice365.parse_url( f"o365://user@live.com/tenant-id/{client_id}/{seed_token}/?mode=org" ) assert parsed3 is not None assert parsed3["mode"] == Office365Mode.ORG assert parsed3["tenant"] == "tenant-id" # # url() round-trip for personal mode # obj5 = NotifyOffice365( source="user@outlook.com", client_id=client_id, secret=seed_token, targets=["recipient@example.com"], ) url = obj5.url() assert "mode=personal" in url assert "outlook.com" in url assert client_id in url # No tenant segment assert "tenant" not in url # Round-trip: parse and reconstruct parsed4 = NotifyOffice365.parse_url(url) assert parsed4 is not None assert parsed4["mode"] == Office365Mode.PERSONAL obj6 = Apprise.instantiate(url) assert isinstance(obj6, NotifyOffice365) assert obj6.mode == Office365Mode.PERSONAL # # parse_url: unrecognised ?mode= value falls back to auto-detection # (covers the branch where _mode is NOT in OFFICE365_MODES) # parsed5 = NotifyOffice365.parse_url( f"o365://user@live.com/{client_id}/{seed_token}/?mode=bogus-value" ) assert parsed5 is not None # bogus mode is ignored; auto-detection kicks in β†’ personal assert parsed5["mode"] == Office365Mode.PERSONAL # # Personal mode supports cc/bcc/reply_to and emits them via url() # mock_post.reset_mock() mock_post.return_value = okay_response obj7 = NotifyOffice365( source="user@outlook.com", client_id=client_id, secret=seed_token, targets=["target@example.com"], cc=["Carbon Copy "], bcc=["Blind Copy "], reply_to=["Reply Person "], ) assert isinstance(obj7, NotifyOffice365) personal_url = obj7.url() assert "mode=personal" in personal_url assert "cc=Carbon%20Copy%3Acc%40example.com" in personal_url assert "bcc=Blind%20Copy%3Abcc%40example.com" in personal_url assert "reply_to=Reply%20Person%3Areply%40example.org" in personal_url assert obj7.notify(title="title", body="body") is True assert mock_post.call_count == 2 assert ( mock_post.call_args_list[1][0][0] == "https://graph.microsoft.com/v1.0/me/sendMail" ) sent_payload = loads(mock_post.call_args_list[1][1]["data"]) assert sent_payload["message"]["ccRecipients"][0]["emailAddress"] == { "address": "cc@example.com", "name": "Carbon Copy", } assert sent_payload["message"]["bccRecipients"][0]["emailAddress"] == { "address": "bcc@example.com", "name": "Blind Copy", } assert sent_payload["message"]["replyTo"][0]["emailAddress"] == { "address": "reply@example.org", "name": "Reply Person", } # # Auth succeeds but server returns no new refresh_token # (covers the branch where `if new_refresh:` is falsy) # no_rotate_payload = { "token_type": "Bearer", "expires_in": 3600, "access_token": access_token, # no "refresh_token" key } no_rotate_response = mock.Mock() no_rotate_response.content = dumps(no_rotate_payload) no_rotate_response.status_code = requests.codes.ok send_response = mock.Mock() send_response.content = dumps({}) send_response.status_code = requests.codes.accepted mock_post.side_effect = [no_rotate_response, send_response] obj_nr = NotifyOffice365( source="user@live.com", client_id=client_id, secret=seed_token, targets=["target@example.com"], ) assert obj_nr.notify(title="t", body="b") is True # No new token stored β€” seed_token still in url() assert obj_nr.store.get("refresh_token") is None mock_post.reset_mock() mock_post.side_effect = None mock_post.return_value = okay_response @mock.patch("requests.put") @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_office365_personal_large_attachment( mock_post, mock_get, mock_put ): """Personal mode large-attachment path (me/messages draft + send).""" from apprise.plugins.office365 import Office365Mode client_id = "aa-bb-cc-dd-ee" seed_token = "seed-refresh-token-abc123" new_token = "rotated-refresh-token-xyz789" access_token = "access-token-live" # Response that covers auth tokens, draft ID, and upload URL payload = { "token_type": "Bearer", "expires_in": 3600, "access_token": access_token, "refresh_token": new_token, "id": "draft-id-no", "uploadUrl": "https://upload.url.example/session", } okay_response = mock.Mock() okay_response.content = dumps(payload) okay_response.status_code = requests.codes.ok mock_post.return_value = okay_response mock_get.return_value = okay_response mock_put.return_value = okay_response path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) obj = NotifyOffice365( source="user@live.com", client_id=client_id, secret=seed_token, targets=["target@example.com"], ) assert obj.mode == Office365Mode.PERSONAL # Force all attachments to be "large" so the draft path is used obj.outlook_attachment_inline_max = 50 assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # POST 0: token refresh POST 1: draft create POST 2: upload session # POST 3: send draft assert mock_post.call_count == 4 assert ( mock_post.call_args_list[0][0][0] == "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" ) assert ( mock_post.call_args_list[1][0][0] == "https://graph.microsoft.com/v1.0/me/messages" ) assert ( mock_post.call_args_list[2][0][0] == "https://graph.microsoft.com/v1.0/me/" "message/draft-id-no/attachments/createUploadSession" ) assert ( mock_post.call_args_list[3][0][0] == "https://graph.microsoft.com/v1.0" "/me/messages/draft-id-no/send" ) def test_plugin_office365_cc_bcc_invalid_branch(): """NotifyOffice365() CC/BCC/Reply-To invalid entries dropped.""" # Build a NotifyOffice365 in personal mode with: # cc = one valid address + one that parse_emails passes through # but is_email rejects (hits the FALSE branch in the CC loop). # A list must be used -- parse_emails filters invalid tokens # out of comma-separated strings before is_email is called. # bcc = same, for the FALSE branch in the BCC loop # reply_to = same, for the FALSE branch in the Reply-To loop obj = NotifyOffice365( client_id="aa-bb-cc", secret="seed-refresh-token", source="user@live.com", targets=["target@example.com"], cc=["good@example.com", "notanemail"], bcc=["bcc@example.com", "alsonotanemail"], reply_to=["reply@example.com", "badreply"], ) # Valid CC entry was added; invalid one was silently dropped assert "good@example.com" in obj.cc assert len(obj.cc) == 1 # Valid BCC entry was added; invalid one was silently dropped assert "bcc@example.com" in obj.bcc assert len(obj.bcc) == 1 # Valid Reply-To entry was added; invalid one was silently dropped assert "reply@example.com" in obj.reply_to assert len(obj.reply_to) == 1 caronc-apprise-182f859/tests/test_plugin_onesignal.py000066400000000000000000000373171524161175200230370ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.plugins.one_signal import NotifyOneSignal logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "onesignal://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "onesignal://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "onesignal://apikey/", { # no app id specified "instance": TypeError, }, ), ( "onesignal://appid@%20%20/", { # invalid apikey "instance": TypeError, }, ), ( "onesignal://appid@apikey/playerid/?lang=X", { # invalid language id (must be 2 characters) "instance": TypeError, }, ), ( "onesignal://appid@apikey/", { # No targets specified; we will initialize but not notify anything "instance": NotifyOneSignal, "notify_response": False, }, ), ( "onesignal://appid@apikey/playerid", { # Valid playerid "instance": NotifyOneSignal, "privacy_url": "onesignal://a...d@a...y/playerid", }, ), ( "onesignal://appid@apikey/player", { # Valid player id "instance": NotifyOneSignal, # don't include an image by default "include_image": False, }, ), ( "onesignal://appid@apikey/@user?image=no", { # Valid userid, no image "instance": NotifyOneSignal, }, ), ( "onesignal://appid@apikey/user@email.com/#seg/player/@user/%20/a", { # Valid email, valid playerid, valid user, invalid entry (%20), # and too short of an entry (a) "instance": NotifyOneSignal, }, ), ( "onesignal://appid@apikey?to=#segment,playerid", { # Test to= "instance": NotifyOneSignal, }, ), ( "onesignal://appid@apikey/#segment/@user/?batch=yes", { # Test batch= "instance": NotifyOneSignal, }, ), ( "onesignal://appid@apikey/#segment/@user/?batch=no", { # Test batch= "instance": NotifyOneSignal, }, ), ( "onesignal://templateid:appid@apikey/playerid", { # Test Template ID "instance": NotifyOneSignal, }, ), ( "onesignal://appid@apikey/playerid/?lang=es&subtitle=Sub", { # Test Language and Subtitle Over-ride "instance": NotifyOneSignal, }, ), ( "onesignal://?apikey=abc&template=tp&app=123&to=playerid", { # Test Kwargs "instance": NotifyOneSignal, }, ), ( ( "onesignal://?apikey=abc&template=tp&app=123&to=playerid&body=no" "&:key1=val1&:key2=val2" ), { # Test Kwargs "instance": NotifyOneSignal, }, ), ( ( "onesignal://?apikey=abc&template=tp&app=123&to=playerid&body=no" "&+key1=val1&+key2=val2" ), { # Test Kwargs "instance": NotifyOneSignal, }, ), ( "onesignal://appid@apikey/#segment/playerid/", { "instance": NotifyOneSignal, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "onesignal://appid@apikey/#segment/playerid/", { "instance": NotifyOneSignal, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_onesignal_urls(): """NotifyOneSignal() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_onesignal_edge_cases(): """NotifyOneSignal() Batch Validation.""" obj = Apprise.instantiate( "onesignal://appid@apikey/#segment/@user/playerid/user@email.com" "/?batch=yes" ) # Validate that it loaded okay assert isinstance(obj, NotifyOneSignal) # all 4 types defined; but even in a batch mode, they can not be # sent in one submission assert len(obj) == 4 # # Users # obj = Apprise.instantiate( "onesignal://appid@apikey/@user1/@user2/@user3/@user4/?batch=yes" ) assert isinstance(obj, NotifyOneSignal) # We can lump these together - no problem assert len(obj) == 1 # Same query, but no batch mode set obj = Apprise.instantiate( "onesignal://appid@apikey/@user1/@user2/@user3/@user4/?batch=no" ) assert isinstance(obj, NotifyOneSignal) # Individual queries assert len(obj) == 4 # # Segments # obj = Apprise.instantiate( "onesignal://appid@apikey/#segment1/#seg2/#seg3/#seg4/?batch=yes" ) assert isinstance(obj, NotifyOneSignal) # We can lump these together - no problem assert len(obj) == 1 # Same query, but no batch mode set obj = Apprise.instantiate( "onesignal://appid@apikey/#segment1/#seg2/#seg3/#seg4/?batch=no" ) assert isinstance(obj, NotifyOneSignal) # Individual queries assert len(obj) == 4 # # Player ID's # obj = Apprise.instantiate( "onesignal://appid@apikey/pid1/pid2/pid3/pid4/?batch=yes" ) assert isinstance(obj, NotifyOneSignal) # We can lump these together - no problem assert len(obj) == 1 # Same query, but no batch mode set obj = Apprise.instantiate( "onesignal://appid@apikey/pid1/pid2/pid3/pid4/?batch=no" ) assert isinstance(obj, NotifyOneSignal) # Individual queries assert len(obj) == 4 # # Emails # emails = ("abc@yahoo.ca", "def@yahoo.ca", "ghi@yahoo.ca", "jkl@yahoo.ca") obj = Apprise.instantiate( "onesignal://appid@apikey/{}/?batch=yes".format("/".join(emails)) ) assert isinstance(obj, NotifyOneSignal) # We can lump these together - no problem assert len(obj) == 1 # Same query, but no batch mode set obj = Apprise.instantiate( "onesignal://appid@apikey/{}/?batch=no".format("/".join(emails)) ) assert isinstance(obj, NotifyOneSignal) # Individual queries assert len(obj) == 4 # # Mixed # emails = ("abc@yahoo.ca", "def@yahoo.ca", "ghi@yahoo.ca", "jkl@yahoo.ca") users = ("@user1", "@user2", "@user3", "@user4") players = ("player1", "player2", "player3", "player4") segments = ("#seg1", "#seg2", "#seg3", "#seg4") path = "{}/{}/{}/{}".format( "/".join(emails), "/".join(users), "/".join(players), "/".join(segments), ) obj = Apprise.instantiate(f"onesignal://appid@apikey/{path}/?batch=yes") assert isinstance(obj, NotifyOneSignal) # We can lump these together - no problem assert len(obj) == 4 # Same query, but no batch mode set obj = Apprise.instantiate(f"onesignal://appid@apikey/{path}/?batch=no") assert isinstance(obj, NotifyOneSignal) # Individual queries assert len(obj) == 16 # custom must be a dictionary with pytest.raises(TypeError): NotifyOneSignal( app="appid", apikey="key", targets=["@user"], custom="not-a-dict" ) # postback must be a dictionary with pytest.raises(TypeError): NotifyOneSignal( app="appid", apikey="key", targets=["@user"], custom=[], postback="not-a-dict", ) def test_plugin_onesignal_url_round_trip(): """NotifyOneSignal() url() is reloadable without mutating targets.""" obj = Apprise.instantiate( "onesignal://appid@apikey/@user/#segment/player/user@email.com" ) assert isinstance(obj, NotifyOneSignal) # The user and segment prefixes are stored as part of the target assert obj.targets["include_external_user_ids"] == ["@user"] assert obj.targets["included_segments"] == ["#segment"] # Reloading the url() must not double the @ / # prefixes, and the # generated url() must be stable (idempotent) across reloads. url = obj.url() reloaded = Apprise.instantiate(url) assert isinstance(reloaded, NotifyOneSignal) assert reloaded.targets["include_external_user_ids"] == ["@user"] assert reloaded.targets["included_segments"] == ["#segment"] assert reloaded.url() == url @mock.patch("requests.post") def test_plugin_onesignal_notifications(mock_post): """OneSignal() Notifications Support.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Load URL with Template instance = Apprise.instantiate( "onesignal://templateid:appid@apikey/@user/?:key1=value1&+key3=value3" ) # Validate that it loaded okay assert isinstance(instance, NotifyOneSignal) response = instance.notify("hello world") assert response is True assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.onesignal.com/notifications" ) details = mock_post.call_args_list[0] payload = loads(details[1]["data"]) assert payload == { "app_id": "appid", "contents": {"en": "hello world"}, "content_available": True, "template_id": "templateid", "custom_data": {"key1": "value1"}, "data": {"key3": "value3"}, "large_icon": ( "https://github.com/caronc/apprise" "/raw/master/apprise/assets/themes/default/apprise-info-72x72.png" ), "small_icon": ( "https://github.com/caronc/apprise" "/raw/master/apprise/assets/themes/default/apprise-info-32x32.png" ), "include_external_user_ids": ["@user"], } mock_post.reset_mock() # Load URL with Template and disable body instance = Apprise.instantiate( "onesignal://templateid:appid@apikey/@user/?contents=no" ) # Validate that it loaded okay assert isinstance(instance, NotifyOneSignal) response = instance.notify("hello world") assert response is True assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.onesignal.com/notifications" ) details = mock_post.call_args_list[0] payload = loads(details[1]["data"]) assert payload == { "app_id": "appid", "content_available": True, "template_id": "templateid", "large_icon": ( "https://github.com/caronc/apprise" "/raw/master/apprise/assets/themes/default/apprise-info-72x72.png" ), "small_icon": ( "https://github.com/caronc/apprise" "/raw/master/apprise/assets/themes/default/apprise-info-32x32.png" ), "include_external_user_ids": ["@user"], } # Now set a title mock_post.reset_mock() response = instance.notify("hello world", title="mytitle") assert response is True assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.onesignal.com/notifications" ) details = mock_post.call_args_list[0] payload = loads(details[1]["data"]) assert payload == { "app_id": "appid", "headings": {"en": "mytitle"}, "content_available": True, "template_id": "templateid", "large_icon": ( "https://github.com/caronc/apprise" "/raw/master/apprise/assets/themes/default/apprise-info-72x72.png" ), "small_icon": ( "https://github.com/caronc/apprise" "/raw/master/apprise/assets/themes/default/apprise-info-32x32.png" ), "include_external_user_ids": ["@user"], } # Test without decoding parameters instance = Apprise.instantiate( "onesignal://templateid:appid@apikey/@user/" "?:par=b64:eyJhIjoxLCJiIjoyfQ==&decode=no" ) assert isinstance(instance, NotifyOneSignal) and instance.custom_data == { "par": "b64:eyJhIjoxLCJiIjoyfQ==" } # Now same with loading parameters instance = Apprise.instantiate( "onesignal://templateid:appid@apikey/@user/" "?:par=b64:eyJhIjoxLCJiIjoyfQ==&decode=yes" ) assert isinstance(instance, NotifyOneSignal) and instance.custom_data == { "par": {"a": 1, "b": 2} } # Test bad data in general instance = Apprise.instantiate( "onesignal://templateid:appid@apikey/@user/?:par=garbage1&decode=yes" ) assert isinstance(instance, NotifyOneSignal) and instance.custom_data == { "par": "garbage1" } instance = Apprise.instantiate( "onesignal://templateid:appid@apikey/@user/" "?:par=b64:garbage2&decode=yes" ) assert isinstance(instance, NotifyOneSignal) and instance.custom_data == { "par": "b64:garbage2" } instance = Apprise.instantiate( "onesignal://templateid:appid@apikey/@user/" "?:par=b64:garbage3==&decode=yes" ) assert isinstance(instance, NotifyOneSignal) and instance.custom_data == { "par": "b64:garbage3==" } # Now same with not-base64 parameters instance = Apprise.instantiate( "onesignal://templateid:appid@apikey/@user/" "?:par=eyJhIjoxLCJiIjoyfQ==&:par2=123&decode=yes" ) assert isinstance(instance, NotifyOneSignal) and instance.custom_data == { "par": "eyJhIjoxLCJiIjoyfQ==", "par2": "123", } # Test incorrect base64 parameters. Second one has incorrect padding url = ( "onesignal://templateid:appid@apikey/@user/" "?:par=b64:1234=&:par2=b64:eyJhIjoxLCJiIjoyfQ&" ":par3=b64:eyJhIjoxLCJiIjoyfQ==&decode=yes" ) instance = Apprise.instantiate(url) assert isinstance(instance, NotifyOneSignal) and instance.custom_data == { "par": "b64:1234=", "par2": "b64:eyJhIjoxLCJiIjoyfQ", "par3": {"a": 1, "b": 2}, } caronc-apprise-182f859/tests/test_plugin_opsgenie.py000066400000000000000000000365341524161175200226710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests import apprise from apprise.plugins.opsgenie import ( NotifyOpsgenie, NotifyType, OpsgeniePriority, ) logging.disable(logging.CRITICAL) # a test UUID we can use UUID4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752" OPSGENIE_GOOD_RESPONSE = dumps( { "result": "Request will be processed", "took": 0.204, "requestId": "43a29c5c-3dbf-4fa4-9c26-f4f71023e120", } ) # Our Testing URLs apprise_url_tests = ( ( "opsgenie://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "opsgenie://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "opsgenie://%20%20/", { # invalid apikey specified "instance": TypeError, }, ), ( "opsgenie://apikey/user/?region=xx", { # invalid region id "instance": TypeError, }, ), ( "opsgenie://user@apikey/", { # No targets specified; this is allowed "instance": NotifyOpsgenie, "notify_type": NotifyType.WARNING, # Bad response returned "requests_response_text": "{", # We will not be successful sending the notice "notify_response": False, }, ), ( "opsgenie://apikey/", { # No targets specified; this is allowed "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/user", { # Valid user "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, "privacy_url": "opsgenie://a...y/%40user", }, ), ( "opsgenie://apikey/@user?region=eu", { # European Region "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/@user?entity=A%20Entity", { # Assign an entity "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/@user?alias=An%20Alias", { # Assign an alias "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), # Bad Action ( "opsgenie://apikey/@user?action=invalid", { # Assign an entity "instance": TypeError, }, ), ( "opsgenie://from@apikey/@user?:invalid=note", { # Assign an entity "instance": TypeError, }, ), ( "opsgenie://apikey/@user?:warning=invalid", { # Assign an entity "instance": TypeError, }, ), # Creates an index entry ( "opsgenie://apikey/@user?entity=index&action=new", { # Assign an entity "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), # Now action it ( "opsgenie://apikey/@user?entity=index&action=acknowledge", { # Assign an entity "instance": NotifyOpsgenie, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://from@apikey/@user?entity=index&action=note", { # Assign an entity "instance": NotifyOpsgenie, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://from@apikey/@user?entity=index&action=note", { # Assign an entity "instance": NotifyOpsgenie, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, "response": False, "requests_response_code": 500, }, ), ( "opsgenie://apikey/@user?entity=index&action=close", { # Assign an entity "instance": NotifyOpsgenie, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/@user?entity=index&action=delete", { # Assign an entity "instance": NotifyOpsgenie, "notify_type": NotifyType.SUCCESS, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), # map info messages to generate a new message ( "opsgenie://apikey/@user?entity=index2&:info=new", { # Assign an entity "instance": NotifyOpsgenie, "notify_type": NotifyType.INFO, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://joe@apikey/@user?priority=p3", { # Assign our priority "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/?tags=comma,separated", { # Test our our 'tags' (tag is reserved in Apprise) but not 'tags' # Also test the fact we do not need to define a target "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/@user?priority=invalid", { # Invalid priority (loads using default) "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/user@email.com/#team/*sche/^esc/%20/a", { # Valid user (email), valid schedule, Escalated ID, # an invalid entry (%20), and too short of an entry (a) "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( f"opsgenie://apikey/@{UUID4}/#{UUID4}/*{UUID4}/^{UUID4}/", { # similar to the above, except we use the UUID's "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), # Same link as before but @ missing at the front causing an ambigious # lookup however the entry is treated a though a @ was in front (user) ( f"opsgenie://apikey/{UUID4}/#{UUID4}/*{UUID4}/^{UUID4}/", { # similar to the above, except we use the UUID's "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey?to=#team,user&+key=value&+type=override", { # Test to= and details (key/value pair) also override 'type' "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/#team/@user/?batch=yes", { # Test batch= "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/#team/@user/?batch=no", { # Test batch= "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://?apikey=abc&to=user", { # Test Kwargs "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, }, ), ( "opsgenie://apikey/#team/user/", { "instance": NotifyOpsgenie, # throw a bizarre code forcing us to fail to look it up "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, "response": False, "requests_response_code": 999, }, ), ( "opsgenie://apikey/#topic1/device/", { "instance": NotifyOpsgenie, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": OPSGENIE_GOOD_RESPONSE, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_opsgenie_urls(tmpdir): """NotifyOpsgenie() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all(str(tmpdir)) @mock.patch("requests.post") def test_plugin_opsgenie_config_files(mock_post): """NotifyOpsgenie() Config File Cases.""" content = """ urls: - opsgenie://apikey/user: - priority: 1 tag: opsgenie_int low - priority: "1" tag: opsgenie_str_int low - priority: "p1" tag: opsgenie_pstr_int low - priority: low tag: opsgenie_str low # This will take on moderate (default) priority - priority: invalid tag: opsgenie_invalid - opsgenie://apikey2/user2: - priority: 5 tag: opsgenie_int emerg - priority: "5" tag: opsgenie_str_int emerg - priority: "p5" tag: opsgenie_pstr_int emerg - priority: emergency tag: opsgenie_str emerg """ # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = OPSGENIE_GOOD_RESPONSE # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 9 servers from that # 4x low # 4x emerg # 1x invalid (so takes on normal priority) assert len(ac.servers()) == 9 assert len(aobj) == 9 assert len(list(aobj.find(tag="low"))) == 4 for s in aobj.find(tag="low"): assert s.priority == OpsgeniePriority.LOW assert len(list(aobj.find(tag="emerg"))) == 4 for s in aobj.find(tag="emerg"): assert s.priority == OpsgeniePriority.EMERGENCY assert len(list(aobj.find(tag="opsgenie_str"))) == 2 assert len(list(aobj.find(tag="opsgenie_str_int"))) == 2 assert len(list(aobj.find(tag="opsgenie_pstr_int"))) == 2 assert len(list(aobj.find(tag="opsgenie_int"))) == 2 assert len(list(aobj.find(tag="opsgenie_invalid"))) == 1 assert ( next(aobj.find(tag="opsgenie_invalid")).priority == OpsgeniePriority.NORMAL ) @mock.patch("requests.post") def test_plugin_opsgenie_edge_case(mock_post): """NotifyOpsgenie() Edge Cases.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = OPSGENIE_GOOD_RESPONSE instance = apprise.Apprise.instantiate("opsgenie://apikey") assert isinstance(instance, NotifyOpsgenie) assert len(instance.store.keys()) == 0 assert instance.notify("test", "key", NotifyType.FAILURE) is True assert len(instance.store.keys()) == 1 # Again just causes same index to get over-written assert instance.notify("test", "key", NotifyType.FAILURE) is True assert len(instance.store.keys()) == 1 assert "a62f2225bf" in instance.store # Assign it garbage instance.store["a62f2225bf"] = "garbage" # This causes an internal check to fail where the keys are expected to be # as a list (this one is now a string) # content self corrects and things are fine assert instance.notify("test", "key", NotifyType.FAILURE) is True assert len(instance.store.keys()) == 1 # new key is new index assert instance.notify("test", "key2", NotifyType.FAILURE) is True assert len(instance.store.keys()) == 2 caronc-apprise-182f859/tests/test_plugin_pagerduty.py000066400000000000000000000144301524161175200230530ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise, NotifyType from apprise.plugins.pagerduty import NotifyPagerDuty logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "pagerduty://", { # No Access Token or Integration/Routing Key specified "instance": TypeError, }, ), ( "pagerduty://%20@%20/", { # invalid Access Token and Integration/Routing Key "instance": TypeError, }, ), ( "pagerduty://%20/", { # invalid Access Token; no Integration/Routing Key "instance": TypeError, }, ), ( "pagerduty://%20@abcd/", { # Invalid Integration/Routing Key (but valid Access Token) "instance": TypeError, }, ), ( "pagerduty://myroutekey@myapikey/%20", { # bad source "instance": TypeError, }, ), ( "pagerduty://myroutekey@myapikey/mysource/%20", { # bad component "instance": TypeError, }, ), ( "pagerduty://myroutekey@myapikey?region=invalid", { # invalid region "instance": TypeError, }, ), ( "pagerduty://myroutekey@myapikey?severity=invalid", { # invalid severity "instance": TypeError, }, ), ( "pagerduty://myroutekey@myapikey", { # minimum requirements met "instance": NotifyPagerDuty, # Our expected url(privacy=True) startswith() response: "privacy_url": "pagerduty://****@****/A...e/N...n?", }, ), ( "pagerduty://myroutekey@myapikey?image=no", { # minimum requirements met and disable images "instance": NotifyPagerDuty, }, ), ( "pagerduty://myroutekey@myapikey?region=eu", { # european region "instance": NotifyPagerDuty, }, ), ( "pagerduty://myroutekey@myapikey?severity=critical", { # Severity over-ride "instance": NotifyPagerDuty, }, ), ( "pagerduty://myroutekey@myapikey?severity=err", { # Severity over-ride (short-form) "instance": NotifyPagerDuty, }, ), # Custom values ( "pagerduty://myroutekey@myapikey?+key=value&+key2=value2", { # minimum requirements and support custom key/value pairs "instance": NotifyPagerDuty, }, ), ( "pagerduty://myroutekey@myapikey/mysource/mycomponent", { # a valid url "instance": NotifyPagerDuty, # Our expected url(privacy=True) startswith() response: "privacy_url": "pagerduty://****@****/m...e/m...t?", }, ), ( "pagerduty://routekey@apikey/ms/mc?group=mygroup&class=myclass", { # class/group testing "instance": NotifyPagerDuty, }, ), ( ( "pagerduty://?integrationkey=r&apikey=a&source=s&component=c" "&group=g&class=c&image=no&click=http://localhost" ), { # all parameters "instance": NotifyPagerDuty }, ), ( "pagerduty://somerkey@someapikey/bizarre/code", { "instance": NotifyPagerDuty, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pagerduty://myroutekey@myapikey/mysource/mycomponent", { "instance": NotifyPagerDuty, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_pagerduty_urls(): """NotifyPagerDuty() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_pagerduty_notify_type_is_string(mock_post): response = mock.Mock() response.status_code = requests.codes.ok response.content = "" mock_post.return_value = response obj = Apprise.instantiate("pagerduty://myroutekey@myapikey") assert isinstance(obj, NotifyPagerDuty) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 1 call = mock_post.call_args_list[0] # PagerDuty uses JSON payload payload = call[1].get("data") or call[1].get("json") payload_text = payload if isinstance(payload, str) else str(payload) assert "NotifyType." not in payload_text caronc-apprise-182f859/tests/test_plugin_pagertree.py000066400000000000000000000122601524161175200230240ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.pagertree import NotifyPagerTree logging.disable(logging.CRITICAL) # a test UUID we can use INTEGRATION_ID = "int_xxxxxxxxxxx" # Our Testing URLs apprise_url_tests = ( ( "pagertree://", { # Missing Integration ID "instance": TypeError, }, ), # Invalid Integration ID ( "pagertree://%s" % ("+" * 24), { "instance": TypeError, }, ), # Minimum requirements met ( f"pagertree://{INTEGRATION_ID}", { "instance": NotifyPagerTree, # Our expected url(privacy=True) startswith() response: "privacy_url": "pagertree://i...x?", }, ), # change the integration id ( f"pagertree://{INTEGRATION_ID}?integration=int_yyyyyyyyyy", { "instance": NotifyPagerTree, # Our expected url(privacy=True) startswith() response: "privacy_url": "pagertree://i...y?", }, ), # entries specified on the URL will over-ride the host (integration id) ( f"pagertree://{INTEGRATION_ID}?id=int_zzzzzzzzzz", { "instance": NotifyPagerTree, # Our expected url(privacy=True) startswith() response: "privacy_url": "pagertree://i...z?", }, ), # Integration ID + bad url ( "pagertree://:@/", { "instance": TypeError, }, ), ( f"pagertree://{INTEGRATION_ID}", { "instance": NotifyPagerTree, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( f"pagertree://{INTEGRATION_ID}", { "instance": NotifyPagerTree, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( f"pagertree://{INTEGRATION_ID}", { "instance": NotifyPagerTree, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( f"pagertree://{INTEGRATION_ID}?urgency=low", { # urgency override "instance": NotifyPagerTree, }, ), ( f"pagertree://?id={INTEGRATION_ID}&urgency=low", { # urgency override and id= (for integration) "instance": NotifyPagerTree, }, ), ( f"pagertree://{INTEGRATION_ID}?tags=production,web", { # tags "instance": NotifyPagerTree, }, ), ( f"pagertree://{INTEGRATION_ID}?action=resolve&thirdparty=123", { # test resolve "instance": NotifyPagerTree, }, ), # Custom values ( f"pagertree://{INTEGRATION_ID}?+pagertree-token=123&:env=prod&-m=v", { # minimum requirements and support custom key/value pairs "instance": NotifyPagerTree, }, ), ) def test_plugin_pagertree_urls(): """NotifyPagerTree() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_pagertree_general(mock_post): """NotifyPagerTree() General Checks.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Invalid thirdparty id with pytest.raises(TypeError): NotifyPagerTree(integration=INTEGRATION_ID, thirdparty=" ") caronc-apprise-182f859/tests/test_plugin_parse_platform.py000066400000000000000000000100451524161175200240630ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.parseplatform import NotifyParsePlatform logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "parsep://", { "instance": None, }, ), # API Key + bad url ( "parsep://:@/", { "instance": None, }, ), # APIkey; no app_id or master_key ( "parsep://%s" % ("a" * 32), { "instance": TypeError, }, ), # APIkey; no master_key ( "parsep://app_id@%s" % ("a" * 32), { "instance": TypeError, }, ), # APIkey; no app_id ( "parseps://:master_key@%s" % ("a" * 32), { "instance": TypeError, }, ), # app_id + master_key (using arguments=) ( "parseps://localhost?app_id={}&master_key={}".format( "a" * 32, "d" * 32 ), { "instance": NotifyParsePlatform, # Our expected url(privacy=True) startswith() response: "privacy_url": "parseps://a...a:d...d@localhost", }, ), # Set a device id + custom port ( "parsep://app_id:master_key@localhost:8080?device=ios", { "instance": NotifyParsePlatform, }, ), # invalid device id ( "parsep://app_id:master_key@localhost?device=invalid", { "instance": TypeError, }, ), # Normal Query ( "parseps://app_id:master_key@localhost", { "instance": NotifyParsePlatform, }, ), ( "parseps://app_id:master_key@localhost", { "instance": NotifyParsePlatform, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "parseps://app_id:master_key@localhost", { "instance": NotifyParsePlatform, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "parseps://app_id:master_key@localhost", { "instance": NotifyParsePlatform, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_parse_platform_urls(): """NotifyParsePlatform() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_pinglet.py000066400000000000000000000232251524161175200225130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests import apprise from apprise import NotifyType from apprise.plugins.pinglet import NotifyPinglet, PingletPriority logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "pinglet://", { "instance": None, }, ), # An invalid url ( "pinglet://:@/", { "instance": None, }, ), # No API Key specified ( "pinglet://hostname/acme/deploys", { "instance": TypeError, }, ), # No namespace and/or topic specified ( "pinglet://token@hostname", { "instance": TypeError, }, ), ( "pinglet://token@hostname/deploys", { "instance": TypeError, }, ), # Provide an API Key, namespace, and topic ( "pinglet://abc123@hostname/acme/deploys", { "instance": NotifyPinglet, # Our expected url(privacy=True) startswith() response: "privacy_url": "pinglet://****@hostname/acme/deploys", }, ), # Secure protocol ( "pinglets://abc123@hostname/acme/deploys", { "instance": NotifyPinglet, "privacy_url": "pinglets://****@hostname/acme/deploys", }, ), # API Key as a query argument ( "pinglets://hostname/acme/deploys?token=abc123", { "instance": NotifyPinglet, "privacy_url": "pinglets://****@hostname/acme/deploys", }, ), # A path prefix (such as a reverse-proxy mount point) ( "pinglet://abc123@hostname/prefix/acme/deploys", { "instance": NotifyPinglet, "privacy_url": "pinglet://****@hostname/prefix/acme/deploys", }, ), # Host with a port ( "pinglet://abc123@hostname:8080/acme/deploys", { "instance": NotifyPinglet, "privacy_url": "pinglet://****@hostname:8080/acme/deploys", }, ), # Provide a priority ( "pinglet://abc123@hostname/acme/deploys?priority=urgent", { "instance": NotifyPinglet, }, ), # Priorities support prefix matching (s => silent) ( "pinglet://abc123@hostname/acme/deploys?priority=s", { "instance": NotifyPinglet, }, ), # An invalid priority takes on the default (normal) ( "pinglet://abc123@hostname/acme/deploys?priority=invalid", { "instance": NotifyPinglet, }, ), # Badges (:key=value) and metadata (+key=value) ( "pinglet://abc123@hostname/acme/deploys?:CPU=95%25&+region=eu-west", { "instance": NotifyPinglet, }, ), ( "pinglet://abc123@hostname/acme/deploys", { "instance": NotifyPinglet, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pinglets://abc123@localhost/acme/deploys", { "instance": NotifyPinglet, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pinglet://abc123@localhost/acme/deploys", { "instance": NotifyPinglet, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_pinglet_urls(): """NotifyPinglet() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_pinglet_edge_cases(): """NotifyPinglet() Edge Cases.""" # Initializes the plugin with an invalid token with pytest.raises(TypeError): NotifyPinglet(token=None, namespace="acme", topic="deploys") # Whitespace also acts as an invalid token value with pytest.raises(TypeError): NotifyPinglet(token=" ", namespace="acme", topic="deploys") # Missing namespace and/or topic with pytest.raises(TypeError): NotifyPinglet(token="abc123", namespace=None, topic="deploys") with pytest.raises(TypeError): NotifyPinglet(token="abc123", namespace="acme", topic=None) # Direct instantiation without a fullpath defaults to "/" obj = NotifyPinglet(token="abc123", namespace="acme", topic="deploys") assert obj.fullpath == "/" @mock.patch("requests.post") def test_plugin_pinglet_payload(mock_post): """NotifyPinglet() Payload Construction.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok aobj = apprise.Apprise() assert aobj.add( "pinglets://mykey@app.pinglet.co.uk/acme/deploys" "?priority=urgent&:CPU=95%25&:Host=web-1&+region=eu-west" ) assert ( aobj.notify( title="Hello", body="It works", notify_type=NotifyType.FAILURE, ) is True ) assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://app.pinglet.co.uk/acme/deploys" ) headers = mock_post.call_args_list[0][1]["headers"] assert headers["Authorization"] == "Bearer mykey" payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["title"] == "Hello" assert payload["message"] == "It works" assert payload["priority"] == PingletPriority.URGENT # The FAILURE notification type maps to Pinglet's 'error' level assert payload["level"] == "error" assert payload["badges"] == {"CPU": "95%", "Host": "web-1"} assert payload["data"] == {"region": "eu-west"} @mock.patch("requests.post") def test_plugin_pinglet_payload_defaults(mock_post): """NotifyPinglet() Payload Defaults.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok aobj = apprise.Apprise() assert aobj.add("pinglet://mykey@hostname/acme/deploys") assert aobj.notify(body="It works") is True assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "http://hostname/acme/deploys" payload = loads(mock_post.call_args_list[0][1]["data"]) # An empty title is omitted entirely assert "title" not in payload # No badges/metadata were specified so they are omitted assert "badges" not in payload assert "data" not in payload assert payload["priority"] == PingletPriority.NORMAL # The INFO notification type maps to Pinglet's 'info' level assert payload["level"] == "info" def test_plugin_pinglet_limits(): """NotifyPinglet() Badge and Metadata Limits.""" # Badges are capped at 3 entries; keys are truncated to 24 # characters, and values to 32 obj = apprise.Apprise.instantiate( "pinglet://abc123@hostname/acme/deploys" "?:{}={}&:b=2&:c=3&:d=4&:e=5".format("k" * 30, "v" * 40) ) assert isinstance(obj, NotifyPinglet) assert len(obj.badges) == 3 assert "k" * 24 in obj.badges assert obj.badges["k" * 24] == "v" * 32 # Metadata keys are truncated to 64 characters, and values to 256 obj = apprise.Apprise.instantiate( "pinglet://abc123@hostname/acme/deploys?+{}={}".format( "k" * 70, "v" * 300 ) ) assert isinstance(obj, NotifyPinglet) assert obj.data == {"k" * 64: "v" * 256} def test_plugin_pinglet_priorities(): """NotifyPinglet() Priority Handling.""" for priority, expected in ( ("silent", PingletPriority.SILENT), ("s", PingletPriority.SILENT), ("NORMAL", PingletPriority.NORMAL), ("urgent", PingletPriority.URGENT), ("u", PingletPriority.URGENT), # Invalid entries take on the default ("invalid", PingletPriority.NORMAL), ("", PingletPriority.NORMAL), ): obj = apprise.Apprise.instantiate( f"pinglet://abc123@hostname/acme/deploys?priority={priority}" ) assert isinstance(obj, NotifyPinglet) assert obj.priority == expected, f"priority={priority}" caronc-apprise-182f859/tests/test_plugin_pingram.py000066400000000000000000000564221524161175200225130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps, loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.plugins.pingram import NotifyPingram logging.disable(logging.CRITICAL) PINGRAM_GOOD_RESPONSE = dumps({"trackingId": "abc123"}) PINGRAM_BAD_RESPONSE = "{" # Our Testing URLs apprise_url_tests = ( ( "pingram://", { # No API Key at all "instance": TypeError, }, ), ( "pingram://:@/", { "instance": TypeError, }, ), ( "pingram://abcd", { # Doesn't match the pingram_(sk|pk)_ prefix "instance": TypeError, }, ), ( "pingram://pingram_sk_key/+15551235553/?mode=invalid", { # Invalid mode "instance": TypeError, }, ), ( "pingram://pingram_sk_key/+15551235553/?region=invalid", { # Invalid region "instance": TypeError, }, ), ( "pingram://pingram_sk_key/+15551235553/?type=*(", { # Invalid type "instance": TypeError, }, ), ( "pingram://pingram_sk_key/+15551235553/?channels=bad", { # Invalid channel "instance": TypeError, }, ), ( "pingram://pingram_sk_abc123/g@rb@ge/+15551235553/", { # g@rb@ge entry ignored (invalid target, dropped with a # warning, preserved for round-trip only) "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/g@rb@ge/", { # Only an invalid target specified; nothing to notify "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, "notify_response": False, }, ), ( "pingram://pingram_sk_abc123/user1@example.com/user2@example.com", { # Two emails back to back with no id between them each # become their own recipient "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/+15551235553", { # A bare phone number with no id is valid "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_pk_abc123/user@example.ca", { # A bare email with no id is valid; public key prefix works "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/+15551235553/+15551235554", { # Multiple id-less phone numbers become separate targets "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/+15551235553", { # An id can still optionally be supplied "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/id2/user1@example.com", { # two ids in a row; first is dropped in favor of the second "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( ( "pingram://type@pingram_sk_abc123/id10/user2@example.com/" "id5/+15551235555/id8/+15551235534" "?reply=Chris" ), { "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( ( "pingram://type@pingram_sk_abc123/abc1/user1@example.com/" "id5/+15551235555/?from=Chris&reply=Christopher" ), { "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( ( "pingram://type@pingram_sk_abc123/user3@example.com/" "?from=joe@example.ca&reply=user@abc.com" ), { # Set from/source "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( ( "pingram://type@pingram_sk_abc123/user4@example.com/" "?from=joe@example.ca&bcc=user1@yahoo.ca&cc=user2@yahoo.ca" ), { "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: "privacy_url": "pingram://type@p...3/", }, ), ( "pingram://?apikey=pingram_sk_abc123&to=id,user5@example.com" "&type=typec", { # use just kwargs "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, "privacy_url": "pingram://typec@p...3/", }, ), ( "pingram://pingram_sk_abc123?to=id,user5@example.com&type=typeb", { # apikey is pulled from the host "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, "privacy_url": "pingram://typeb@p...3/", }, ), ( "pingram://?apikey=pingram_sk_abc123&type=test-type®ion=eu", { # No targets specified "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, "notify_response": False, }, ), ( "pingram://?apikey=pingram_sk_abc123&to=id,user5@example.com" "&type=typec", { # bad response "instance": NotifyPingram, "requests_response_text": PINGRAM_BAD_RESPONSE, "notify_response": False, }, ), ( "pingram://pingram_sk_abc123/id/user6@example.ca?bcc=invalid", { # A good email with a bad Blind Carbon Copy "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user8@example.ca?cc=l2g@nuxref.com", { # A good email with Carbon Copy "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user8@example.ca" "?channels=email,sms,slack,mobile_push,web_push,inapp,call", { # All channels forced at once "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user9@example.ca" "?cc=Chris", { "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user10@example.ca?cc=invalid", { "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user11@example.ca?to=invalid", { # an invalid to email "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id1/user12@example.ca" "?to=id,Chris", { "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id2/user13@example.ca/" "id/kris@example.com/id/chris2@example.com/id/+15552341234" "?:token=value", { # Several targets to notify "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user14@example.ca" "?bcc=Chris", { "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user@example.ca" "?:sub=value&:sub2=value2", { # A good email with template substitutions "instance": NotifyPingram, "requests_response_text": PINGRAM_GOOD_RESPONSE, "privacy_url": "pingram://p...3/", }, ), ( "pingram://pingram_sk_abc123/id/user@example.ca", { "instance": NotifyPingram, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user@example.ca", { "instance": NotifyPingram, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ( "pingram://pingram_sk_abc123/id/user@example.ca", { "instance": NotifyPingram, # Throws connection and transfer exceptions when this # flag is set and tests that we gracefully handle them "test_requests_exceptions": True, "requests_response_text": PINGRAM_GOOD_RESPONSE, }, ), ) def test_plugin_pingram_urls(): """NotifyPingram() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_pingram_template_sms_payloads(mock_post): """NotifyPingram() Testing Template SMS Payloads.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = PINGRAM_GOOD_RESPONSE # Assign our mock object our return value mock_post.return_value = okay_response # Details apikey = "pingram_sk_my_key" message_type = "apprise-post" targets = "userid/+1-555-123-4567" obj = Apprise.instantiate( f"pingram://{message_type}@{apikey}/{targets}?mode=template" ) assert isinstance(obj, NotifyPingram) assert isinstance(obj.url(), str) # No calls made yet assert mock_post.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # delivery of message assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "https://api.pingram.io/send" payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload == { "type": "apprise-post", "to": { "id": "userid", "number": "+15551234567", }, "parameters": { "appBody": "body", "appTitle": "title", "appType": "info", "appId": "Apprise", "appDescription": "Apprise Notifications", "appColor": "#3AA3E3", "appImageUrl": ( "https://github.com/caronc/apprise/raw/master/apprise" "/assets/themes/default/apprise-info-72x72.png" ), "appUrl": "https://github.com/caronc/apprise", }, } headers = mock_post.call_args_list[0][1]["headers"] assert headers == { "User-Agent": "Apprise", "Content-Type": "application/json", "Authorization": f"Bearer {apikey}", } # Reset our mock object mock_post.reset_mock() @mock.patch("requests.post") def test_plugin_pingram_template_email_payloads(mock_post): """NotifyPingram() Testing Template Email Payloads.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = PINGRAM_GOOD_RESPONSE # Assign our mock object our return value mock_post.return_value = okay_response # Details apikey = "pingram_sk_my_key_abc" message_type = "apprise-post" targets = "userid/test@example.ca" obj = Apprise.instantiate( f"pingram://{message_type}@{apikey}/{targets}" f"?from=Chris&bcc=joe@hidden.com&" f"cc=jason@hidden.com&:customToken=customValue&mode=template" ) assert isinstance(obj, NotifyPingram) assert isinstance(obj.url(), str) # No calls made yet assert mock_post.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # delivery of message assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "https://api.pingram.io/send" payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload == { "type": "apprise-post", "to": { "id": "userid", "email": "test@example.ca", }, "options": { "email": { "fromAddress": "chris@example.eu", "fromName": "Chris", "ccAddresses": ["jason@hidden.com"], "bccAddresses": ["joe@hidden.com"], } }, "parameters": { "customToken": "customValue", "appBody": "body", "appTitle": "title", "appType": "info", "appId": "Apprise", "appDescription": "Apprise Notifications", "appColor": "#3AA3E3", "appImageUrl": ( "https://github.com/caronc/apprise/raw/master/apprise/" "assets/themes/default/apprise-info-72x72.png" ), "appUrl": "https://github.com/caronc/apprise", }, } headers = mock_post.call_args_list[0][1]["headers"] assert headers == { "User-Agent": "Apprise", "Content-Type": "application/json", "Authorization": f"Bearer {apikey}", } # Reset our mock object mock_post.reset_mock() @mock.patch("requests.post") def test_plugin_pingram_message_payloads(mock_post): """NotifyPingram() Testing Message Payloads.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = PINGRAM_GOOD_RESPONSE # Assign our mock object our return value mock_post.return_value = okay_response # Details apikey = "pingram_sk_my_key_abc" message_type = "apprise-post" targets = "userid/test@example.ca/+15551239876" obj = Apprise.instantiate( f"pingram://{message_type}@{apikey}/{targets}" f"?from=Chris&bcc=joe@hidden.com" f"&mode=message" ) assert isinstance(obj, NotifyPingram) assert isinstance(obj.url(), str) # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # delivery of message assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "https://api.pingram.io/send" payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload == { "type": "apprise-post", "to": { "id": "userid", "email": "test@example.ca", "number": "+15551239876", }, "email": { "subject": "title", "html": "body", "senderName": "Chris", "senderEmail": "chris@example.eu", }, "options": { "email": { "fromAddress": "chris@example.eu", "fromName": "Chris", "bccAddresses": ["joe@hidden.com"], }, }, } headers = mock_post.call_args_list[0][1]["headers"] assert headers == { "User-Agent": "Apprise", "Content-Type": "application/json", "Authorization": f"Bearer {apikey}", } # Reset our mock object mock_post.reset_mock() # Reversing the sms with email causes auto-detection channel to # be sms instead of email targets = "userid/+15551239876/test@example.ca" obj = Apprise.instantiate( f"pingram://{apikey}/{targets}" f"?from=Chris&bcc=joe@hidden.com" ) assert isinstance(obj, NotifyPingram) assert isinstance(obj.url(), str) # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # delivery of message assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "https://api.pingram.io/send" payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload == { "type": "apprise", "to": { "id": "userid", "number": "+15551239876", "email": "test@example.ca", }, "sms": {"message": "title\nbody"}, "options": { "email": { "fromAddress": "chris@example.eu", "fromName": "Chris", "bccAddresses": ["joe@hidden.com"], }, }, } headers = mock_post.call_args_list[0][1]["headers"] assert headers == { "User-Agent": "Apprise", "Content-Type": "application/json", "Authorization": f"Bearer {apikey}", } # Reset our mock object mock_post.reset_mock() # Experiment with fixed channels (including the call/TTS channel) obj = Apprise.instantiate( f"pingram://{message_type}@{apikey}/{targets}" f"?from=Chris&bcc=joe@hidden.com" f"&mode=message&channels=sms,slack,call" ) assert isinstance(obj, NotifyPingram) assert isinstance(obj.url(), str) # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # delivery of message assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "https://api.pingram.io/send" payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload == { "type": "apprise-post", "to": { "id": "userid", "email": "test@example.ca", "number": "+15551239876", }, "slack": {"text": "title\nbody"}, "sms": {"message": "title\nbody"}, "call": {"message": "title\nbody"}, "options": { "email": { "fromAddress": "chris@example.eu", "fromName": "Chris", "bccAddresses": ["joe@hidden.com"], }, }, } headers = mock_post.call_args_list[0][1]["headers"] assert headers == { "User-Agent": "Apprise", "Content-Type": "application/json", "Authorization": f"Bearer {apikey}", } @mock.patch("requests.post") def test_plugin_pingram_targets(mock_post): """NotifyPingram() Testing Target Parsing.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = PINGRAM_GOOD_RESPONSE # Assign our mock object our return value mock_post.return_value = okay_response apikey = "pingram_sk_abc123" # A bare phone number with no id is valid obj = Apprise.instantiate(f"pingram://{apikey}/+15551234567") assert isinstance(obj, NotifyPingram) assert isinstance(obj.url(), str) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == "https://api.pingram.io/send" # No "id" present since none was supplied in the URL payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload == { "type": "apprise", "to": { "number": "+15551234567", }, "sms": {"message": "title\nbody"}, } headers = mock_post.call_args_list[0][1]["headers"] assert headers == { "User-Agent": "Apprise", "Content-Type": "application/json", "Authorization": f"Bearer {apikey}", } mock_post.reset_mock() # Multiple id-less phone targets each become their own recipient obj = Apprise.instantiate(f"pingram://{apikey}/+15551234567/+15551234568") assert isinstance(obj, NotifyPingram) assert len(obj.targets) == 2 assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 2 mock_post.reset_mock() # Region support carries over to the Pingram endpoints too obj = Apprise.instantiate(f"pingram://{apikey}/+15551234567?region=eu") assert isinstance(obj, NotifyPingram) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.eu.pingram.io/send" ) mock_post.reset_mock() # An id can still optionally be supplied alongside the number obj = Apprise.instantiate(f"pingram://{apikey}/myid/+15551234567") assert isinstance(obj, NotifyPingram) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["to"] == {"id": "myid", "number": "+15551234567"} def test_plugin_pingram_edge_cases(): """NotifyPingram() Edge Cases.""" # No API Key raises TypeError with pytest.raises(TypeError): NotifyPingram(apikey=None, targets=["+15551239876"]) # An invalid API Key (wrong prefix) raises TypeError with pytest.raises(TypeError): NotifyPingram(apikey="not-a-pingram-key", targets=["+15551239876"]) # Tests case where tokens is == None obj = NotifyPingram(apikey="pingram_sk_my_key", targets=["+15551239876"]) assert isinstance(obj, NotifyPingram) assert isinstance(obj.url(), str) assert obj.tokens == {} caronc-apprise-182f859/tests/test_plugin_plivo.py000066400000000000000000000114231524161175200221770ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester from apprise.plugins.plivo import NotifyPlivo logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "plivo://", { # No hostname/apikey specified "instance": TypeError, }, ), ( "plivo://{}@{}/15551232000".format("a" * 10, "a" * 25), { # invalid auth id "instance": TypeError, }, ), ( "plivo://{}@{}/15551232000".format("a" * 25, "a" * 10), { # invalid token "instance": TypeError, }, ), ( "plivo://{}@{}/123".format("a" * 25, "a" * 40), { # invalid phone number "instance": TypeError, }, ), ( "plivo://{}@{}/abc".format("a" * 25, "a" * 40), { # invalid phone number "instance": TypeError, }, ), ( "plivo://{}@{}/15551231234".format("a" * 25, "b" * 40), { # target phone number becomes who we text too; all is good "instance": NotifyPlivo, }, ), ( "plivo://{}@{}/15551232000/abcd".format("a" * 25, "a" * 40), { # invalid target phone number "instance": NotifyPlivo, # Notify will fail because it couldn't send to anyone "response": False, }, ), ( "plivo://{}@{}/15551232000/123".format("a" * 25, "a" * 40), { # invalid target phone number "instance": NotifyPlivo, # Notify will fail because it couldn't send to anyone "response": False, }, ), ( "plivo://{}@{}/?from=15551233000&to=15551232000&batch=yes".format( "a" * 25, "a" * 40 ), { # reference to to= and from= "instance": NotifyPlivo, }, ), ( "plivo://?id={}&token={}&from=15551233000&to=15551232000".format( "a" * 25, "a" * 40 ), { # Our expected url(privacy=True) startswith() response: "privacy_url": "plivo://a...a@a...a/+15551233000/+15551232000", # reference to to= and from= "instance": NotifyPlivo, }, ), ( "plivo://15551232123?id={}&token={}&from=15551233000" "&to=15551232000".format("a" * 25, "a" * 40), { # reference to to= and from= "instance": NotifyPlivo, # Our expected url(privacy=True) startswith() response: "privacy_url": "plivo://a...a@a...a/+15551233000/+15551232123", }, ), ( "plivo://{}@{}/15551232000".format("a" * 25, "a" * 40), { "instance": NotifyPlivo, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "plivo://{}@{}/15551232000".format("a" * 25, "a" * 40), { "instance": NotifyPlivo, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_plivo_urls(): """NotifyPlivo() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_postmark.py000066400000000000000000000411271524161175200227120ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.postmark import NotifyPostmark logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "postmark://", { # No credentials "instance": TypeError, }, ), ( "postmark://:@/", { # Empty credentials "instance": TypeError, }, ), ( "postmark://abcd", { # API key only, no from email "instance": TypeError, }, ), ( "postmark://abcd@host", { # API key only, no from email "instance": TypeError, }, ), ( "postmark://abcd:user@example.com", { # No targets; defaults to from address "instance": NotifyPostmark, }, ), ( "postmark://abcd:user@example.com?format=text", { # Text format override "instance": NotifyPostmark, }, ), ( "postmark://abcd:user@example.com/newuser1@example.com", { # A good email with explicit target "instance": NotifyPostmark, "force_debug": True, }, ), ( "postmark://abcd:user@example.com/newuser2@example.com?name=John", { # With From display name "instance": NotifyPostmark, "privacy_url": ( "postmark://a...d:user@example.com/newuser2@example.com" ), "url_matches": r"name=John", }, ), ( ( "postmark://abcd:user@example.com/newuser3@example.com" "?reply=reply@example.com" ), { # With Reply-To "instance": NotifyPostmark, "url_matches": r"reply=", }, ), ( ( "postmark://abcd:user@example.com/newuser4@example.com" "?bcc=bcc@nuxref.com" ), { # With Blind Carbon Copy "instance": NotifyPostmark, }, ), ( ( "postmark://abcd:user@example.com/newuser5@example.com" "?cc=cc@nuxref.com" ), { # With Carbon Copy "instance": NotifyPostmark, }, ), ( ( "postmark://abcd:user@example.com/newuser5@example.com" "?cc=Chris" ), { # With named Carbon Copy "instance": NotifyPostmark, }, ), ( ( "postmark://abcd:user@example.com/newuser6@example.com" "?to=extra@nuxref.com" ), { # Via ?to= for additional targets "instance": NotifyPostmark, }, ), ( "postmark://abcd:user@example.com/bademailaddress", { # Invalid target dropped; no valid recipients -- fails to send "instance": NotifyPostmark, "notify_response": False, }, ), ( "postmark://abcd:user@example.ca/newuser@example.ca", { "instance": NotifyPostmark, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "postmark://abcd:user@example.uk/newuser@example.uk", { "instance": NotifyPostmark, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "postmark://abcd:user@example.au/newuser@example.au", { "instance": NotifyPostmark, # Throws a series of connection exceptions with this flag "test_requests_exceptions": True, }, ), ) def test_plugin_postmark_urls(): """NotifyPostmark() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_postmark_edge_cases(mock_post, mock_get): """NotifyPostmark() Edge Cases.""" # No API key with pytest.raises(TypeError): NotifyPostmark(apikey=None, from_email="user@example.com") # Invalid from email with pytest.raises(TypeError): NotifyPostmark(apikey="abcd", from_email="!invalid") # No from email (None) with pytest.raises(TypeError): NotifyPostmark(apikey="abcd", from_email=None) # Invalid target email -- plugin loads but target is dropped obj = NotifyPostmark( apikey="abcd", from_email="user@example.com", targets="!invalid", ) # All targets dropped -> send() returns False assert isinstance(obj, NotifyPostmark) # Test invalid bcc/cc entries mixed with valid ones assert isinstance( NotifyPostmark( apikey="abcd", from_email="l2g@example.com", bcc=("abc@def.com", "!invalid"), cc=("abc@test.org", "!invalid"), ), NotifyPostmark, ) # Test invalid reply_to entries mixed with valid ones assert isinstance( NotifyPostmark( apikey="abcd", from_email="l2g@example.com", reply_to=("reply@def.com", "!invalid"), ), NotifyPostmark, ) @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_postmark_send(mock_post, mock_get): """NotifyPostmark() Send.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() mock_get.return_value = _mk_resp() # Basic send to self (no explicit target) obj = Apprise.instantiate("postmark://abcd:user@example.com") assert isinstance(obj, NotifyPostmark) assert obj.notify(body="body", title="title") is True # Verify the X-Postmark-Server-Token header was set assert mock_post.call_count == 1 call_kwargs = mock_post.call_args import json posted = json.loads(call_kwargs[1]["data"]) assert posted["To"] == "user@example.com" assert "X-Postmark-Server-Token" in call_kwargs[1]["headers"] mock_post.reset_mock() # Send with an explicit target obj = Apprise.instantiate( "postmark://abcd:user@example.com/target@example.com" ) assert obj.notify(body="body", title="title") is True posted = json.loads(mock_post.call_args[1]["data"]) assert posted["To"] == "target@example.com" mock_post.reset_mock() # Send with cc and bcc obj = NotifyPostmark( apikey="abcd", from_email="user@example.com", targets=["target@example.com"], cc=["cc@example.com"], bcc=["bcc@example.com"], ) assert obj.notify(body="body", title="title") is True posted = json.loads(mock_post.call_args[1]["data"]) assert "Cc" in posted assert "Bcc" in posted mock_post.reset_mock() # Send with reply_to obj = NotifyPostmark( apikey="abcd", from_email="user@example.com", reply_to=["reply@example.com"], ) assert obj.notify(body="body", title="title") is True posted = json.loads(mock_post.call_args[1]["data"]) assert "ReplyTo" in posted mock_post.reset_mock() # Empty title falls back to default_empty_subject obj = NotifyPostmark( apikey="abcd", from_email="user@example.com", ) assert obj.notify(body="body", title="") is True posted = json.loads(mock_post.call_args[1]["data"]) assert posted["Subject"] == NotifyPostmark.default_empty_subject mock_post.reset_mock() # Text format obj = NotifyPostmark( apikey="abcd", from_email="user@example.com", ) from apprise.common import NotifyFormat obj.notify_format = NotifyFormat.TEXT assert obj.notify(body="body", title="title") is True posted = json.loads(mock_post.call_args[1]["data"]) assert "TextBody" in posted assert "HtmlBody" not in posted mock_post.reset_mock() # HTTP error response mock_post.return_value = _mk_resp(requests.codes.internal_server_error) obj = Apprise.instantiate( "postmark://abcd:user@example.com/target@example.com" ) assert obj.notify(body="body", title="title") is False mock_post.reset_mock() # Unknown HTTP code mock_post.return_value = _mk_resp(999) assert obj.notify(body="body", title="title") is False mock_post.reset_mock() # RequestException mock_post.side_effect = requests.RequestException("Connection failed") assert obj.notify(body="body", title="title") is False mock_post.side_effect = None mock_post.reset_mock() # No valid targets -> False without any HTTP call obj = NotifyPostmark( apikey="abcd", from_email="user@example.com", targets=["!invalid"], ) mock_post.return_value = _mk_resp() assert obj.notify(body="body", title="title") is False assert mock_post.call_count == 0 # Multiple targets -- partial failure mock_post.reset_mock() responses = [_mk_resp(), _mk_resp(requests.codes.bad_request)] mock_post.side_effect = responses obj = NotifyPostmark( apikey="abcd", from_email="user@example.com", targets=["t1@example.com", "t2@example.com"], ) assert obj.notify(body="body", title="title") is False assert mock_post.call_count == 2 mock_post.side_effect = None @mock.patch("requests.post") def test_plugin_postmark_attachments(mock_post): """NotifyPostmark() Attachments.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() # Single attachment success path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) obj = Apprise.instantiate("postmark://abcd:user@example.com") assert isinstance(obj, NotifyPostmark) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) import json posted = json.loads(mock_post.call_args[1]["data"]) assert "Attachments" in posted assert len(posted["Attachments"]) == 1 assert posted["Attachments"][0]["Name"] == "apprise-test.gif" mock_post.reset_mock() # Multiple attachments attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.png")) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) posted = json.loads(mock_post.call_args[1]["data"]) assert len(posted["Attachments"]) == 2 mock_post.reset_mock() # Attachment inaccessible (file not found) with mock.patch("os.path.isfile", return_value=False): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ), ) is False ) # Attachment read error (OSError) with mock.patch("builtins.open", side_effect=OSError): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ), ) is False ) @mock.patch("requests.post") def test_plugin_postmark_url_parsing(mock_post): """NotifyPostmark() URL parsing and round-trip.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() # Basic round-trip obj = NotifyPostmark( apikey="mytoken", from_email="user@example.com", ) url = obj.url() result = NotifyPostmark.parse_url(url) assert result is not None obj2 = NotifyPostmark(**result) assert obj.url_identifier == obj2.url_identifier # With explicit target obj = NotifyPostmark( apikey="mytoken", from_email="user@example.com", targets=["target@example.com"], ) url = obj.url() result = NotifyPostmark.parse_url(url) assert result is not None obj2 = NotifyPostmark(**result) assert len(obj.targets) == len(obj2.targets) # With from_name obj = NotifyPostmark( apikey="mytoken", from_email="user@example.com", from_name="Alice", ) url = obj.url() assert "name=Alice" in url result = NotifyPostmark.parse_url(url) assert result is not None obj2 = NotifyPostmark(**result) assert obj2.from_addr[0] == "Alice" # With bcc and cc obj = NotifyPostmark( apikey="mytoken", from_email="user@example.com", targets=["target@example.com"], cc=["cc@example.com"], bcc=["bcc@example.com"], ) url = obj.url() result = NotifyPostmark.parse_url(url) assert result is not None obj2 = NotifyPostmark(**result) assert "cc@example.com" in obj2.cc assert "bcc@example.com" in obj2.bcc # With reply_to obj = NotifyPostmark( apikey="mytoken", from_email="user@example.com", reply_to=["reply@example.com"], ) url = obj.url() result = NotifyPostmark.parse_url(url) assert result is not None obj2 = NotifyPostmark(**result) assert "reply@example.com" in obj2.reply_to # parse_url returns None for completely unparseable input assert NotifyPostmark.parse_url(None) is None # ?apikey= override result = NotifyPostmark.parse_url( "postmark://?apikey=overridekey&from=sender@example.com" "&to=target@example.com" ) assert result is not None assert result["apikey"] == "overridekey" # ?from= override (from address via query param) result = NotifyPostmark.parse_url( "postmark://tok@example.com/path@example.com?from=sender@example.com" ) assert result is not None assert result["from_email"] == "sender@example.com" # privacy_url hides the API key obj = NotifyPostmark( apikey="secrettoken", from_email="user@example.com", ) privacy = obj.url(privacy=True) assert "secrettoken" not in privacy assert "s...n" in privacy or "..." in privacy # __len__ obj = NotifyPostmark( apikey="mytoken", from_email="user@example.com", targets=["a@example.com", "b@example.com"], ) assert len(obj) == 2 # url_identifier obj1 = NotifyPostmark( apikey="tok", from_email="user@example.com", ) obj2 = NotifyPostmark( apikey="tok", from_email="user@example.com", targets=["other@example.com"], ) assert obj1.url_identifier == obj2.url_identifier caronc-apprise-182f859/tests/test_plugin_prowl.py000066400000000000000000000156611524161175200222210ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests import apprise from apprise.plugins.prowl import NotifyProwl, ProwlPriority logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "prowl://", { "instance": TypeError, }, ), # bad url ( "prowl://:@/", { "instance": TypeError, }, ), # Invalid API Key ( "prowl://%s" % ("a" * 20), { "instance": TypeError, }, ), # Provider Key ( "prowl://{}/{}".format("a" * 40, "b" * 40), { "instance": NotifyProwl, }, ), # Invalid Provider Key ( "prowl://{}/{}".format("a" * 40, "b" * 20), { "instance": TypeError, }, ), # APIkey; no device ( "prowl://%s" % ("a" * 40), { "instance": NotifyProwl, }, ), # API Key ( "prowl://%s" % ("a" * 40), { "instance": NotifyProwl, # don't include an image by default "include_image": False, }, ), # API Key + priority setting ( "prowl://%s?priority=high" % ("a" * 40), { "instance": NotifyProwl, }, ), # API Key + invalid priority setting ( "prowl://%s?priority=invalid" % ("a" * 40), { "instance": NotifyProwl, }, ), # API Key + priority setting (empty) ( "prowl://%s?priority=" % ("a" * 40), { "instance": NotifyProwl, }, ), # API Key + No Provider Key (empty) ( "prowl://%s///" % ("w" * 40), { "instance": NotifyProwl, # Our expected url(privacy=True) startswith() response: "privacy_url": "prowl://w...w/", }, ), # API Key + Provider Key ( "prowl://{}/{}".format("a" * 40, "b" * 40), { "instance": NotifyProwl, # Our expected url(privacy=True) startswith() response: "privacy_url": "prowl://a...a/b...b", }, ), # API Key + with image ( "prowl://%s" % ("a" * 40), { "instance": NotifyProwl, }, ), ( "prowl://%s" % ("a" * 40), { "instance": NotifyProwl, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "prowl://%s" % ("a" * 40), { "instance": NotifyProwl, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "prowl://%s" % ("a" * 40), { "instance": NotifyProwl, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_prowl(): """NotifyProwl() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_prowl_edge_cases(): """NotifyProwl() Edge Cases.""" # Initializes the plugin with an invalid apikey with pytest.raises(TypeError): NotifyProwl(apikey=None) # Whitespace also acts as an invalid apikey value with pytest.raises(TypeError): NotifyProwl(apikey=" ") # Whitespace also acts as an invalid provider key with pytest.raises(TypeError): NotifyProwl(apikey="abcd", providerkey=object()) with pytest.raises(TypeError): NotifyProwl(apikey="abcd", providerkey=" ") @mock.patch("requests.post") def test_plugin_prowl_config_files(mock_post): """NotifyProwl() Config File Cases.""" content = """ urls: - prowl://{}: - priority: -2 tag: prowl_int low - priority: "-2" tag: prowl_str_int low - priority: low tag: prowl_str low # This will take on moderate (default) priority - priority: invalid tag: prowl_invalid - prowl://{}: - priority: 2 tag: prowl_int emerg - priority: "2" tag: prowl_str_int emerg - priority: emergency tag: prowl_str emerg """.format("a" * 40, "b" * 40) # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 7 servers from that # 3x low # 3x emerg # 1x invalid (so takes on normal priority) assert len(ac.servers()) == 7 assert len(aobj) == 7 assert len(list(aobj.find(tag="low"))) == 3 for s in aobj.find(tag="low"): assert s.priority == ProwlPriority.LOW assert len(list(aobj.find(tag="emerg"))) == 3 for s in aobj.find(tag="emerg"): assert s.priority == ProwlPriority.EMERGENCY assert len(list(aobj.find(tag="prowl_str"))) == 2 assert len(list(aobj.find(tag="prowl_str_int"))) == 2 assert len(list(aobj.find(tag="prowl_int"))) == 2 assert len(list(aobj.find(tag="prowl_invalid"))) == 1 assert ( next(aobj.find(tag="prowl_invalid")).priority == ProwlPriority.NORMAL ) caronc-apprise-182f859/tests/test_plugin_pushbullet.py000066400000000000000000000337571524161175200232530ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment from apprise.plugins.pushbullet import NotifyPushBullet logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "pbul://", { "instance": TypeError, }, ), ( "pbul://:@/", { "instance": TypeError, }, ), # APIkey ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, "check_attachments": False, }, ), # APIkey; but support attachment response ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "file_name": "cat.jpeg", "file_type": "image/jpeg", "file_url": "http://file_url", "upload_url": "http://upload_url", }, }, ), # APIkey; attachment testing that isn't an image type ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "file_name": "test.pdf", "file_type": "application/pdf", "file_url": "http://file_url", "upload_url": "http://upload_url", }, }, ), # APIkey; attachment testing were expected entry in payload is missing ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # Test what happens if a batch send fails to return a messageCount "requests_response_text": { "file_name": "test.pdf", "file_type": "application/pdf", "file_url": "http://file_url", # upload_url missing }, # Our Notification calls associated with attachments will fail: "attach_response": False, }, ), # API Key + channel ( "pbul://%s/#channel/" % ("a" * 32), { "instance": NotifyPushBullet, "check_attachments": False, }, ), # API Key + channel (via to= ( "pbul://%s/?to=#channel" % ("a" * 32), { "instance": NotifyPushBullet, "check_attachments": False, }, ), # API Key + 2 channels ( "pbul://%s/#channel1/#channel2" % ("a" * 32), { "instance": NotifyPushBullet, # Our expected url(privacy=True) startswith() response: "privacy_url": "pbul://a...a/", "check_attachments": False, }, ), # API Key + device ( "pbul://%s/device/" % ("a" * 32), { "instance": NotifyPushBullet, "check_attachments": False, }, ), # API Key + 2 devices ( "pbul://%s/device1/device2/" % ("a" * 32), { "instance": NotifyPushBullet, "check_attachments": False, }, ), # API Key + email ( "pbul://%s/user@example.com/" % ("a" * 32), { "instance": NotifyPushBullet, "check_attachments": False, }, ), # API Key + 2 emails ( "pbul://%s/user@example.com/abc@def.com/" % ("a" * 32), { "instance": NotifyPushBullet, "check_attachments": False, }, ), # API Key + Combo ( "pbul://%s/device/#channel/user@example.com/" % ("a" * 32), { "instance": NotifyPushBullet, "check_attachments": False, }, ), # , ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, "check_attachments": False, }, ), ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, "check_attachments": False, }, ), ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, "check_attachments": False, }, ), ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, "check_attachments": False, }, ), ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, "check_attachments": False, }, ), ( "pbul://%s" % ("a" * 32), { "instance": NotifyPushBullet, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, "check_attachments": False, }, ), ) def test_plugin_pushbullet_urls(): """NotifyPushBullet() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_pushbullet_attachments(mock_post): """NotifyPushBullet() Attachment Checks.""" # Initialize some generic (but valid) tokens access_token = "t" * 32 # Prepare Mock return object response = mock.Mock() response.content = dumps( { "file_name": "cat.jpg", "file_type": "image/jpeg", "file_url": "https://dl.pushb.com/abc/cat.jpg", "upload_url": "https://upload.pushbullet.com/abcd123", } ).encode("utf-8") response.status_code = requests.codes.ok mock_post.return_value = response # prepare our attachment attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Test our markdown obj = Apprise.instantiate(f"pbul://{access_token}/?format=markdown") # Send a good attachment assert obj.notify(body="test", attach=attach) is True # Test our call count assert mock_post.call_count == 4 # Image Prep assert ( mock_post.call_args_list[0][0][0] == "https://api.pushbullet.com/v2/upload-request" ) assert ( mock_post.call_args_list[1][0][0] == "https://upload.pushbullet.com/abcd123" ) # Message assert ( mock_post.call_args_list[2][0][0] == "https://api.pushbullet.com/v2/pushes" ) # Image Send assert ( mock_post.call_args_list[3][0][0] == "https://api.pushbullet.com/v2/pushes" ) # Reset our mock object mock_post.reset_mock() # Add another attachment so we drop into the area of the PushBullet code # that sends remaining attachments (if more detected) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our attachments assert obj.notify(body="test", attach=attach) is True # Test our call count assert mock_post.call_count == 7 # Image Prep assert ( mock_post.call_args_list[0][0][0] == "https://api.pushbullet.com/v2/upload-request" ) assert ( mock_post.call_args_list[1][0][0] == "https://upload.pushbullet.com/abcd123" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.pushbullet.com/v2/upload-request" ) assert ( mock_post.call_args_list[3][0][0] == "https://upload.pushbullet.com/abcd123" ) # Message assert ( mock_post.call_args_list[4][0][0] == "https://api.pushbullet.com/v2/pushes" ) # Image Send assert ( mock_post.call_args_list[5][0][0] == "https://api.pushbullet.com/v2/pushes" ) assert ( mock_post.call_args_list[6][0][0] == "https://api.pushbullet.com/v2/pushes" ) # Reset our mock object mock_post.reset_mock() # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") attach = AppriseAttachment(path) assert obj.notify(body="test", attach=attach) is False # Test our call count assert mock_post.call_count == 0 # prepare our attachment attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Prepare a bad response bad_response = mock.Mock() bad_response.content = dumps( { "file_name": "cat.jpg", "file_type": "image/jpeg", "file_url": "https://dl.pushb.com/abc/cat.jpg", "upload_url": "https://upload.pushbullet.com/abcd123", } ).encode("utf-8") bad_response.status_code = requests.codes.internal_server_error bad_response.headers = {} # Prepare a bad response bad_json_response = mock.Mock() bad_json_response.content = b"}" bad_json_response.status_code = requests.codes.ok bad_json_response.headers = {} # Throw an exception on the first call to requests.post() for side_effect in ( requests.RequestException(), OSError(), [bad_response], ): mock_post.reset_mock() mock_post.side_effect = side_effect # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # Throw an exception on the second call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.reset_mock() mock_post.side_effect = [response, side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # Throw an exception on the third call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.reset_mock() mock_post.side_effect = [response, response, side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # Throw an exception on the forth call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.reset_mock() mock_post.side_effect = [response, response, response, side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # Test case where we don't get a valid response back mock_post.side_effect = None mock_post.return_value = bad_json_response # We'll fail because of an invalid json object assert obj.send(body="test", attach=attach) is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_pushbullet_edge_cases(mock_post, mock_get): """NotifyPushBullet() Edge Cases.""" # Initialize some generic (but valid) tokens accesstoken = "a" * 32 # Support strings recipients = "#chan1,#chan2,device,user@example.com,,," # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() mock_post.content = b"" mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok mock_get.content = b"" # Invalid Access Token with pytest.raises(TypeError): NotifyPushBullet(accesstoken=None) with pytest.raises(TypeError): NotifyPushBullet(accesstoken=" ") obj = NotifyPushBullet(accesstoken=accesstoken, targets=recipients) assert isinstance(obj, NotifyPushBullet) is True assert len(obj.targets) == 4 obj = NotifyPushBullet(accesstoken=accesstoken) assert isinstance(obj, NotifyPushBullet) is True # Default is to send to all devices, so there will be a # recipient here assert len(obj.targets) == 1 obj = NotifyPushBullet(accesstoken=accesstoken, targets=set()) assert isinstance(obj, NotifyPushBullet) is True # Default is to send to all devices, so there will be a # recipient here assert len(obj.targets) == 1 caronc-apprise-182f859/tests/test_plugin_pushdeer.py000066400000000000000000000105321524161175200226650ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise from apprise.plugins.pushdeer import NotifyPushDeer logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "pushdeer://", { "instance": TypeError, }, ), ( "pushdeers://", { "instance": TypeError, }, ), ( "pushdeer://localhost/{}".format("a" * 8), { "instance": NotifyPushDeer, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pushdeer://localhost/{}".format("a" * 8), { "instance": NotifyPushDeer, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "pushdeer://localhost:80/{}".format("a" * 8), { "instance": NotifyPushDeer, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pushdeer://localhost:80/{}".format("a" * 8), { "instance": NotifyPushDeer, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "pushdeer://{}".format("a" * 8), { "instance": NotifyPushDeer, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pushdeer://{}".format("a" * 8), { "instance": NotifyPushDeer, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_pushdeer_urls(): """NotifyPushDeer() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_pushdeer_general(mock_post): """NotifyPushDeer() General Checks.""" response = mock.Mock() response.content = "" response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Variation Initializations obj = Apprise.instantiate("pushdeer://localhost/pushKey") assert isinstance(obj, NotifyPushDeer) assert isinstance(obj.url(), str) # Send Notification assert obj.send(body="test") is True assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "http://localhost:80/message/push?pushkey=pushKey" ) caronc-apprise-182f859/tests/test_plugin_pushed.py000066400000000000000000000173451524161175200223470ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.pushed import NotifyPushed logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "pushed://", { "instance": TypeError, }, ), # Application Key Only ( "pushed://%s" % ("a" * 32), { "instance": TypeError, }, ), # Invalid URL ( "pushed://:@/", { "instance": TypeError, }, ), # Application Key+Secret ( "pushed://{}/{}".format("a" * 32, "a" * 64), { "instance": NotifyPushed, }, ), # Application Key+Secret + channel ( "pushed://{}/{}/#channel/".format("a" * 32, "a" * 64), { "instance": NotifyPushed, }, ), # Application Key+Secret + channel (via to=) ( "pushed://{}/{}?to=channel".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushed://a...a/****/", }, ), # Application Key+Secret + dropped entry ( "pushed://{}/{}/dropped_value/".format("a" * 32, "a" * 64), { # No entries validated is a fail "instance": TypeError, }, ), # Application Key+Secret + 2 channels ( "pushed://{}/{}/#channel1/#channel2".format("a" * 32, "a" * 64), { "instance": NotifyPushed, }, ), # Application Key+Secret + User Pushed ID ( "pushed://{}/{}/@ABCD/".format("a" * 32, "a" * 64), { "instance": NotifyPushed, }, ), # Application Key+Secret + 2 devices ( "pushed://{}/{}/@ABCD/@DEFG/".format("a" * 32, "a" * 64), { "instance": NotifyPushed, }, ), # Application Key+Secret + Combo ( "pushed://{}/{}/@ABCD/#channel".format("a" * 32, "a" * 64), { "instance": NotifyPushed, }, ), # , ( "pushed://{}/{}".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pushed://{}/{}".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pushed://{}/{}".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "pushed://{}/{}".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pushed://{}/{}".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pushed://{}/{}/#channel".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pushed://{}/{}/@user".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pushed://{}/{}".format("a" * 32, "a" * 64), { "instance": NotifyPushed, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_pushed_urls(): """NotifyPushed() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_pushed_edge_cases(mock_post, mock_get): """NotifyPushed() Edge Cases.""" # Chat ID recipients = "@ABCDEFG, @DEFGHIJ, #channel, #channel2" # Some required input app_key = "ABCDEFG" app_secret = "ABCDEFG" # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok # No application Key specified with pytest.raises(TypeError): NotifyPushed( app_key=None, app_secret=app_secret, recipients=None, ) with pytest.raises(TypeError): NotifyPushed( app_key=" ", app_secret=app_secret, recipients=None, ) # No application Secret specified with pytest.raises(TypeError): NotifyPushed( app_key=app_key, app_secret=None, recipients=None, ) with pytest.raises(TypeError): NotifyPushed( app_key=app_key, app_secret=" ", ) # recipients list set to (None) is perfectly fine; in this case it will # notify the App obj = NotifyPushed( app_key=app_key, app_secret=app_secret, recipients=None, ) assert isinstance(obj, NotifyPushed) is True assert len(obj.channels) == 0 assert len(obj.users) == 0 obj = NotifyPushed( app_key=app_key, app_secret=app_secret, targets=recipients, ) assert isinstance(obj, NotifyPushed) is True assert len(obj.channels) == 2 assert len(obj.users) == 2 # Prepare Mock to fail mock_post.return_value.status_code = requests.codes.internal_server_error mock_get.return_value.status_code = requests.codes.internal_server_error caronc-apprise-182f859/tests/test_plugin_pushjet.py000066400000000000000000000100011524161175200225170ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import pytest import requests from apprise.plugins.pushjet import NotifyPushjet logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "pjet://", { "instance": None, }, ), ( "pjets://", { "instance": None, }, ), ( "pjet://:@/", { "instance": None, }, ), # You must specify a secret key ( "pjet://%s" % ("a" * 32), { "instance": TypeError, }, ), # The proper way to log in ( "pjet://user:pass@localhost/%s" % ("a" * 32), { "instance": NotifyPushjet, }, ), # The proper way to log in ( "pjets://localhost/%s" % ("a" * 32), { "instance": NotifyPushjet, }, ), # Specify your own server with login (secret= MUST be provided) ( "pjet://user:pass@localhost?secret=%s" % ("a" * 32), { "instance": NotifyPushjet, # Our expected url(privacy=True) startswith() response: "privacy_url": "pjet://user:****@localhost", }, ), # Specify your own server with port ( "pjets://localhost:8080/%s" % ("a" * 32), { "instance": NotifyPushjet, }, ), ( "pjets://localhost:8080/%s" % ("a" * 32), { "instance": NotifyPushjet, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pjets://localhost:4343/%s" % ("a" * 32), { "instance": NotifyPushjet, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pjet://localhost:8081/%s" % ("a" * 32), { "instance": NotifyPushjet, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_pushjet_urls(): """NotifyPushjet() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_pushjet_edge_cases(): """NotifyPushjet() Edge Cases.""" # No application Key specified with pytest.raises(TypeError): NotifyPushjet(secret_key=None) with pytest.raises(TypeError): NotifyPushjet(secret_key=" ") caronc-apprise-182f859/tests/test_plugin_pushme.py000066400000000000000000000077211524161175200223550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.pushme import NotifyPushMe logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "pushme://", { "instance": TypeError, }, ), ( "pushme://:@/", { "instance": TypeError, }, ), # Token specified ( "pushme://%s" % ("a" * 6), { "instance": NotifyPushMe, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushme://a...a/", }, ), # Token specified ( "pushme://?token=%s&status=yes" % ("b" * 6), { "instance": NotifyPushMe, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushme://b...b/", }, ), # Status setting ( "pushme://?token=%s&status=no" % ("b" * 6), { "instance": NotifyPushMe, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushme://b...b/", }, ), # Status setting ( "pushme://?token=%s&status=True" % ("b" * 6), { "instance": NotifyPushMe, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushme://b...b/", }, ), # Token specified ( "pushme://?push_key=%s&status=no" % ("p" * 6), { "instance": NotifyPushMe, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushme://p...p/", }, ), ( "pushme://%s" % ("c" * 6), { "instance": NotifyPushMe, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pushme://%s" % ("d" * 7), { "instance": NotifyPushMe, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pushme://%s" % ("e" * 8), { "instance": NotifyPushMe, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_pushme_urls(): """NotifyPushMe() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_pushover.py000066400000000000000000000774211524161175200227330ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests import apprise from apprise.plugins.pushover import NotifyPushover, PushoverPriority logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "pover://", { "instance": TypeError, }, ), # bad url ( "pover://:@/", { "instance": TypeError, }, ), # APIkey; no user ( "pover://%s" % ("a" * 30), { "instance": TypeError, }, ), # API Key + custom sound setting ( "pover://{}@{}?sound=mysound".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + valid alternate sound picked ( "pover://{}@{}?sound=spacealarm".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + valid url_title with url ( "pover://{}@{}?url=my-url&url_title=title".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + Valid User ( "pover://{}@{}".format("u" * 30, "a" * 30), { "instance": NotifyPushover, # don't include an image by default "include_image": False, }, ), # API Key + Valid User + 1 Device ( "pover://{}@{}/DEVICE".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + Valid User + 1 Device (via to=) ( "pover://{}@{}?to=DEVICE".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + Valid User + 2 Devices ( "pover://{}@{}/DEVICE1/Device-with-dash/".format("u" * 30, "a" * 30), { "instance": NotifyPushover, # Our expected url(privacy=True) startswith() response: "privacy_url": "pover://u...u@a...a", }, ), # API Key + Valid User + invalid device ( "pover://{}@{}/{}/".format("u" * 30, "a" * 30, "d" * 30), { "instance": NotifyPushover, # Notify will return False since there is a bad device in our list "response": False, }, ), # API Key + Valid User + device + invalid device ( "pover://{}@{}/DEVICE1/{}/".format("u" * 30, "a" * 30, "d" * 30), { "instance": NotifyPushover, # Notify will return False since there is a bad device in our list "response": False, }, ), # API Key + Valid User + 1 Group key (percent-encoded) ( "pover://{}@{}/%23{}".format("u" * 30, "a" * 30, "g" * 30), { "instance": NotifyPushover, }, ), # API Key + Valid User + 1 Group key (literal #) ( "pover://{}@{}/#{}/".format("u" * 30, "a" * 30, "g" * 30), { "instance": NotifyPushover, }, ), # API Key + Valid User + Group key (via to= percent-encoded) ( "pover://{}@{}?to=%23{}".format("u" * 30, "a" * 30, "g" * 30), { "instance": NotifyPushover, }, ), # API Key + Valid User + 2 Group keys (percent-encoded) ( "pover://{}@{}/%23{}/%23{}".format( "u" * 30, "a" * 30, "g" * 30, "h" * 30 ), { "instance": NotifyPushover, }, ), # API Key + Valid User + 2 Group keys (literal #) ( "pover://{}@{}/#{}/#{}/".format( "u" * 30, "a" * 30, "g" * 30, "h" * 30 ), { "instance": NotifyPushover, }, ), # API Key + Valid User + 1 Device + 1 Group key (percent-encoded) ( "pover://{}@{}/DEVICE/%23{}".format("u" * 30, "a" * 30, "g" * 30), { "instance": NotifyPushover, }, ), # API Key + Valid User + 1 Device + 1 Group key (literal #) ( "pover://{}@{}/DEVICE/#{}".format("u" * 30, "a" * 30, "g" * 30), { "instance": NotifyPushover, }, ), # API Key + Valid User + invalid group (contains invalid chars) ( "pover://{}@{}/%23invalid-group/".format("u" * 30, "a" * 30), { "instance": NotifyPushover, # Notify will return False since the group key is invalid # (contains a dash which is not alphanumeric) "response": False, }, ), # API Key + priority setting ( "pover://{}@{}?priority=high".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + priority setting + html mode ( "pover://{}@{}?priority=high&format=html".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + priority setting + markdown mode ( "pover://{}@{}?priority=high&format=markdown".format( "u" * 30, "a" * 30 ), { "instance": NotifyPushover, }, ), # API Key + invalid priority setting ( "pover://{}@{}?priority=invalid".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + emergency(2) priority setting ( "pover://{}@{}?priority=emergency".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + emergency(2) priority setting (via numeric value ( "pover://{}@{}?priority=2".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), # API Key + emergency priority setting with interval and expire ( "pover://{}@{}?priority=emergency&{}&{}".format( "u" * 30, "a" * 30, "interval=30", "expire=300" ), { "instance": NotifyPushover, }, ), # API Key + emergency priority setting with text interval ( "pover://{}@{}?priority=emergency&{}&{}".format( "u" * 30, "a" * 30, "interval=invalid", "expire=300" ), { "instance": NotifyPushover, }, ), # API Key + emergency priority setting with text expire ( "pover://{}@{}?priority=emergency&{}&{}".format( "u" * 30, "a" * 30, "interval=30", "expire=invalid" ), { "instance": NotifyPushover, }, ), # API Key + emergency priority setting with invalid expire ( "pover://{}@{}?priority=emergency&{}".format( "u" * 30, "a" * 30, "expire=100000" ), { "instance": TypeError, }, ), # API Key + emergency priority setting with invalid interval ( "pover://{}@{}?priority=emergency&{}".format( "u" * 30, "a" * 30, "interval=15" ), { "instance": TypeError, }, ), # API Key + priority setting (empty) ( "pover://{}@{}?priority=".format("u" * 30, "a" * 30), { "instance": NotifyPushover, }, ), ( "pover://{}@{}".format("u" * 30, "a" * 30), { "instance": NotifyPushover, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pover://{}@{}".format("u" * 30, "a" * 30), { "instance": NotifyPushover, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pover://{}@{}".format("u" * 30, "a" * 30), { "instance": NotifyPushover, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # E2EE: valid 64-char hex encryption key ( "pover://{}@{}?key={}".format("u" * 30, "a" * 30, "b" * 64), { "instance": NotifyPushover, "privacy_url": "pover://u...u@a...a", }, ), # E2EE: explicit e2ee=yes with key ( "pover://{}@{}?key={}&e2ee=yes".format("u" * 30, "a" * 30, "c" * 64), { "instance": NotifyPushover, }, ), # E2EE: key present but e2ee=no (plaintext send) ( "pover://{}@{}?key={}&e2ee=no".format("u" * 30, "a" * 30, "d" * 64), { "instance": NotifyPushover, }, ), # E2EE: invalid key (not 64 hex chars) ( "pover://{}@{}?key=tooshort".format("u" * 30, "a" * 30), { "instance": TypeError, }, ), # E2EE: invalid key (correct length but non-hex) ( "pover://{}@{}?key={}".format( "u" * 30, "a" * 30, # 64 chars but contains 'z' which is not valid hex "z" * 64, ), { "instance": TypeError, }, ), ) def test_plugin_pushover_urls(): """NotifyPushover() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_pushover_attachments(mock_post, tmpdir): """NotifyPushover() Attachment Checks.""" # Initialize some generic (but valid) tokens user_key = "u" * 30 api_token = "a" * 30 # Prepare a good response response = mock.Mock() response.content = dumps( {"status": 1, "request": "647d2300-702c-4b38-8b2f-d56326ae460b"} ) response.status_code = requests.codes.ok # Prepare a bad response bad_response = mock.Mock() bad_response.content = dumps( {"status": 1, "request": "647d2300-702c-4b38-8b2f-d56326ae460b"} ) bad_response.status_code = requests.codes.internal_server_error # Assign our good response mock_post.return_value = response # prepare our attachment attach = apprise.AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) # Instantiate our object obj = apprise.Apprise.instantiate(f"pover://{user_key}@{api_token}/") assert isinstance(obj, NotifyPushover) # Test our attachment assert obj.notify(body="test", attach=attach) is True # Test our call count assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.pushover.net/1/messages.json" ) # Reset our mock object for multiple tests mock_post.reset_mock() # Test multiple attachments assert attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) assert obj.notify(body="test", attach=attach) is True # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.pushover.net/1/messages.json" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.pushover.net/1/messages.json" ) # Reset our mock object for multiple tests mock_post.reset_mock() image = tmpdir.mkdir("pover_image").join("test.jpg") image.write("a" * NotifyPushover.attach_max_size_bytes) attach = apprise.AppriseAttachment.instantiate(str(image)) assert obj.notify(body="test", attach=attach) is True # Test our call count assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.pushover.net/1/messages.json" ) # Reset our mock object for multiple tests mock_post.reset_mock() # Add 1 more byte to the file (putting it over the limit) image.write("a" * (NotifyPushover.attach_max_size_bytes + 1)) attach = apprise.AppriseAttachment.instantiate(str(image)) assert obj.notify(body="test", attach=attach) is False # Test our call count assert mock_post.call_count == 0 # Test case when file is missing attach = apprise.AppriseAttachment.instantiate( f"file://{image!s}?cache=False" ) os.unlink(str(image)) assert obj.notify(body="body", title="title", attach=attach) is False # Test our call count assert mock_post.call_count == 0 # Test unsuported files: image = tmpdir.mkdir("pover_unsupported").join("test.doc") image.write("a" * 256) attach = apprise.AppriseAttachment.instantiate(str(image)) # Content is silently ignored assert obj.notify(body="test", attach=attach) is True # prepare our attachment attach = apprise.AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) # Throw an exception on the first call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.side_effect = [side_effect, side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # Same case without an attachment assert obj.send(body="test") is False @mock.patch("requests.post") def test_plugin_pushover_edge_cases(mock_post): """NotifyPushover() Edge Cases.""" # No token with pytest.raises(TypeError): NotifyPushover(token=None) # Initialize some generic (but valid) tokens token = "a" * 30 user_key = "u" * 30 invalid_device = "d" * 35 # Support strings devices = f"device1,device2,,,,{invalid_device}" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # No webhook id specified with pytest.raises(TypeError): NotifyPushover(user_key=user_key, webhook_id=None) obj = NotifyPushover(user_key=user_key, token=token, targets=devices) assert isinstance(obj, NotifyPushover) # Our invalid device is ignored; 2 valid devices remain assert len(obj.devices) == 2 assert len(obj.groups) == 0 # We notify the 2 devices loaded assert ( obj.notify( body="body", title="title", notify_type=apprise.NotifyType.INFO ) is True ) obj = NotifyPushover(user_key=user_key, token=token) assert isinstance(obj, NotifyPushover) # Default is to send to all devices (sentinel placed in devices list) assert len(obj.devices) == 1 assert len(obj.groups) == 0 assert len(obj) == 1 # This call succeeds because all of the devices are valid assert ( obj.notify( body="body", title="title", notify_type=apprise.NotifyType.INFO ) is True ) obj = NotifyPushover(user_key=user_key, token=token, targets=set()) assert isinstance(obj, NotifyPushover) # Default is to send to all devices (sentinel placed in devices list) assert len(obj.devices) == 1 assert len(obj.groups) == 0 assert len(obj) == 1 # Group targets group_key = "g" * 30 obj = NotifyPushover( user_key=user_key, token=token, targets=f"#{group_key}" ) assert isinstance(obj, NotifyPushover) assert len(obj.devices) == 0 assert len(obj.groups) == 1 assert obj.groups[0] == group_key assert len(obj) == 1 # Verify notification to a group succeeds (separate API call per group) assert ( obj.notify( body="body", title="title", notify_type=apprise.NotifyType.INFO ) is True ) # Mix of device and group β†’ 2 HTTP calls (1 device batch + 1 group) obj = NotifyPushover( user_key=user_key, token=token, targets=["device1", f"#{group_key}"] ) assert isinstance(obj, NotifyPushover) assert len(obj.devices) == 1 assert len(obj.groups) == 1 assert len(obj) == 2 # Invalid group key (contains dash β€” not alphanumeric) obj = NotifyPushover( user_key=user_key, token=token, targets="#invalid-group" ) assert isinstance(obj, NotifyPushover) # Invalid target is rejected; both lists empty β†’ send() returns False assert len(obj.devices) == 0 assert len(obj.groups) == 0 # Always 1 is returned as minimum assert len(obj) == 1 # No User Key specified with pytest.raises(TypeError): NotifyPushover(user_key=None, token="abcd") # No Access Token specified with pytest.raises(TypeError): NotifyPushover(user_key="abcd", token=None) with pytest.raises(TypeError): NotifyPushover(user_key="abcd", token=" ") @mock.patch("requests.post") def test_plugin_pushover_config_files(mock_post): """NotifyPushover() Config File Cases.""" content = """ urls: - pover://USER@TOKEN: - priority: -2 tag: pushover_int low - priority: "-2" tag: pushover_str_int low - priority: low tag: pushover_str low # This will take on normal (default) priority - priority: invalid tag: pushover_invalid - pover://USER2@TOKEN2: - priority: 2 tag: pushover_int emerg - priority: "2" tag: pushover_str_int emerg - priority: emergency tag: pushover_str emerg """ # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Create ourselves a config object ac = apprise.AppriseConfig() assert ac.add_config(content=content) is True aobj = apprise.Apprise() # Add our configuration aobj.add(ac) # We should be able to read our 7 servers from that # 3x low # 3x emerg # 1x invalid (so takes on normal priority) assert len(ac.servers()) == 7 assert len(aobj) == 7 assert len(list(aobj.find(tag="low"))) == 3 for s in aobj.find(tag="low"): assert s.priority == PushoverPriority.LOW assert len(list(aobj.find(tag="emerg"))) == 3 for s in aobj.find(tag="emerg"): assert s.priority == PushoverPriority.EMERGENCY assert len(list(aobj.find(tag="pushover_str"))) == 2 assert len(list(aobj.find(tag="pushover_str_int"))) == 2 assert len(list(aobj.find(tag="pushover_int"))) == 2 assert len(list(aobj.find(tag="pushover_invalid"))) == 1 assert ( next(aobj.find(tag="pushover_invalid")).priority == PushoverPriority.NORMAL ) # Notifications work # We test 'pushover_str_int' and 'low' which only matches 1 end point assert ( aobj.notify( title="title", body="body", tag=[("pushover_str_int", "low")] ) is True ) # Notify everything loaded assert aobj.notify(title="title", body="body") is True @mock.patch("requests.post") def test_plugin_pushover_group_request_exception(mock_post): """NotifyPushover() group RequestException must not raise KeyError.""" user_key = "u" * 30 token = "a" * 30 group_key = "g" * 30 mock_post.side_effect = requests.RequestException("connection error") obj = NotifyPushover( user_key=user_key, token=token, targets=f"#{group_key}" ) assert isinstance(obj, NotifyPushover) assert len(obj.groups) == 1 assert len(obj.devices) == 0 # Must return False cleanly -- no KeyError from payload["device"] assert obj.send(body="test") is False @mock.patch("requests.post") def test_plugin_pushover_url_roundtrip(mock_post): """NotifyPushover() url() must preserve sound, url, and url_title.""" from apprise.plugins.pushover import NotifyPushover user_key = "u" * 30 token = "a" * 30 mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok # Instantiate with all three round-trip fields set obj = NotifyPushover( user_key=user_key, token=token, sound="bike", supplemental_url="https://example.com", supplemental_url_title="Click Here", ) assert isinstance(obj, NotifyPushover) assert obj.sound == "bike" assert obj.supplemental_url == "https://example.com" assert obj.supplemental_url_title == "Click Here" # Round-trip via url() -> parse_url() -> re-instantiate generated_url = obj.url() assert "sound=bike" in generated_url assert "url=" in generated_url assert "url_title=" in generated_url parsed = NotifyPushover.parse_url(generated_url) assert parsed is not None assert parsed["sound"] == "bike" assert parsed["supplemental_url"] == "https://example.com" assert parsed["supplemental_url_title"] == "Click Here" obj2 = NotifyPushover(**parsed) assert obj2.sound == "bike" assert obj2.supplemental_url == "https://example.com" assert obj2.supplemental_url_title == "Click Here" def test_plugin_pushover_parse_url_title_unquote(): """NotifyPushover() parse_url() unquote url_title like other fields.""" user_key = "u" * 30 token = "a" * 30 # url_title with percent-encoded spaces and special chars url = "pover://{}@{}/?url_title=Hello%20World".format(user_key, token) parsed = NotifyPushover.parse_url(url) assert parsed is not None # Must be decoded, not raw percent-encoded assert parsed["supplemental_url_title"] == "Hello World" # Spacing is fine too url = "pover://{}@{}/?url_title=Hello World".format(user_key, token) parsed = NotifyPushover.parse_url(url) assert parsed is not None # Must be decoded, not raw percent-encoded assert parsed["supplemental_url_title"] == "Hello World" @mock.patch("requests.post") def test_plugin_pushover_attach_memory(mock_post): """Regression: AttachMemory must be sendable without OSError.""" from apprise.attachment.memory import AttachMemory user_key = "u" * 30 api_token = "a" * 30 response = mock.Mock() response.content = dumps( {"status": 1, "request": "647d2300-702c-4b38-8b2f-d56326ae460b"} ) response.status_code = requests.codes.ok mock_post.return_value = response obj = apprise.Apprise.instantiate(f"pover://{user_key}@{api_token}/") assert isinstance(obj, NotifyPushover) mem = AttachMemory( content=b"

    Test

    ", name="report.html", mimetype="text/html", ) assert obj.notify(body="Test", attach=mem) is True assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_pushover_e2ee(mock_post): """NotifyPushover() E2EE encryption support.""" import apprise.plugins.pushover as _pover_mod user_key = "u" * 30 token = "a" * 30 # A valid 256-bit AES key expressed as 64 hex characters valid_key = "ab" * 32 # 64 chars response = mock.Mock() response.content = dumps( {"status": 1, "request": "647d2300-702c-4b38-8b2f-d56326ae460b"} ) response.status_code = requests.codes.ok mock_post.return_value = response # --- Invalid key: too short --- with pytest.raises(TypeError): NotifyPushover(user_key=user_key, token=token, encryption_key="abc") # --- Invalid key: 64 chars but non-hex --- with pytest.raises(TypeError): NotifyPushover(user_key=user_key, token=token, encryption_key="z" * 64) # --- Valid key: object instantiates correctly --- obj = NotifyPushover( user_key=user_key, token=token, encryption_key=valid_key ) assert isinstance(obj, NotifyPushover) assert obj.encryption_key == valid_key # e2ee defaults to True assert obj.e2ee is True # --- e2ee=False: encryption disabled even when key present --- obj_no_e2ee = NotifyPushover( user_key=user_key, token=token, encryption_key=valid_key, e2ee=False ) assert obj_no_e2ee.e2ee is False # --- No key: e2ee flag is still stored but encryption is skipped --- obj_nokey = NotifyPushover(user_key=user_key, token=token) assert obj_nokey.encryption_key is None # Send without key succeeds normally (no encryption path taken) assert obj_nokey.send(body="test") is True assert mock_post.call_count == 1 mock_post.reset_mock() # --- URL round-trip with key: key and e2ee survive parse/reconstruct --- generated = obj.url() assert "key=" in generated # e2ee=no is NOT emitted because the default is True assert "e2ee=" not in generated parsed = NotifyPushover.parse_url(generated) assert parsed is not None assert parsed["encryption_key"] == valid_key obj2 = NotifyPushover(**parsed) assert obj2.encryption_key == valid_key assert obj2.e2ee is True assert obj.url_identifier == obj2.url_identifier # --- URL round-trip with key + e2ee=no --- generated_no = obj_no_e2ee.url() assert "e2ee=no" in generated_no parsed_no = NotifyPushover.parse_url(generated_no) assert parsed_no is not None obj3 = NotifyPushover(**parsed_no) assert obj3.e2ee is False assert obj3.encryption_key == valid_key # --- Privacy URL hides the key --- priv = obj.url(privacy=True) assert valid_key not in priv assert "key=" in priv # --- Successful encrypted send (patch support flag + _encrypt_field so # the test works in both minimal and qa tox environments) --- _fake_enc = "FAKE_ENC_BASE64VALUE==" mock_post.reset_mock() with ( mock.patch.object(_pover_mod, "PUSHOVER_E2EE_SUPPORT", True), mock.patch.object(obj, "_encrypt_field", return_value=_fake_enc), ): assert obj.send(body="Hello", title="World") is True assert mock_post.call_count == 1 call_data = mock_post.call_args[1]["data"] # The API must receive encrypted=1 assert call_data.get("encrypted") == 1 # message and title must be the fake-encrypted string assert call_data["message"] == _fake_enc assert call_data["title"] == _fake_enc # --- Encrypted send with url and url_title fields --- mock_post.reset_mock() obj_url = NotifyPushover( user_key=user_key, token=token, encryption_key=valid_key, supplemental_url="https://example.com", supplemental_url_title="Click", ) with ( mock.patch.object(_pover_mod, "PUSHOVER_E2EE_SUPPORT", True), mock.patch.object(obj_url, "_encrypt_field", return_value=_fake_enc), ): assert obj_url.send(body="body", title="title") is True call_data = mock_post.call_args[1]["data"] assert call_data.get("encrypted") == 1 assert call_data["url"] == _fake_enc assert call_data["url_title"] == _fake_enc # --- e2ee=False: plaintext send despite key being set --- mock_post.reset_mock() assert obj_no_e2ee.send(body="plaintext", title="ptitle") is True call_data = mock_post.call_args[1]["data"] assert "encrypted" not in call_data assert call_data["message"] == "plaintext" assert call_data["title"] == "ptitle" # --- Fallback: PUSHOVER_E2EE_SUPPORT patched to False --- mock_post.reset_mock() orig_support = _pover_mod.PUSHOVER_E2EE_SUPPORT _pover_mod.PUSHOVER_E2EE_SUPPORT = False try: # Should warn and fall back to sending unencrypted result = obj.send(body="unenc body", title="unenc title") assert result is True assert mock_post.call_count == 1 call_data = mock_post.call_args[1]["data"] # encrypted flag must NOT be set when we fell back assert "encrypted" not in call_data assert call_data["message"] == "unenc body" finally: _pover_mod.PUSHOVER_E2EE_SUPPORT = orig_support # --- Crypto failure in _encrypt_field causes send() to return False --- mock_post.reset_mock() with ( mock.patch.object(_pover_mod, "PUSHOVER_E2EE_SUPPORT", True), mock.patch.object( obj, "_encrypt_field", side_effect=ValueError("boom") ), ): result = obj.send(body="fail body", title="fail title") assert result is False assert mock_post.call_count == 0 # --- runtime_deps returns tuple containing 'cryptography' --- deps = NotifyPushover.runtime_deps() assert isinstance(deps, tuple) assert "cryptography" in deps def test_plugin_pushover_e2ee_field_encrypt(): """Exercise _encrypt_field directly; skipped when cryptography absent.""" import apprise.plugins.pushover as _pover_mod pytest.importorskip("cryptography") user_key = "u" * 30 token = "a" * 30 valid_key = "ab" * 32 # 64 hex chars = 32-byte AES-256 key obj = NotifyPushover( user_key=user_key, token=token, encryption_key=valid_key ) key_bytes = bytes.fromhex(valid_key) # Happy path: should return a non-empty ASCII base64 string plaintext = "hello world" result = obj._encrypt_field(plaintext, key_bytes) assert isinstance(result, str) assert len(result) > 0 # Self-decryption round-trip: verify the blob layout and that # decrypting it reproduces the original plaintext. # This validates our AES + HMAC + gzip implementation matches # the Pushover spec (IV || ciphertext || HMAC, all base64 encoded). import base64 as _b64 import gzip as _gzip import hmac as _hmac_std from cryptography.hazmat.primitives import ( hashes as _h, hmac as _hmac_crypt, padding as _pad, ) from cryptography.hazmat.primitives.ciphers import ( Cipher as _C, algorithms as _alg, modes as _mode, ) blob = _b64.b64decode(result) # Minimum: 16-byte IV + 16-byte AES block + 32-byte HMAC = 64 B assert len(blob) >= 64 iv = blob[:16] mac_from_blob = blob[-32:] ct = blob[16:-32] # Verify HMAC-SHA256 over IV || ciphertext h = _hmac_crypt.HMAC(key_bytes, _h.SHA256()) h.update(iv + ct) expected_mac = h.finalize() assert _hmac_std.compare_digest(mac_from_blob, expected_mac) # AES-256-CBC decrypt cipher = _C(_alg.AES(key_bytes), _mode.CBC(iv)) decryptor = cipher.decryptor() padded_plain = decryptor.update(ct) + decryptor.finalize() # Remove PKCS7 padding unpadder = _pad.PKCS7(128).unpadder() compressed = unpadder.update(padded_plain) + unpadder.finalize() # Gzip decompress and compare to original plaintext recovered = _gzip.decompress(compressed).decode("utf-8") assert recovered == plaintext # Empty string is also a valid input (compressed + encrypted) result_empty = obj._encrypt_field("", key_bytes) assert isinstance(result_empty, str) assert len(result_empty) > 0 blob_empty = _b64.b64decode(result_empty) assert len(blob_empty) >= 64 # Exception path: patch urandom to trigger the except/raise branch with ( mock.patch.object( _pover_mod._os, "urandom", side_effect=OSError("rng failure") ), pytest.raises(OSError, match="rng failure"), ): obj._encrypt_field("boom", key_bytes) caronc-apprise-182f859/tests/test_plugin_pushplus.py000066400000000000000000001001471524161175200227330ustar00rootroot00000000000000# # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import dumps, loads import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise from apprise.common import NotifyFormat from apprise.plugins.pushplus import ( PUSHPLUS_CHANNEL_DEFAULT, PUSHPLUS_CHANNELS, PUSHPLUS_FORMAT_MAP, NotifyPushplus, PushPlusChannel, ) logging.disable(logging.CRITICAL) # A 32-character token used throughout the tests (minimum valid length) GOOD_TOKEN = "abc123def456ghi789jkl012mno345pq" # A 64-character token (upper boundary of the allowed length range) LONG_TOKEN = "a" * 64 # PushPlus always returns HTTP 200; success/failure lives in the JSON body GOOD_RESPONSE = dumps({"code": 200, "msg": "ok", "data": "msgid"}) BAD_RESPONSE = dumps({"code": 907, "msg": "Token does not exist."}) # Our Testing URLs apprise_url_tests = ( # ---------------------------------------------------------------- # Invalid / missing token cases # ---------------------------------------------------------------- ( "pushplus://", { # Empty token must raise "instance": TypeError, }, ), ( "pushplus://short", { # Token too short (fewer than 32 characters) "instance": TypeError, }, ), ( "pushplus://invalid!chars00000000000000000000000000000", { # Token contains characters not allowed by the regex "instance": TypeError, }, ), # ---------------------------------------------------------------- # Valid token -- basic personal notification (no targets) # ---------------------------------------------------------------- ( "pushplus://{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, # Privacy URL must mask the token "privacy_url": "pushplus://****/", "requests_response_text": GOOD_RESPONSE, }, ), # Token supplied as a query parameter instead of the URL host ( "pushplus://?token={}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "privacy_url": "pushplus://****/", "requests_response_text": GOOD_RESPONSE, }, ), # 64-character (maximum-length) token ( "pushplus://{}".format(LONG_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Group topic variations # ---------------------------------------------------------------- # Single topic in the URL path (no prefix required) ( "pushplus://{}/mygroup".format(GOOD_TOKEN), { "instance": NotifyPushplus, "privacy_url": "pushplus://****/mygroup/", "requests_response_text": GOOD_RESPONSE, }, ), # Two topics in the path -- two API calls are made ( "pushplus://{}/group1/group2".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # Topic supplied via the backward-compatible ?topic= parameter ( "pushplus://{}/?topic=mygroup".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # Topic supplied via the ?to= convenience alias ( "pushplus://{}/?to=mygroup".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # Invalid topic characters are silently moved to invalid_targets; # the object still instantiates and sends a personal notification ( "pushplus://{}/bad!topic".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Delivery channel via ?channel= query parameter # ---------------------------------------------------------------- ( "pushplus://{}?channel=mail".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushplus://{}?channel=sms".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushplus://{}?channel=cp".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # wecom is a friendly alias that maps to the "cp" API channel ( "pushplus://{}?channel=wecom".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # mode= is a synonym for channel= ( "pushplus://{}?mode=mail".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Schema alias that auto-sets the delivery channel # ---------------------------------------------------------------- # wecom:// auto-sets channel=cp (WeCom / Enterprise WeChat) ( "wecom://{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Webhook channel with a named endpoint -- both input forms accepted # ---------------------------------------------------------------- ( "pushplus://{}?channel=webhook&name=myhook".format(GOOD_TOKEN), { "instance": NotifyPushplus, # url() emits the compact schema://name@token form "privacy_url": "pushplus://myhook@", "requests_response_text": GOOD_RESPONSE, }, ), # schema://name@token form -- channel=webhook is implied ( "pushplus://myhook@{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "privacy_url": "pushplus://myhook@", "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Native PushPlus API URL (parse_native_url support) # ---------------------------------------------------------------- ( "https://www.pushplus.plus/send?token={}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # API application-level failure (HTTP 200 but JSON code != 200) # ---------------------------------------------------------------- ( "pushplus://{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "response": False, "requests_response_text": BAD_RESPONSE, }, ), # Unparsable JSON body is treated as a failure ( "pushplus://{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "response": False, "requests_response_text": "{bad json", }, ), # None body is treated as a failure ( "pushplus://{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "response": False, "requests_response_text": None, }, ), # ---------------------------------------------------------------- # HTTP-level failures # ---------------------------------------------------------------- ( "pushplus://{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pushplus://{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "response": False, "requests_response_code": 999, }, ), # ---------------------------------------------------------------- # Connection / socket exceptions # ---------------------------------------------------------------- ( "pushplus://{}".format(GOOD_TOKEN), { "instance": NotifyPushplus, "test_requests_exceptions": True, }, ), ) def test_plugin_pushplus_urls(): """NotifyPushplus() Apprise URL test suite.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_pushplus_init(): """NotifyPushplus() initialisation and parameter handling.""" # Minimal valid instantiation -- no targets obj = NotifyPushplus(token=GOOD_TOKEN) assert obj.token == GOOD_TOKEN assert obj.topics == [] assert obj.channel == PUSHPLUS_CHANNEL_DEFAULT assert obj.webhook is None assert obj.invalid_targets == [] # Single group topic via targets list (no prefix required) obj = NotifyPushplus(token=GOOD_TOKEN, targets=["mygroup"]) assert obj.topics == ["mygroup"] assert obj.channel == PUSHPLUS_CHANNEL_DEFAULT # Multiple group topics obj = NotifyPushplus( token=GOOD_TOKEN, targets=["group1", "group2", "group3"] ) assert obj.topics == ["group1", "group2", "group3"] # Explicit channel via channel= argument obj = NotifyPushplus(token=GOOD_TOKEN, channel="mail") assert obj.channel == PushPlusChannel.MAIL # wecom friendly alias normalises to the "cp" API value obj = NotifyPushplus(token=GOOD_TOKEN, channel="wecom") assert obj.channel == PushPlusChannel.WECOM assert obj.channel == "cp" # cp is the canonical API value (same result as wecom) obj = NotifyPushplus(token=GOOD_TOKEN, channel="cp") assert obj.channel == "cp" # Channel is case-insensitive obj = NotifyPushplus(token=GOOD_TOKEN, channel="MAIL") assert obj.channel == PushPlusChannel.MAIL # Invalid channel raises TypeError import pytest with pytest.raises(TypeError): NotifyPushplus(token=GOOD_TOKEN, channel="invalid_channel") # Invalid target (bad characters) goes to invalid_targets obj = NotifyPushplus(token=GOOD_TOKEN, targets=["bad!target"]) assert obj.topics == [] assert obj.invalid_targets == ["bad!target"] # Invalid token raises TypeError with pytest.raises(TypeError): NotifyPushplus(token="short") with pytest.raises(TypeError): NotifyPushplus(token="bad!token" + "0" * 30) # Webhook value stored as None when falsy obj = NotifyPushplus(token=GOOD_TOKEN, webhook="") assert obj.webhook is None # Webhook value stored when truthy obj = NotifyPushplus( token=GOOD_TOKEN, channel="webhook", webhook="myhook", ) assert obj.webhook == "myhook" assert obj.channel == PushPlusChannel.WEBHOOK def test_plugin_pushplus_schema_aliases(): """NotifyPushplus() wecom:// schema alias.""" # wecom:// schema sets channel=cp (WeCom API value) result = NotifyPushplus.parse_url("wecom://{}".format(GOOD_TOKEN)) assert result is not None obj = NotifyPushplus(**result) assert obj.channel == PushPlusChannel.WECOM assert obj.channel == "cp" # An explicit channel= overrides the schema-implied channel result = NotifyPushplus.parse_url( "wecom://{}?channel=mail".format(GOOD_TOKEN) ) assert result is not None obj = NotifyPushplus(**result) assert obj.channel == PushPlusChannel.MAIL def test_plugin_pushplus_all_channels(): """NotifyPushplus() accepts every documented API channel value.""" for ch in PUSHPLUS_CHANNELS: # Each channel must initialise without raising obj = NotifyPushplus(token=GOOD_TOKEN, channel=ch) assert obj.channel == ch # Channel matching must be case-insensitive obj2 = NotifyPushplus(token=GOOD_TOKEN, channel=ch.upper()) assert obj2.channel == ch def test_plugin_pushplus_url(): """NotifyPushplus() url() output and round-trip fidelity.""" # Personal notification URL -- no topics in path, default channel omitted obj = NotifyPushplus(token=GOOD_TOKEN) url = obj.url() assert url.startswith("pushplus://") # Token appears in plain text assert GOOD_TOKEN in url # No topic path segment assert "/mygroup" not in url # Default channel is omitted from params assert "channel=" not in url # Privacy URL must mask the token priv = obj.url(privacy=True) assert GOOD_TOKEN not in priv assert "****" in priv # Group topic URL -- topic appears in the path obj = NotifyPushplus(token=GOOD_TOKEN, targets=["mygroup"]) url = obj.url() assert "/mygroup/" in url priv = obj.url(privacy=True) assert "/mygroup/" in priv assert GOOD_TOKEN not in priv # Multiple topics all appear in the path obj = NotifyPushplus(token=GOOD_TOKEN, targets=["grp1", "grp2"]) url = obj.url() assert "grp1" in url assert "grp2" in url # Non-default channel appears as ?channel= query parameter obj = NotifyPushplus(token=GOOD_TOKEN, channel="mail") url = obj.url() assert "channel=mail" in url # Default channel (wechat) is suppressed from the URL obj = NotifyPushplus(token=GOOD_TOKEN, channel="wechat") url = obj.url() assert "channel=" not in url # When channel=webhook with a name, url() uses schema://name@token form obj = NotifyPushplus( token=GOOD_TOKEN, channel="webhook", webhook="myhook", ) url = obj.url() assert "pushplus://myhook@" in url # channel= and name= are both implied by user@ -- omitted from params assert "channel=" not in url assert "name=" not in url # Privacy URL preserves the webhook name but masks the token priv = obj.url(privacy=True) assert "pushplus://myhook@" in priv assert GOOD_TOKEN not in priv assert "****" in priv # Webhook prefix form with group topics -- schema://name@token/topic/ form obj = NotifyPushplus( token=GOOD_TOKEN, channel="webhook", webhook="myhook", targets=["mygroup"], ) url = obj.url() assert "pushplus://myhook@" in url assert "mygroup" in url assert "channel=" not in url assert "name=" not in url # Webhook name suppressed when channel is not webhook obj = NotifyPushplus( token=GOOD_TOKEN, channel="mail", webhook="myhook", ) url = obj.url() assert "name=" not in url assert "@" not in url # Webhook channel without a name uses the plain ?channel=webhook form obj = NotifyPushplus(token=GOOD_TOKEN, channel="webhook") url = obj.url() assert "name=" not in url assert "channel=webhook" in url assert "@" not in url # Invalid targets are preserved in the URL path for round-trip fidelity obj = NotifyPushplus(token=GOOD_TOKEN, targets=["bad!target"]) url = obj.url() assert "bad" in url # url_identifier contains only the schema and token (no targets or channel) obj = NotifyPushplus(token=GOOD_TOKEN, targets=["grp"]) assert obj.url_identifier == ("pushplus", GOOD_TOKEN) # Schema alias (wecom://) always normalises back to pushplus:// result = NotifyPushplus.parse_url("wecom://{}".format(GOOD_TOKEN)) obj = NotifyPushplus(**result) url = obj.url() assert url.startswith("pushplus://") assert "channel=cp" in url @mock.patch("requests.post") def test_plugin_pushplus_send_ok(mock_post): """NotifyPushplus() successful personal-notification send.""" # Prepare a successful mock response response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response # Personal notification (no topic) -- single API call obj = NotifyPushplus(token=GOOD_TOKEN) assert obj.send(body="hello", title="title") is True assert mock_post.call_count == 1 # Verify the endpoint URL call = mock_post.call_args assert call[0][0] == "https://www.pushplus.plus/send" # Verify the core payload fields payload = loads(call[1]["data"]) assert payload["token"] == GOOD_TOKEN assert payload["content"] == "hello" assert payload["title"] == "title" # Default format is HTML -- PushPlus template should be "html" assert payload["template"] == PUSHPLUS_FORMAT_MAP[NotifyFormat.HTML] assert payload["channel"] == PUSHPLUS_CHANNEL_DEFAULT # topic and webhook keys must be absent when not configured assert "topic" not in payload assert "webhook" not in payload mock_post.reset_mock() response.content = GOOD_RESPONSE.encode("utf-8") # When no title is supplied the body is used as the title fallback assert obj.send(body="only body") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["title"] == "only body" assert payload["content"] == "only body" @mock.patch("requests.post") def test_plugin_pushplus_send_formats(mock_post): """NotifyPushplus() maps notify_format to the PushPlus template field.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response obj = NotifyPushplus(token=GOOD_TOKEN) # Markdown format -> "markdown" template obj.notify_format = NotifyFormat.MARKDOWN assert obj.send(body="# heading") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["template"] == "markdown" mock_post.reset_mock() response.content = GOOD_RESPONSE.encode("utf-8") # Text format -> "txt" template obj.notify_format = NotifyFormat.TEXT assert obj.send(body="plain text") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["template"] == "txt" mock_post.reset_mock() response.content = GOOD_RESPONSE.encode("utf-8") # HTML format -> "html" template obj.notify_format = NotifyFormat.HTML assert obj.send(body="bold") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["template"] == "html" @mock.patch("requests.post") def test_plugin_pushplus_send_topic(mock_post): """NotifyPushplus() includes the topic in the payload when configured.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response # Single topic -- one API call, topic present in payload obj = NotifyPushplus(token=GOOD_TOKEN, targets=["mygroup"]) assert obj.send(body="group msg") is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args[1]["data"]) assert payload["topic"] == "mygroup" @mock.patch("requests.post") def test_plugin_pushplus_send_multiple_topics(mock_post): """NotifyPushplus() makes one API call per group topic.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response # Three topics -> three separate API calls obj = NotifyPushplus( token=GOOD_TOKEN, targets=["topic1", "topic2", "topic3"] ) assert len(obj.topics) == 3 assert obj.send(body="multi msg") is True assert mock_post.call_count == 3 # Verify each call carries the correct topic value calls = mock_post.call_args_list topics_sent = [loads(c[1]["data"])["topic"] for c in calls] assert topics_sent == ["topic1", "topic2", "topic3"] mock_post.reset_mock() response.content = GOOD_RESPONSE.encode("utf-8") # Topics with a channel override -- all API calls use the specified channel obj2 = NotifyPushplus( token=GOOD_TOKEN, targets=["grp1", "grp2"], channel="mail" ) assert len(obj2.topics) == 2 assert obj2.channel == PushPlusChannel.MAIL assert obj2.send(body="mixed") is True assert mock_post.call_count == 2 for call in mock_post.call_args_list: assert loads(call[1]["data"])["channel"] == "mail" mock_post.reset_mock() # Partial failure: first topic succeeds, second fails -> overall False good_resp = mock.Mock() good_resp.status_code = requests.codes.ok good_resp.content = GOOD_RESPONSE.encode("utf-8") bad_resp = mock.Mock() bad_resp.status_code = requests.codes.ok bad_resp.content = BAD_RESPONSE.encode("utf-8") mock_post.side_effect = [good_resp, bad_resp] obj3 = NotifyPushplus(token=GOOD_TOKEN, targets=["grp1", "grp2"]) assert obj3.send(body="partial") is False assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_pushplus_send_webhook(mock_post): """NotifyPushplus() webhook channel behaviour.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response # Webhook channel with a named endpoint -- webhook key in payload obj = NotifyPushplus( token=GOOD_TOKEN, channel="webhook", webhook="myhook", ) assert obj.send(body="hook msg") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["channel"] == PushPlusChannel.WEBHOOK assert payload["webhook"] == "myhook" mock_post.reset_mock() response.content = GOOD_RESPONSE.encode("utf-8") # Webhook channel without a name -- webhook key must be absent obj2 = NotifyPushplus(token=GOOD_TOKEN, channel="webhook") assert obj2.send(body="hook no name") is True payload2 = loads(mock_post.call_args[1]["data"]) assert "webhook" not in payload2 mock_post.reset_mock() response.content = GOOD_RESPONSE.encode("utf-8") # Non-webhook channel with a webhook value set -- key must be absent obj3 = NotifyPushplus( token=GOOD_TOKEN, channel="mail", webhook="myhook", ) assert obj3.send(body="mail msg") is True payload3 = loads(mock_post.call_args[1]["data"]) assert "webhook" not in payload3 @mock.patch("requests.post") def test_plugin_pushplus_send_http_error(mock_post): """NotifyPushplus() handles HTTP-level errors correctly.""" response = mock.Mock() response.content = b"" mock_post.return_value = response obj = NotifyPushplus(token=GOOD_TOKEN) # HTTP 500 response.status_code = requests.codes.internal_server_error assert obj.send(body="msg") is False # Unknown HTTP status code response.status_code = 999 assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_pushplus_send_api_error(mock_post): """NotifyPushplus() handles API-level failures (HTTP 200, bad code).""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = NotifyPushplus(token=GOOD_TOKEN) # Known API error code (907 = Token does not exist) response.content = BAD_RESPONSE.encode("utf-8") assert obj.send(body="msg") is False # Unknown API error code with a msg field -- falls back to msg response.content = dumps({"code": 999, "msg": "some error"}).encode( "utf-8" ) assert obj.send(body="msg") is False # Unknown API error code with no msg field -- generic fallback response.content = dumps({"code": 999}).encode("utf-8") assert obj.send(body="msg") is False # Unparsable JSON body response.content = b"{bad json" assert obj.send(body="msg") is False # None content -- treated as unparsable response.content = None assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_pushplus_send_exception(mock_post): """NotifyPushplus() handles connection exceptions gracefully.""" mock_post.side_effect = requests.RequestException("connection error") obj = NotifyPushplus(token=GOOD_TOKEN) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_pushplus_send_exception_multi_topic(mock_post): """NotifyPushplus() continues after a connection error on one topic.""" # First call raises, second succeeds -- overall result is False good_resp = mock.Mock() good_resp.status_code = requests.codes.ok good_resp.content = GOOD_RESPONSE.encode("utf-8") mock_post.side_effect = [ requests.RequestException("network error"), good_resp, ] obj = NotifyPushplus(token=GOOD_TOKEN, targets=["grp1", "grp2"]) assert obj.send(body="msg") is False # Both topics must still be attempted assert mock_post.call_count == 2 def test_plugin_pushplus_parse_url(): """NotifyPushplus() parse_url() extracts all supported fields.""" # Token in the host position result = NotifyPushplus.parse_url("pushplus://{}".format(GOOD_TOKEN)) assert result["token"] == GOOD_TOKEN assert result["targets"] == [] # Token supplied as a ?token= query parameter result = NotifyPushplus.parse_url( "pushplus://?token={}".format(GOOD_TOKEN) ) assert result["token"] == GOOD_TOKEN # Topic in the URL path becomes a plain targets entry result = NotifyPushplus.parse_url( "pushplus://{}/mygroup".format(GOOD_TOKEN) ) assert "mygroup" in result["targets"] # Two topics in the path result = NotifyPushplus.parse_url( "pushplus://{}/grp1/grp2".format(GOOD_TOKEN) ) assert "grp1" in result["targets"] assert "grp2" in result["targets"] # Backward compat: ?topic= adds a plain topic entry result = NotifyPushplus.parse_url( "pushplus://{}/?topic=mygroup".format(GOOD_TOKEN) ) assert "mygroup" in result["targets"] # ?to= alias appends additional targets result = NotifyPushplus.parse_url( "pushplus://{}/?to=mygroup".format(GOOD_TOKEN) ) assert "mygroup" in result["targets"] # Delivery channel via ?channel= result = NotifyPushplus.parse_url( "pushplus://{}?channel=mail".format(GOOD_TOKEN) ) assert result.get("channel") == "mail" # Delivery channel via ?mode= alias result = NotifyPushplus.parse_url( "pushplus://{}?mode=sms".format(GOOD_TOKEN) ) assert result.get("channel") == "sms" # channel= takes precedence over mode= when both are supplied result = NotifyPushplus.parse_url( "pushplus://{}?mode=sms&channel=mail".format(GOOD_TOKEN) ) assert result.get("channel") == "mail" # Webhook name extracted into the "webhook" key from ?name= result = NotifyPushplus.parse_url( "pushplus://{}?channel=webhook&name=myhook".format(GOOD_TOKEN) ) assert result.get("webhook") == "myhook" assert result.get("channel") == "webhook" # schema://name@token form: user@ -> webhook, implies channel=webhook result = NotifyPushplus.parse_url( "pushplus://myhook@{}".format(GOOD_TOKEN) ) assert result.get("webhook") == "myhook" assert result.get("channel") == PushPlusChannel.WEBHOOK # Explicit ?channel= overrides the webhook implication from user@ result = NotifyPushplus.parse_url( "pushplus://myhook@{}?channel=mail".format(GOOD_TOKEN) ) assert result.get("webhook") == "myhook" assert result.get("channel") == "mail" # ?name= takes precedence over user@ when both are supplied result = NotifyPushplus.parse_url( "pushplus://other@{}?channel=webhook&name=myhook".format(GOOD_TOKEN) ) assert result.get("webhook") == "myhook" assert result.get("channel") == "webhook" def test_plugin_pushplus_parse_native_url(): """NotifyPushplus() parse_native_url() handles all edge cases.""" # Basic native URL -- token extracted correctly result = NotifyPushplus.parse_native_url( "https://www.pushplus.plus/send?token={}".format(GOOD_TOKEN) ) assert result is not None obj = NotifyPushplus(**result) assert obj.token == GOOD_TOKEN # Native URL with topic parameter preserved via round-trip result = NotifyPushplus.parse_native_url( "https://www.pushplus.plus/send?token={}&topic=mygroup".format( GOOD_TOKEN ) ) assert result is not None obj = NotifyPushplus(**result) assert "mygroup" in obj.topics # Native URL with channel parameter preserved result = NotifyPushplus.parse_native_url( "https://www.pushplus.plus/send?token={}&channel=mail".format( GOOD_TOKEN ) ) assert result is not None obj = NotifyPushplus(**result) assert obj.channel == PushPlusChannel.MAIL # Native URL with webhook name parameter preserved result = NotifyPushplus.parse_native_url( "https://www.pushplus.plus/send" "?token={}&channel=webhook&name=myhook".format(GOOD_TOKEN) ) assert result is not None obj = NotifyPushplus(**result) assert obj.webhook == "myhook" assert obj.channel == PushPlusChannel.WEBHOOK # Native URL without a token -- not matched, returns None assert ( NotifyPushplus.parse_native_url( "https://www.pushplus.plus/send?channel=mail" ) is None ) # Completely different domain -- not matched, returns None assert ( NotifyPushplus.parse_native_url( "https://other.example.com/send?token={}".format(GOOD_TOKEN) ) is None ) # Native URL with no query string -- not matched, returns None assert ( NotifyPushplus.parse_native_url("https://www.pushplus.plus/send") is None ) @mock.patch("requests.post") def test_plugin_pushplus_apprise_integration(mock_post): """NotifyPushplus() integrates correctly with the Apprise object.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = GOOD_RESPONSE.encode("utf-8") mock_post.return_value = response # Personal URL -- single notification aobj = Apprise() assert aobj.add("pushplus://{}".format(GOOD_TOKEN)) assert len(aobj) == 1 assert aobj.notify(title="Test Title", body="Test Body") is True assert mock_post.call_count == 1 mock_post.reset_mock() response.content = GOOD_RESPONSE.encode("utf-8") # Group URL with markdown format override aobj2 = Apprise() assert aobj2.add( "pushplus://{}/mygroup?format=markdown".format(GOOD_TOKEN) ) assert aobj2.notify(body="Group message") is True assert mock_post.call_count == 1 payload = loads(mock_post.call_args[1]["data"]) # format=markdown must map to the PushPlus "markdown" template assert payload["template"] == "markdown" assert payload["topic"] == "mygroup" mock_post.reset_mock() response.content = GOOD_RESPONSE.encode("utf-8") # wecom:// schema alias -- channel=cp in payload aobj3 = Apprise() assert aobj3.add("wecom://{}".format(GOOD_TOKEN)) assert aobj3.notify(body="WeCom message") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["channel"] == PushPlusChannel.WECOM caronc-apprise-182f859/tests/test_plugin_pushsafer.py000066400000000000000000000240641524161175200230530ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import AppriseAttachment, NotifyType from apprise.plugins.pushsafer import NotifyPushSafer logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "psafer://:@/", { "instance": TypeError, }, ), ( "psafer://", { "instance": TypeError, }, ), ( "psafers://", { "instance": TypeError, }, ), ( "psafer://{}".format("a" * 20), { "instance": NotifyPushSafer, # This will fail because we're also expecting a server # acknowledgement "notify_response": False, }, ), ( "psafer://{}".format("b" * 20), { "instance": NotifyPushSafer, # invalid JSON response "requests_response_text": "{", "notify_response": False, }, ), ( "psafer://{}".format("c" * 20), { "instance": NotifyPushSafer, # A failure has status set to zero # We also expect an 'error' flag to be set "requests_response_text": {"status": 0, "error": "we failed"}, "notify_response": False, }, ), ( "psafers://{}".format("d" * 20), { "instance": NotifyPushSafer, # A failure has status set to zero # Test without an 'error' flag "requests_response_text": { "status": 0, }, "notify_response": False, }, ), # This will notify all users ('a') ( "psafer://{}".format("e" * 20), { "instance": NotifyPushSafer, # A status of 1 is a success "requests_response_text": { "status": 1, }, }, ), # This will notify a selected set of devices ( "psafer://{}/12/24/53".format("e" * 20), { "instance": NotifyPushSafer, # A status of 1 is a success "requests_response_text": { "status": 1, }, }, ), # Same as above, but exercises the to= argument ( "psafer://{}?to=12,24,53".format("e" * 20), { "instance": NotifyPushSafer, # A status of 1 is a success "requests_response_text": { "status": 1, }, }, ), # Set priority ( "psafer://{}?priority=emergency".format("f" * 20), { "instance": NotifyPushSafer, "requests_response_text": { "status": 1, }, }, ), # Support integer value too ( "psafer://{}?priority=-1".format("f" * 20), { "instance": NotifyPushSafer, "requests_response_text": { "status": 1, }, "force_debug": True, }, ), # Invalid priority ( "psafer://{}?priority=invalid".format("f" * 20), { # Invalid Priority "instance": TypeError, }, ), # Invalid priority ( "psafer://{}?priority=25".format("f" * 20), { # Invalid Priority "instance": TypeError, }, ), # Set sound ( "psafer://{}?sound=ok".format("g" * 20), { "instance": NotifyPushSafer, "requests_response_text": { "status": 1, }, }, ), # Support integer value too ( "psafers://{}?sound=14".format("g" * 20), { "instance": NotifyPushSafer, "requests_response_text": { "status": 1, }, "privacy_url": "psafers://g...g", }, ), # Invalid sound ( "psafer://{}?sound=invalid".format("h" * 20), { # Invalid Sound "instance": TypeError, }, ), ( "psafer://{}?sound=94000".format("h" * 20), { # Invalid Sound "instance": TypeError, }, ), # Set vibration (integer only) ( "psafers://{}?vibration=1".format("h" * 20), { "instance": NotifyPushSafer, "requests_response_text": { "status": 1, }, "privacy_url": "psafers://h...h", }, ), # Invalid sound ( "psafer://{}?vibration=invalid".format("h" * 20), { # Invalid Vibration "instance": TypeError, }, ), # Invalid vibration ( "psafer://{}?vibration=25000".format("h" * 20), { # Invalid Vibration "instance": TypeError, }, ), ( "psafers://{}".format("d" * 20), { "instance": NotifyPushSafer, # A failure has status set to zero # Test without an 'error' flag "requests_response_text": { "status": 0, }, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "psafer://{}".format("d" * 20), { "instance": NotifyPushSafer, # A failure has status set to zero # Test without an 'error' flag "requests_response_text": { "status": 0, }, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "psafers://{}".format("d" * 20), { "instance": NotifyPushSafer, # A failure has status set to zero # Test without an 'error' flag "requests_response_text": { "status": 0, }, # Throws a series of connection and transfer exceptions when this # flag is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_pushsafer_urls(): """NotifyPushSafer() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_pushsafer_general(mock_post): """NotifyPushSafer() General Tests.""" # Private Key privatekey = "abc123" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = dumps( { "status": 1, "success": "okay", } ) # Exception should be thrown about the fact no private key was specified with pytest.raises(TypeError): NotifyPushSafer(privatekey=None) # Multiple Attachment Support path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment() for _ in range(0, 4): attach.add(path) obj = NotifyPushSafer(privatekey=privatekey) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test error reading attachment from disk with mock.patch("builtins.open", side_effect=OSError): obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) # Test unsupported mime type attach = AppriseAttachment(path) attach[0]._mimetype = "application/octet-stream" # We gracefully just don't send the attachment in these cases; # The notify itself will still be successful mock_post.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # the 'p', 'p2', and 'p3' are the data variables used when including an # image. assert "data" in mock_post.call_args[1] assert "p" not in mock_post.call_args[1]["data"] assert "p2" not in mock_post.call_args[1]["data"] assert "p3" not in mock_post.call_args[1]["data"] # Invalid file path path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) caronc-apprise-182f859/tests/test_plugin_pushward.py000066400000000000000000000334741524161175200227150ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAsset, NotifyType from apprise.plugins.pushward import NotifyPushWard, pushward_level logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "pushward://", { # No API Key specified "instance": TypeError, }, ), ( "pushward://invalid", { # API Key does not match the hlk_ pattern "instance": TypeError, }, ), ( "pushward://hlk_abc123", { # A valid API Key "instance": NotifyPushWard, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushward://h...3/", }, ), ( "pushward://?apikey=hlk_abc123", { # API Key provided as a query argument "instance": NotifyPushWard, "privacy_url": "pushward://h...3/", }, ), ( "pushward://hlk_abc123?level=critical&volume=0.8", { # critical level with a volume "instance": NotifyPushWard, "privacy_url": "pushward://h...3/", }, ), ( "pushward://hlk_abc123?info=passive&failure=critical", { # per-notification-type level overrides "instance": NotifyPushWard, "privacy_url": "pushward://h...3/", }, ), ( "pushward://hlk_abc123?info=bogus", { # An invalid per-type level "instance": TypeError, }, ), ( "pushward://hlk_abc123?level=invalid", { # Invalid level provided "instance": TypeError, }, ), ( "pushward://hlk_abc123?volume=2.0", { # Volume out of range "instance": TypeError, }, ), ( "pushward://hlk_abc123?volume=invalid", { # Volume that cannot be parsed as a float "instance": TypeError, }, ), ( "pushward://hlk_abc123", { "instance": NotifyPushWard, # Force a failure with a known response code "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "pushward://hlk_abc123", { "instance": NotifyPushWard, # Throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "pushward://hlk_abc123", { "instance": NotifyPushWard, # Drive it through a series of socket exceptions "test_requests_exceptions": True, }, ), ) def test_plugin_pushward_urls(): """NotifyPushWard() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_pushward_edge_cases(): """NotifyPushWard() Edge Cases.""" # No API Key with pytest.raises(TypeError): NotifyPushWard(apikey=None) # Whitespace only API Key with pytest.raises(TypeError): NotifyPushWard(apikey=" ") # API Key that does not match the hlk_ pattern with pytest.raises(TypeError): NotifyPushWard(apikey="invalid") # Invalid level with pytest.raises(TypeError): NotifyPushWard(apikey="hlk_abc123", level="invalid") # Volume above the allowable range with pytest.raises(TypeError): NotifyPushWard(apikey="hlk_abc123", volume=2.0) # Volume below the allowable range with pytest.raises(TypeError): NotifyPushWard(apikey="hlk_abc123", volume=-0.5) # Volume that cannot be coerced to a float with pytest.raises(TypeError): NotifyPushWard(apikey="hlk_abc123", volume="invalid") @mock.patch("requests.post") def test_plugin_pushward_send(mock_post): """NotifyPushWard() a successful (200) send.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = Apprise.instantiate("pushward://hlk_abc123") assert isinstance(obj, NotifyPushWard) assert obj.notify(title="title", body="body") is True assert mock_post.call_count == 1 details = mock_post.call_args assert details[0][0] == "https://api.pushward.app/notifications" headers = details[1]["headers"] assert headers["Authorization"] == "Bearer hlk_abc123" assert headers["User-Agent"] == obj.app_id payload = loads(details[1]["data"]) assert payload["title"] == "title" assert payload["body"] == "body" # NotifyType.INFO maps to the "active" level assert payload["level"] == "active" # An icon is attached (the default asset provides an image mask) assert "icon_url" in payload # No volume is sent for a non-critical notification assert "volume" not in payload @mock.patch("requests.post") def test_plugin_pushward_created(mock_post): """NotifyPushWard() treats a 201 Created response as success.""" response = mock.Mock() response.status_code = requests.codes.created response.content = b"" mock_post.return_value = response obj = Apprise.instantiate("pushward://hlk_abc123") assert obj.notify(title="title", body="body") is True @mock.patch("requests.post") def test_plugin_pushward_level_override(mock_post): """NotifyPushWard() honors an explicit ?level= override.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = Apprise.instantiate("pushward://hlk_abc123?level=passive") assert isinstance(obj, NotifyPushWard) assert obj.level == "passive" assert obj.notify(title="t", body="b") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["level"] == "passive" @mock.patch("requests.post") def test_plugin_pushward_notify_type_mapping(mock_post): """NotifyPushWard() derives the level from notify_type when unset.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = Apprise.instantiate("pushward://hlk_abc123") assert ( obj.notify(title="t", body="b", notify_type=NotifyType.WARNING) is True ) payload = loads(mock_post.call_args[1]["data"]) # WARNING maps to the "time-sensitive" level assert payload["level"] == "time-sensitive" @mock.patch("requests.post") def test_plugin_pushward_critical_volume(mock_post): """NotifyPushWard() sends a volume only for critical notifications.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = Apprise.instantiate( "pushward://hlk_abc123?level=critical&volume=0.8" ) assert isinstance(obj, NotifyPushWard) assert obj.level == "critical" assert obj.volume == 0.8 assert obj.notify(title="t", body="b") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["level"] == "critical" assert payload["volume"] == 0.8 # The volume is dropped when the level is not critical obj = Apprise.instantiate("pushward://hlk_abc123?level=active&volume=0.5") assert obj.notify(title="t", body="b") is True payload = loads(mock_post.call_args[1]["data"]) assert "volume" not in payload @mock.patch("requests.post") def test_plugin_pushward_no_image(mock_post): """NotifyPushWard() omits icon_url when no image is available.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response asset = AppriseAsset(image_url_mask=None) obj = Apprise.instantiate("pushward://hlk_abc123", asset=asset) assert obj.notify(title="t", body="b") is True payload = loads(mock_post.call_args[1]["data"]) assert "icon_url" not in payload @mock.patch("requests.post") def test_plugin_pushward_empty_title(mock_post): """NotifyPushWard() falls back to a default when the title is empty.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response # No title provided; falls back to the asset descriptor obj = Apprise.instantiate("pushward://hlk_abc123") assert obj.notify(body="body only") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["title"] assert payload["title"] == obj.app_desc assert payload["body"] == "body only" # When the descriptor is also empty, fall back to the application id asset = AppriseAsset(app_desc="") obj = Apprise.instantiate("pushward://hlk_abc123", asset=asset) assert obj.notify(body="body only") is True payload = loads(mock_post.call_args[1]["data"]) assert payload["title"] == obj.app_id @mock.patch("requests.post") def test_plugin_pushward_url_roundtrip(mock_post): """NotifyPushWard() url() round-trips losslessly.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = Apprise.instantiate( "pushward://hlk_abc123?level=critical&volume=0.8" ) assert isinstance(obj, NotifyPushWard) # Regenerate the URL and re-instantiate obj2 = Apprise.instantiate(obj.url()) assert isinstance(obj2, NotifyPushWard) assert obj.url_identifier == obj2.url_identifier assert obj2.apikey == "hlk_abc123" assert obj2.level == "critical" assert obj2.volume == 0.8 def test_plugin_pushward_parse_url(): """NotifyPushWard() parse_url() behavior.""" # An unparseable (non-string) URL returns None assert NotifyPushWard.parse_url(None) is None # API Key carried in the host results = NotifyPushWard.parse_url("pushward://hlk_abc123") assert results["apikey"] == "hlk_abc123" # API Key carried in a query argument (overrides the host) results = NotifyPushWard.parse_url("pushward://?apikey=hlk_xyz") assert results["apikey"] == "hlk_xyz" # level + volume carried in the query results = NotifyPushWard.parse_url( "pushward://hlk_abc123?level=critical&volume=0.5" ) assert results["apikey"] == "hlk_abc123" assert results["level"] == "critical" assert results["volume"] == "0.5" # Case is preserved on the key (hlk_ keys are case-sensitive) results = NotifyPushWard.parse_url("pushward://hlk_AbC123") assert results["apikey"] == "hlk_AbC123" def test_plugin_pushward_level_resolution(): """NotifyPushWard() level short-form resolution.""" # Full names, short-forms, and unique single-letter prefixes assert pushward_level("passive") == "passive" assert pushward_level("crit") == "critical" assert pushward_level("c") == "critical" assert pushward_level("t") == "time-sensitive" assert pushward_level("ACTIVE") == "active" # Empty or non-matching input resolves to None assert pushward_level("") is None assert pushward_level(" ") is None assert pushward_level("bogus") is None @mock.patch("requests.post") def test_plugin_pushward_per_type_levels(mock_post): """NotifyPushWard() per-notification-type level overrides.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = Apprise.instantiate( "pushward://hlk_abc123?info=passive&failure=crit" ) assert isinstance(obj, NotifyPushWard) assert obj.level_map[NotifyType.INFO] == "passive" # Short-form resolves to the canonical level assert obj.level_map[NotifyType.FAILURE] == "critical" # Untouched types keep their defaults assert obj.level_map[NotifyType.WARNING] == "time-sensitive" assert obj.notify(title="t", body="b", notify_type=NotifyType.INFO) is True assert loads(mock_post.call_args[1]["data"])["level"] == "passive" assert ( obj.notify(title="t", body="b", notify_type=NotifyType.FAILURE) is True ) assert loads(mock_post.call_args[1]["data"])["level"] == "critical" # A global ?level= still forces every type obj = Apprise.instantiate( "pushward://hlk_abc123?level=passive&info=critical" ) assert obj.notify(title="t", body="b", notify_type=NotifyType.INFO) is True assert loads(mock_post.call_args[1]["data"])["level"] == "passive" caronc-apprise-182f859/tests/test_plugin_pushy.py000066400000000000000000000140571524161175200222240ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester from apprise.plugins.pushy import NotifyPushy logging.disable(logging.CRITICAL) # However we'll be okay if we return a proper response GOOD_RESPONSE = { "success": True, } # Our Testing URLs apprise_url_tests = ( ( "pushy://", { # No no secret api key "instance": TypeError, }, ), ( "pushy://:@/", { # just invalid all around "instance": TypeError, }, ), ( "pushy://apikey", { # No Device/Topic specified "instance": NotifyPushy, # Expected notify() response False (because we won't be able # to actually notify anything if no device_key was specified "notify_response": False, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushy://apikey/topic", { # No Device/Topic specified "instance": NotifyPushy, # Expected notify() response False because the success flag # was set to false "notify_response": False, "requests_response_text": {"success": False}, }, ), ( "pushy://apikey/topic", { # No Device/Topic specified "instance": NotifyPushy, # Expected notify() response False because the success flag # was set to false "notify_response": False, # Invalid JSON data "requests_response_text": "}", }, ), ( "pushy://apikey/%20(", { # Invalid topic specified "instance": NotifyPushy, # Expected notify() response False because there is no one to # notify "notify_response": False, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushy://apikey/@device", { # Everything is okay "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushy://a...y/@device/", }, ), ( "pushy://apikey/topic", { # Everything is okay; no prefix means it's a topic "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushy://a...y/#topic/", }, ), ( "pushy://apikey/device/?sound=alarm.aiff", { # alarm.aiff sound loaded "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushy://apikey/device/?badge=100", { # set badge "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushy://apikey/device/?badge=invalid", { # set invalid badge "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushy://apikey/device/?badge=-12", { # set invalid badge "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushy://_/@device/#topic?key=apikey", { # set device and topic "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushy://apikey/?to=@device", { # test use of to= argument "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, }, ), ( "pushy://_/@device/#topic?key=apikey", { "instance": NotifyPushy, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, "requests_response_text": GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: "privacy_url": "pushy://a...y/#topic/@device/", }, ), ( "pushy://_/@device/#topic?key=apikey", { "instance": NotifyPushy, "requests_response_text": GOOD_RESPONSE, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_pushy_urls(): """NotifyPushy() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_qq.py000066400000000000000000000055041524161175200214720ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging from helpers import AppriseURLTester import requests from apprise.plugins.qq import NotifyQQ logging.disable(logging.CRITICAL) apprise_url_tests = ( ( "qq://", { "instance": TypeError, }, ), ( "qq://invalid!", { "instance": TypeError, }, ), ( "qq://abc123def456ghi789jkl012mno345pq", { "instance": NotifyQQ, "privacy_url": "qq://****/", }, ), ( "qq://?token=abc123def456ghi789jkl012mno345pq", { "instance": NotifyQQ, "privacy_url": "qq://****/", }, ), ( "https://qmsg.zendee.cn/send/abc123def456ghi789jkl012mno345pq", { "instance": NotifyQQ, }, ), ( "qq://abc123def456ghi789jkl012mno345pq", { "instance": NotifyQQ, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "qq://abc123def456ghi789jkl012mno345pq", { "instance": NotifyQQ, "response": False, "requests_response_code": 999, }, ), ( "qq://ffffffffffffffffffffffffffffffff", { "instance": NotifyQQ, "test_requests_exceptions": True, }, ), ) def test_plugin_qq_urls(): AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_reddit.py000066400000000000000000000367771524161175200223440ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timedelta, timezone from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import requests from apprise.plugins.reddit import NotifyReddit logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "reddit://", { # Missing all credentials "instance": TypeError, }, ), ( "reddit://:@/", { "instance": TypeError, }, ), ( "reddit://user@app_id/app_secret/", { # No password "instance": TypeError, }, ), ( "reddit://user:password@app_id/", { # No app secret "instance": TypeError, }, ), ( "reddit://user:password@app%id/appsecret/apprise", { # No invalid app_id (has percent) "instance": TypeError, }, ), ( "reddit://user:password@app%id/app_secret/apprise", { # No invalid app_secret (has percent) "instance": TypeError, }, ), ( "reddit://user:password@app-id/app-secret/apprise?kind=invalid", { # An Invalid Kind "instance": TypeError, }, ), ( "reddit://user:password@app-id/app-secret/apprise", { # Login failed "instance": NotifyReddit, # Expected notify() response is False because internally we would # have failed to login "notify_response": False, }, ), ( "reddit://user:password@app-id/app-secret", { # Login successful, but there was no subreddit to notify "instance": NotifyReddit, "requests_response_text": { "access_token": "abc123", "token_type": "bearer", "expires_in": 100000, "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # No errors during post "errors": [], }, }, # Expected notify() response is False "notify_response": False, }, ), ( "reddit://user:password@app-id/app-secret/apprise", { "instance": NotifyReddit, "requests_response_text": { "access_token": "abc123", "token_type": "bearer", "expires_in": 100000, "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # Identify an error "errors": [ ("KEY", "DESC", "INFO"), ], }, }, # Expected notify() response is False because the # reddit server provided us errors "notify_response": False, }, ), ( "reddit://user:password@app-id/app-secret/apprise", { "instance": NotifyReddit, "requests_response_text": { "access_token": "abc123", "token_type": "bearer", # Test case where 'expires_in' entry is missing "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # No errors during post "errors": [], }, }, # Our expected url(privacy=True) startswith() response: "privacy_url": "reddit://user:****@****/****/apprise", }, ), ( "reddit://user:password@app-id/app-secret/apprise/subreddit2", { # password:login acceptable "instance": NotifyReddit, "requests_response_text": { "access_token": "abc123", "token_type": "bearer", "expires_in": 100000, "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # No errors during post "errors": [], }, }, # Our expected url(privacy=True) startswith() response: "privacy_url": "reddit://user:****@****/****/apprise/subreddit2", }, ), # Pass in some arguments to over-ride defaults ( ( "reddit://user:pass@id/secret/sub/" "?ad=yes&nsfw=yes&replies=no&resubmit=yes&spoiler=yes&kind=self" ), { "instance": NotifyReddit, "requests_response_text": { "access_token": "abc123", "token_type": "bearer", "expires_in": 100000, "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # No errors during post "errors": [], }, }, # Our expected url(privacy=True) startswith() response: "privacy_url": "reddit://user:****@****/****/sub", }, ), # Pass in more arguments ( ( "reddit://" "?user=l2g&pass=pass&app_secret=abc123&app_id=54321&to=sub1,sub2" ), { "instance": NotifyReddit, "requests_response_text": { "access_token": "abc123", "token_type": "bearer", "expires_in": 100000, "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # No errors during post "errors": [], }, }, # Our expected url(privacy=True) startswith() response: "privacy_url": "reddit://l2g:****@****/****/sub1/sub2", }, ), # More arguments ... ( ( "reddit://user:pass@id/secret/sub7/sub6/sub5/" "?flair_id=wonder&flair_text=not%20for%20you" ), { "instance": NotifyReddit, "requests_response_text": { "access_token": "abc123", "token_type": "bearer", "expires_in": 100000, "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # No errors during post "errors": [], }, }, # Our expected url(privacy=True) startswith() response: "privacy_url": "reddit://user:****@****/****/sub", }, ), ( "reddit://user:password@app-id/app-secret/apprise", { "instance": NotifyReddit, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "reddit://user:password@app-id/app-secret/apprise", { "instance": NotifyReddit, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_reddit_urls(): """NotifyReddit() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_reddit_general(mock_post): """NotifyReddit() General Tests.""" NotifyReddit.clock_skew = timedelta(seconds=0) # Generate a valid credentials: kwargs = { "app_id": "a" * 10, "app_secret": "b" * 20, "user": "user", "password": "pasword", "targets": "apprise", } # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) good_response = mock.Mock() good_response.content = dumps( { "access_token": "abc123", "token_type": "bearer", "expires_in": 100000, "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # No errors during post "errors": [], }, } ) good_response.status_code = requests.codes.ok good_response.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # Prepare Mock mock_post.return_value = good_response # Variation Initializations obj = NotifyReddit(**kwargs) assert isinstance(obj, NotifyReddit) assert isinstance(obj.url(), str) # Dynamically pick up on a link assert obj.send(body="http://hostname") is True bad_response = mock.Mock() bad_response.content = "" bad_response.status_code = 401 # Change our status code and try again mock_post.return_value = bad_response assert obj.send(body="test") is False assert obj.ratelimit_remaining == 1 # Return the status mock_post.return_value = good_response # Force a case where there are no more remaining posts allowed good_response.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 0, } # behind the scenes, it should cause us to update our rate limit assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 # This should cause us to block good_response.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 10, } assert obj.send(body="test") is True assert obj.ratelimit_remaining == 10 # Handle cases where we simply couldn't get this field del good_response.headers["X-RateLimit-Remaining"] assert obj.send(body="test") is True # It remains set to the last value assert obj.ratelimit_remaining == 10 # Reset our variable back to 1 good_response.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # Handle cases where our epoch time is wrong del good_response.headers["X-RateLimit-Reset"] assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block good_response.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() + 1 ), "X-RateLimit-Remaining": 0, } obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block good_response.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() - 1 ), "X-RateLimit-Remaining": 0, } assert obj.send(body="test") is True # Return our limits to always work obj.ratelimit_remaining = 1 # Invalid JSON response = mock.Mock() response.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } response.content = "{" response.status_code = requests.codes.ok mock_post.return_value = response obj = NotifyReddit(**kwargs) assert obj.send(body="test") is False # Return it to a parseable string but missing the entries we expect response.content = "{}" obj = NotifyReddit(**kwargs) assert obj.send(body="test") is False # No access token provided response.content = dumps( { "access_token": "", "json": { # No errors during post "errors": [], }, } ) obj = NotifyReddit(**kwargs) assert obj.send(body="test") is False # cause a json parsing issue now response.content = None obj = NotifyReddit(**kwargs) assert obj.send(body="test") is False # Reset to what we consider a good response good_response.content = dumps( { "access_token": "abc123", "token_type": "bearer", "expires_in": 100000, "scope": "*", "refresh_token": "def456", # The below is used in the response: "json": { # No errors during post "errors": [], }, } ) good_response.status_code = requests.codes.ok good_response.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # Reset our mock object mock_post.reset_mock() # Test sucessful re-authentication after failed post mock_post.side_effect = [ good_response, bad_response, good_response, good_response, ] obj = NotifyReddit(**kwargs) assert obj.send(body="test") is True assert mock_post.call_count == 4 assert ( mock_post.call_args_list[0][0][0] == "https://www.reddit.com/api/v1/access_token" ) assert ( mock_post.call_args_list[1][0][0] == "https://oauth.reddit.com/api/submit" ) assert ( mock_post.call_args_list[2][0][0] == "https://www.reddit.com/api/v1/access_token" ) assert ( mock_post.call_args_list[3][0][0] == "https://oauth.reddit.com/api/submit" ) # Test failed re-authentication mock_post.side_effect = [good_response, bad_response, bad_response] obj = NotifyReddit(**kwargs) assert obj.send(body="test") is False # Test exception handing on re-auth attempt response.content = "{" response.status_code = requests.codes.ok mock_post.side_effect = [ good_response, bad_response, good_response, response, ] obj = NotifyReddit(**kwargs) assert obj.send(body="test") is False caronc-apprise-182f859/tests/test_plugin_resend.py000066400000000000000000000210351524161175200223260ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.resend import NotifyResend logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # a resend api key UUID4 = "re_FmABCDEF_987654321zqbabc123abc8fw" # Our Testing URLs apprise_url_tests = ( ( "resend://", { "instance": TypeError, }, ), ( "resend://:@/", { "instance": TypeError, }, ), ( "resend://abcd", { # Just an broken email (no api key or email) "instance": TypeError, }, ), ( "resend://abcd@host", { # Just an Email specified, no API Key "instance": TypeError, }, ), ( "resend://invalid-api-key+*-d:user@example.com", { # An invalid API Key "instance": TypeError, }, ), ( "resend://abcd:user@example.com", { # No To/Target Address(es) specified; so we sub in the same From # address "instance": NotifyResend, }, ), ( "resend://abcd:user@example.com/newuser1@example.com", { # A good email "instance": NotifyResend, }, ), ( "resend://abcd:user@example.com/newuser2@example.com?name=Jessica", { # A good email "instance": NotifyResend, "privacy_url": "resend://a...d:user@example.com/newuser2@example.com", "url_matches": r"name=Jessica", }, ), ( ( "resend://abcd@newuser4%40example.com?name=Ralph" "&from=user2@example.ca" ), { # A good email "instance": NotifyResend, "privacy_url": "resend://a...d:user2@example.ca/", "url_matches": r"name=Ralph", "force_debug": True, }, ), ( ( "resend://?apikey=abcd&from=Joe" "&to=newuser5@example.com" ), { # A good email "instance": NotifyResend, "privacy_url": "resend://a...d:user@example.com/newuser5@example.com", "url_matches": r"name=Joe", }, ), ( ( "resend://?apikey=abcd&from=Joe" "&reply=John" ), { # A good email "instance": NotifyResend, "privacy_url": "resend://a...d:user@example.com", "url_matches": r"reply=John", }, ), ( ("resend://?apikey=abcd&from=Joe&reply=garbage%"), { # A good email but has a garbage reply-to value "instance": NotifyResend, }, ), ( ( "resend://abcd:user@example.com/newuser7@example.com" "?bcc=l2g@nuxref.com" ), { # A good email with Blind Carbon Copy "instance": NotifyResend, }, ), ( "resend://abcd:user@example.com/newuser8@example.com?cc=l2g@nuxref.com", { # A good email with Carbon Copy "instance": NotifyResend, }, ), ( ( "resend://abcd:user@example.com/newuser8@example.com?" "cc=Chris" ), { # A good email with Carbon Copy + Name "instance": NotifyResend, }, ), ( "resend://abcd:user@example.com/newuser9@example.com?to=l2g@nuxref.com", { # A good email with Carbon Copy "instance": NotifyResend, }, ), ( "resend://abcd:user@example.ca/newuser0@example.ca", { "instance": NotifyResend, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "resend://abcd:user@example.uk/newuser01@example.uk", { "instance": NotifyResend, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "resend://abcd:user@example.au/newuser02@example.au", { "instance": NotifyResend, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_resend_urls(): """NotifyResend() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_resend_edge_cases(mock_post, mock_get): """NotifyResend() Edge Cases.""" # no apikey with pytest.raises(TypeError): NotifyResend(apikey=None, from_addr="user@example.com") # invalid from email with pytest.raises(TypeError): NotifyResend(apikey="abcd", from_addr="!invalid") # no email with pytest.raises(TypeError): NotifyResend(apikey="abcd", from_addr=None) # Invalid To email address NotifyResend( apikey="abcd", from_addr="user@example.com", targets="!invalid" ) # Test invalid bcc/cc entries mixed with good ones assert isinstance( NotifyResend( apikey="abcd", from_addr="l2g@example.com", bcc=("abc@def.com", "!invalid"), cc=("abc@test.org", "!invalid"), ), NotifyResend, ) @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_resend_attachments(mock_post, mock_get): """NotifyResend() Attachments.""" request = mock.Mock() request.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = request mock_get.return_value = request path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) obj = Apprise.instantiate("resend://abcd:user@example.com") assert isinstance(obj, NotifyResend) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) mock_post.reset_mock() mock_get.reset_mock() # Try again in a use case where we can't access the file with mock.patch("os.path.isfile", return_value=False): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Try again in a use case where we can't access the file with mock.patch("builtins.open", side_effect=OSError): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) caronc-apprise-182f859/tests/test_plugin_revolt.py000066400000000000000000000454351524161175200223730ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timedelta from json import dumps, loads # Disable logging for a cleaner testing output import logging import os from random import choice from string import ascii_uppercase as str_alpha, digits as str_num from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyFormat, NotifyType from apprise.common import OverflowMode from apprise.plugins.revolt import NotifyRevolt logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Prepare a Valid Response REVOLT_GOOD_RESPONSE = dumps( { "_id": "AAAPWPMMQA2JJB59BR2EASWWWW", "nonce": "01HPWPPMDJABC2FTDG54CBKKKS", "channel": "00000000000000000000000000", "author": "011244Q9S8NCS67KMM9543W7JJ", "content": "test", } ) # Our Testing URLs apprise_url_tests = ( ( "revolt://", { "instance": TypeError, }, ), # An invalid url ( "revolt://:@/", { "instance": TypeError, }, ), # No channel_id specified ( "revolt://%s" % ("i" * 24), { "instance": NotifyRevolt, # Notify will fail "response": False, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), # channel_id specified on url, but no Bot Token ( "revolt://?channel=%s" % ("i" * 24), { "instance": TypeError, }, ), # channel_id specified on url ( "revolt://{}/?channel={}".format("i" * 24, "i" * 24), { "instance": NotifyRevolt, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), ( "revolt://{}/?to={}".format("i" * 24, "i" * 24), { "instance": NotifyRevolt, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), ( "revolt://{}/?to={}".format("i" * 24, "i" * 24), { "instance": NotifyRevolt, # an invalid JSON Response "requests_response_text": "{", }, ), # channel_id specified on url ( "revolt://{}/?channel={},%20".format("i" * 24, "i" * 24), { "instance": NotifyRevolt, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), # Provide both a bot token and a channel id ( "revolt://{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), ( "revolt://_?bot_token={}&channel={}".format("i" * 24, "t" * 64), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), # different format support ( "revolt://{}/{}?format=markdown".format("i" * 24, "t" * 64), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), ( "revolt://{}/{}?format=text".format("i" * 24, "t" * 64), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), # Test with url ( "revolt://{}/{}?url=http://localhost".format("i" * 24, "t" * 64), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), # URL implies markdown unless explicitly set otherwise ( "revolt://{}/{}?format=text&url=http://localhost".format( "i" * 24, "t" * 64 ), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), # Test with Icon URL ( "revolt://{}/{}?icon_url=http://localhost/test.jpg".format( "i" * 24, "t" * 64 ), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), # Icon URL implies markdown unless explicitly set otherwise ( "revolt://{}/{}?format=text&icon_url=http://localhost/test.jpg".format( "i" * 24, "t" * 64 ), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), # Test without any image set ( "revolt://{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyRevolt, "requests_response_code": requests.codes.ok, # don't include an image by default "include_image": False, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), ( "revolt://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyRevolt, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), ( "revolt://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyRevolt, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), ( "revolt://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyRevolt, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, # Our response expected server response "requests_response_text": REVOLT_GOOD_RESPONSE, }, ), ) def test_plugin_revolt_urls(): """NotifyRevolt() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_revolt_notifications(mock_post): """NotifyRevolt() Notifications.""" # Initialize some generic (but valid) tokens bot_token = "A" * 24 channel_id = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = REVOLT_GOOD_RESPONSE # Test our header parsing when not lead with a header body = """ # Heading @everyone and @admin, wake and meet our new user <@123>; <@&456>" """ results = NotifyRevolt.parse_url( f"revolt://{bot_token}/{channel_id}/?format=markdown" ) assert isinstance(results, dict) assert results["user"] is None assert results["bot_token"] == bot_token assert results["targets"] == [ channel_id, ] assert results["password"] is None assert results["port"] is None assert results["host"] == bot_token assert results["fullpath"] == f"/{channel_id}/" assert results["path"] == f"/{channel_id}/" assert results["query"] is None assert results["schema"] == "revolt" assert results["url"] == f"revolt://{bot_token}/{channel_id}/" instance = NotifyRevolt(**results) assert isinstance(instance, NotifyRevolt) response = instance.send(body=body) assert response is True assert mock_post.call_count == 1 # Reset our object mock_post.reset_mock() results = NotifyRevolt.parse_url( f"revolt://{bot_token}/{channel_id}/?format=text" ) assert isinstance(results, dict) assert results["user"] is None assert results["bot_token"] == bot_token assert results["targets"] == [ channel_id, ] assert results["password"] is None assert results["port"] is None assert results["host"] == bot_token assert results["fullpath"] == f"/{channel_id}/" assert results["path"] == f"/{channel_id}/" assert results["query"] is None assert results["schema"] == "revolt" assert results["url"] == f"revolt://{bot_token}/{channel_id}/" instance = NotifyRevolt(**results) assert isinstance(instance, NotifyRevolt) response = instance.send(body=body) assert response is True assert mock_post.call_count == 1 @mock.patch("requests.post") @mock.patch("time.sleep") def test_plugin_revolt_general(mock_sleep, mock_post): """NotifyRevolt() General Checks.""" # Prevent throttling mock_sleep.return_value = True # Turn off clock skew for local testing NotifyRevolt.clock_skew = timedelta(seconds=0) # Initialize some generic (but valid) tokens bot_token = "A" * 24 channel_id = ",".join(["B" * 32, "C" * 32]) + ", ,%%" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = REVOLT_GOOD_RESPONSE mock_post.return_value.headers = { "X-RateLimit-Remaining": 0, "X-RateLimit-Reset-After": 1, } # Invalid bot_token with pytest.raises(TypeError): NotifyRevolt(bot_token=None, targets=channel_id) # Invalid bot_token (whitespace) with pytest.raises(TypeError): NotifyRevolt(bot_token=" ", targets=channel_id) obj = NotifyRevolt(bot_token=bot_token, targets=channel_id) assert obj.ratelimit_remaining == 1 # Test that we get a string response assert isinstance(obj.url(), str) # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Force a case where there are no more remaining posts allowed mock_post.return_value.headers = { "X-RateLimit-Remaining": 0, "X-RateLimit-Reset-After": 0, } # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # behind the scenes, it should cause us to update our rate limit assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 assert isinstance(obj.ratelimit_reset, datetime) # This should cause us to block mock_post.return_value.headers = { "X-RateLimit-Remaining": 0, "X-RateLimit-Reset-After": 3000, } assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 assert isinstance(obj.ratelimit_reset, datetime) # Reset our variable back to 1 mock_post.return_value.headers = { "X-RateLimit-Remaining": 0, "X-RateLimit-Reset-After": 10000, } del mock_post.return_value.headers["X-RateLimit-Remaining"] assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 assert isinstance(obj.ratelimit_reset, datetime) # Return our object, but place it in the future forcing us to block mock_post.return_value.headers = { "X-RateLimit-Remaining": 0, "X-RateLimit-Reset-After": 0, } obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Test 429 error response mock_post.return_value.status_code = requests.codes.too_many_requests # The below will attempt a second transmission and fail (because we didn't # set up a second post request to pass) :) assert obj.send(body="test") is False # Return our object, but place it in the future forcing us to block mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.headers = { "X-RateLimit-Remaining": 0, "X-RateLimit-Reset-After": 0, } assert obj.send(body="test") is True # Return our limits to always work obj.ratelimit_remaining = 1 # Return our headers to normal mock_post.return_value.headers = { "X-RateLimit-Remaining": 0, "X-RateLimit-Reset-After": 1, } # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Create an apprise instance a = Apprise() # Our processing is slightly different when we aren't using markdown # as we do not pre-parse content during our notifications assert a.add(f"revolt://{bot_token}/{channel_id}/?format=markdown") is True # Toggle our logo availability a.asset.image_url_logo = None assert ( a.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) @mock.patch("requests.post") def test_plugin_revolt_overflow(mock_post): """NotifyRevolt() Overflow Checks.""" # Initialize some generic (but valid) tokens bot_token = "A" * 24 channel_id = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = REVOLT_GOOD_RESPONSE # Some variables we use to control the data we work with body_len = 2005 title_len = 110 # Number of characters per line row = 24 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num + " ") for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) results = NotifyRevolt.parse_url( f"revolt://{bot_token}/{channel_id}/?overflow=split" ) assert isinstance(results, dict) assert results["user"] is None assert results["bot_token"] == bot_token assert results["targets"] == [ channel_id, ] assert results["password"] is None assert results["port"] is None assert results["host"] == bot_token assert results["fullpath"] == f"/{channel_id}/" assert results["path"] == f"/{channel_id}/" assert results["query"] is None assert results["schema"] == "revolt" assert results["url"] == f"revolt://{bot_token}/{channel_id}/" instance = NotifyRevolt(**results) assert isinstance(instance, NotifyRevolt) results = instance._apply_overflow( body, title=title, overflow=OverflowMode.SPLIT ) # Split into 2 assert len(results) == 2 assert len(results[0]["title"]) <= instance.title_maxlen assert len(results[0]["body"]) <= instance.body_maxlen @mock.patch("requests.post") def test_plugin_revolt_markdown_extra(mock_post): """NotifyRevolt() Markdown Extra Checks.""" # Initialize some generic (but valid) tokens bot_token = "A" * 24 channel_id = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = REVOLT_GOOD_RESPONSE # Reset our apprise object a = Apprise() # We want to further test our markdown support to accommodate bug rased on # 2022.10.25; see https://github.com/caronc/apprise/issues/717 assert a.add(f"revolt://{bot_token}/{channel_id}/?format=markdown") is True test_markdown = "[green-blue](https://google.com)" # This call includes an image with it's payload: assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) assert ( a.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) @mock.patch("requests.post") def test_plugin_revolt_html_to_markdown_format(mock_post): """NotifyRevolt(): HTML body is converted to Markdown.""" # Initialize some generic (but valid) tokens bot_token = "A" * 24 channel_id = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = REVOLT_GOOD_RESPONSE # Instantiate a Revolt plugin in Markdown mode aobj = Apprise() assert aobj.add(f"revolt://{bot_token}/{channel_id}/?format=markdown") # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Revolt result = aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) assert result is True assert mock_post.call_count == 1 # The body must arrive inside the Markdown embed, not as # stripped plain text in "content" payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["embeds"][0]["description"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_ringcentral.py000066400000000000000000000665231524161175200233710ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment from apprise.plugins.ringcentral import ( NotifyRingCentral, RingCentralAuthMode, RingCentralEnvironment, ) logging.disable(logging.CRITICAL) # Path to static test attachment files TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Shared valid credential constants used across tests CLIENT_ID = "client_id" CLIENT_SECRET = "client-secret" SOURCE = "+15551233456" JWT_TOKEN = "a" * 80 # A realistic RingCentral token response (covers both auth and SMS fields) GOOD_RESPONSE = { # OAuth token fields "access_token": "abc123", "token_type": "bearer", "expires_in": 3600, "scope": "Faxes SMS TeamMessaging A2PSMS", "owner_id": "123", "endpoint_id": "akfJbWJYQ7GEUev2CaR37k", # SMS response fields "uri": ( "https://platform.ringcentral.com/restapi/v1.0/" "account/123/extension/123/message-store/123" ), "id": 123, "to": [{"phoneNumber": "+14223453486", "name": "Chris"}], "from": {"phoneNumber": "+14223452386", "name": "Chris"}, "type": "SMS", "messageStatus": "Queued", } # Our Testing URLs apprise_url_tests = ( ( "ringc://", { # No credentials at all "instance": TypeError, }, ), ( "ringc://:@/", { # No credentials at all "instance": TypeError, }, ), ( "ringc://password@client_id/18005554321", { # No client secret "instance": TypeError, }, ), ( "ringc://18005554321:jwt{}@client_id".format("a" * 60), { # JWT provided but no client secret "instance": TypeError, }, ), ( "ringc://18005554321:jwt{}@%21%21%21/secret".format("b" * 60), { # Invalid client_id "instance": TypeError, }, ), ( "ringc://18005554321:jwt{}@client_id/%21%21%21/".format("c" * 60), { # Invalid client secret "instance": TypeError, }, ), ( "ringc://18005554321:password@client_id/secret?mode=invalid", { # Invalid auth mode "instance": TypeError, }, ), ( "ringc://18005554321:password@client_id/secret?env=invalid", { # Invalid environment "instance": TypeError, }, ), ( "ringc://18005554321:jwt=@client_id/secret?mode=jwt", { # Invalid JWT token (contains disallowed chars) "instance": TypeError, }, ), # Valid JWT mode with explicit ?mode=jwt ( "ringc://18005554321:jwt{}@client_id/secret/1555123456?mode=jwt".format( "c" * 60 ), { "instance": NotifyRingCentral, "requests_response_text": GOOD_RESPONSE, "privacy_url": "ringc://18005554321:j...c@c...d/****/1555123456/" "?env=prod&mode=jwt", }, ), # Valid JWT mode auto-detected from token length (>60 chars) ( "ringc://18005554321:jwt{}@client_id/secret/1555123456".format( "c" * 60 ), { "instance": NotifyRingCentral, "requests_response_text": GOOD_RESPONSE, }, ), # Invalid phone number in targets (gets dropped, loopback used) ( "ringc://18005554321:jwt{}@client_id/secret/245/?env=sandbox".format( "c" * 60 ), { "instance": NotifyRingCentral, "requests_response_text": GOOD_RESPONSE, }, ), # BASIC auth mode ( "ringc://18005554321:password@client_id/secret", { "instance": NotifyRingCentral, "requests_response_text": GOOD_RESPONSE, }, ), # Query param form with ?token= and ?secret= ( "ringc://_?token={}&secret={}&from={}".format( "a" * 8, "b" * 16, "5" * 11 ), { "requests_response_text": GOOD_RESPONSE, "instance": NotifyRingCentral, }, ), # Query param form with ?source= instead of ?from= ( "ringc://_?token={}&secret={}&source={}".format( "a" * 8, "b" * 16, "5" * 11 ), { "requests_response_text": GOOD_RESPONSE, "instance": NotifyRingCentral, }, ), # Query param form with ?to= target ( "ringc://_?token={}&secret={}&from={}&to={}".format( "a" * 8, "b" * 16, "5" * 11, "7" * 13 ), { "requests_response_text": GOOD_RESPONSE, "instance": NotifyRingCentral, }, ), # HTTP 999 (unknown response code) ( "ringc://18005554321:jwt{}@client_id/secret".format("c" * 60), { "instance": NotifyRingCentral, "requests_response_text": GOOD_RESPONSE, "response": False, "requests_response_code": 999, }, ), # RequestException handling ( "ringc://18005554321:jwt{}@client_id/secret".format("c" * 60), { "instance": NotifyRingCentral, "requests_response_text": GOOD_RESPONSE, "test_requests_exceptions": True, }, ), ) def test_plugin_ringc_urls(): """NotifyRingCentral() Apprise URLs.""" AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_ringc_init(mock_post): """NotifyRingCentral() init edge cases.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = d return r mock_post.return_value = _mk_resp(b"{}") # No client_id with pytest.raises(TypeError): NotifyRingCentral( client_id=None, client_secret=CLIENT_SECRET, source=SOURCE, ) # Blank client_id with pytest.raises(TypeError): NotifyRingCentral( client_id=" ", client_secret=CLIENT_SECRET, source=SOURCE, ) # No client_secret with pytest.raises(TypeError): NotifyRingCentral( client_id=CLIENT_ID, client_secret=None, source=SOURCE, ) # Blank client_secret with pytest.raises(TypeError): NotifyRingCentral( client_id=CLIENT_ID, client_secret=" ", source=SOURCE, ) # Invalid source phone with pytest.raises(TypeError): NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source="notaphone", ) # Invalid auth mode with pytest.raises(TypeError): NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, mode="bad_mode", ) # Invalid environment with pytest.raises(TypeError): NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, environment="invalid", ) # Invalid JWT token in JWT mode with pytest.raises(TypeError): NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="bad token!", mode=RingCentralAuthMode.JWT, ) # Valid BASIC mode (token is a plain password -- no regex check) obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="some-password", ) assert obj.mode == RingCentralAuthMode.BASIC # Valid JWT mode obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token=JWT_TOKEN, mode=RingCentralAuthMode.JWT, ) assert obj.mode == RingCentralAuthMode.JWT @mock.patch("requests.post") def test_plugin_ringc_url_and_identifier(mock_post): """NotifyRingCentral() url() and url_identifier.""" def _mk_resp(d, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = d return r mock_post.return_value = _mk_resp(b"{}") # BASIC mode with targets obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="secret", targets=["15559998888"], ) assert obj.url_identifier == ( "ringc", CLIENT_ID, CLIENT_SECRET, "secret", SOURCE.lstrip("+"), ) full_url = obj.url() assert "ringc://" in full_url assert "mode=basic" in full_url # ext= no longer emitted in URL assert "ext=" not in full_url privacy_url = obj.url(privacy=True) assert "****" in privacy_url # Round-trip result = NotifyRingCentral.parse_url(full_url) assert result is not None obj2 = NotifyRingCentral(**result) assert obj.url_identifier == obj2.url_identifier assert len(obj.targets) == len(obj2.targets) # JWT mode obj_jwt = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token=JWT_TOKEN, mode=RingCentralAuthMode.JWT, ) full_url_jwt = obj_jwt.url() assert "mode=jwt" in full_url_jwt privacy_url_jwt = obj_jwt.url(privacy=True) assert "mode=jwt" in privacy_url_jwt # Round-trip JWT result_jwt = NotifyRingCentral.parse_url(full_url_jwt) assert result_jwt is not None obj_jwt2 = NotifyRingCentral(**result_jwt) assert obj_jwt.url_identifier == obj_jwt2.url_identifier # __len__ assert len(obj) == 1 # one target obj_no_targets = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="pw", ) assert len(obj_no_targets) == 1 # no targets -> loopback -> 1 @mock.patch("requests.post") def test_plugin_ringc_send_success(mock_post): """NotifyRingCentral() successful send.""" from json import dumps as jdumps good_auth = mock.Mock() good_auth.status_code = requests.codes.ok good_auth.content = jdumps(GOOD_RESPONSE).encode() good_sms = mock.Mock() good_sms.status_code = requests.codes.ok good_sms.content = jdumps(GOOD_RESPONSE).encode() mock_post.side_effect = [good_auth, good_sms] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998888"], ) assert obj.notify(body="Test message") is True assert mock_post.call_count == 2 # Second call reuses cached token (no new auth call) good_sms2 = mock.Mock() good_sms2.status_code = requests.codes.ok good_sms2.content = jdumps(GOOD_RESPONSE).encode() mock_post.side_effect = [good_sms2] assert obj.notify(body="Second message") is True assert mock_post.call_count == 3 @mock.patch("requests.post") def test_plugin_ringc_send_multi_target(mock_post): """NotifyRingCentral() send to multiple targets.""" from json import dumps as jdumps def _ok(): r = mock.Mock() r.status_code = requests.codes.ok r.content = jdumps(GOOD_RESPONSE).encode() return r # auth + 2 targets = 3 calls mock_post.side_effect = [_ok(), _ok(), _ok()] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998881", "15559998882"], ) assert obj.notify(body="Multi-target") is True assert mock_post.call_count == 3 @mock.patch("requests.post") def test_plugin_ringc_send_loopback(mock_post): """NotifyRingCentral() with no targets sends to source (loopback).""" from json import dumps as jdumps good = mock.Mock() good.status_code = requests.codes.ok good.content = jdumps(GOOD_RESPONSE).encode() # auth + 1 loopback send mock_post.side_effect = [good, good] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", ) assert obj.notify(body="Loopback") is True assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_ringc_auth_failure(mock_post): """NotifyRingCentral() returns False when auth fails.""" bad = mock.Mock() bad.status_code = requests.codes.internal_server_error bad.content = b"{}" mock_post.return_value = bad obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", ) assert obj.notify(body="Test") is False @mock.patch("requests.post") def test_plugin_ringc_auth_no_access_token(mock_post): """NotifyRingCentral() returns False when 200 OK has no access_token.""" # A 200 OK response that lacks access_token is not a usable login; # previously this would proceed with "Bearer None" as the auth header empty_auth = mock.Mock() empty_auth.status_code = requests.codes.ok empty_auth.content = b"{}" mock_post.return_value = empty_auth obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", ) assert obj.notify(body="Test") is False @mock.patch("requests.post") def test_plugin_ringc_send_http_error(mock_post): """NotifyRingCentral() handles HTTP error on send.""" from json import dumps as jdumps good_auth = mock.Mock() good_auth.status_code = requests.codes.ok good_auth.content = jdumps(GOOD_RESPONSE).encode() bad_sms = mock.Mock() bad_sms.status_code = requests.codes.internal_server_error bad_sms.content = b"{}" mock_post.side_effect = [good_auth, bad_sms] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998888"], ) assert obj.notify(body="Test") is False @mock.patch("requests.post") def test_plugin_ringc_send_unknown_code(mock_post): """NotifyRingCentral() handles unknown HTTP response code.""" from json import dumps as jdumps good_auth = mock.Mock() good_auth.status_code = requests.codes.ok good_auth.content = jdumps(GOOD_RESPONSE).encode() bad_sms = mock.Mock() bad_sms.status_code = 999 bad_sms.content = b"{}" mock_post.side_effect = [good_auth, bad_sms] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998888"], ) assert obj.notify(body="Test") is False @mock.patch("requests.post") def test_plugin_ringc_request_exception(mock_post): """NotifyRingCentral() handles RequestException.""" mock_post.side_effect = requests.RequestException("Connection refused") obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998888"], ) assert obj.notify(body="Test") is False @mock.patch("requests.post") def test_plugin_ringc_send_partial_failure(mock_post): """NotifyRingCentral() returns False when any target fails.""" from json import dumps as jdumps good_auth = mock.Mock() good_auth.status_code = requests.codes.ok good_auth.content = jdumps(GOOD_RESPONSE).encode() good_sms = mock.Mock() good_sms.status_code = requests.codes.ok good_sms.content = jdumps(GOOD_RESPONSE).encode() bad_sms = mock.Mock() bad_sms.status_code = requests.codes.internal_server_error bad_sms.content = b"{}" # auth + good send + bad send mock_post.side_effect = [good_auth, good_sms, bad_sms] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998881", "15559998882"], ) assert obj.notify(body="Partial") is False @mock.patch("requests.post") def test_plugin_ringc_jwt_mode(mock_post): """NotifyRingCentral() JWT auth mode.""" from json import dumps as jdumps good = mock.Mock() good.status_code = requests.codes.ok good.content = jdumps(GOOD_RESPONSE).encode() mock_post.side_effect = [good, good] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token=JWT_TOKEN, mode=RingCentralAuthMode.JWT, targets=["15559998888"], ) assert obj.mode == RingCentralAuthMode.JWT assert obj.notify(body="JWT test") is True # Verify the auth call used the JWT grant type auth_call = mock_post.call_args_list[0] assert "jwt-bearer" in str(auth_call) @mock.patch("requests.post") def test_plugin_ringc_sandbox_environment(mock_post): """NotifyRingCentral() sandbox environment routing.""" from json import dumps as jdumps good = mock.Mock() good.status_code = requests.codes.ok good.content = jdumps(GOOD_RESPONSE).encode() mock_post.side_effect = [good, good] # Sandbox environment with SMS (no attachments) obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", environment=RingCentralEnvironment.SANDBOX, targets=["15559998888"], ) assert obj.environment == RingCentralEnvironment.SANDBOX assert obj.notify(body="Sandbox test") is True # Verify .devtest appears in the URL and /sms is used (no attachments) notify_call = mock_post.call_args_list[1] assert ".devtest" in notify_call[0][0] assert "/sms" in notify_call[0][0] @mock.patch("requests.post") def test_plugin_ringc_mms_attachment(mock_post): """NotifyRingCentral() auto-selects MMS endpoint when attach present.""" from json import dumps as jdumps good = mock.Mock() good.status_code = requests.codes.ok good.content = jdumps(GOOD_RESPONSE).encode() attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # auth + MMS send = 2 calls mock_post.side_effect = [good, good] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998888"], ) assert obj.notify(body="MMS test", attach=attach) is True assert mock_post.call_count == 2 # Verify the MMS endpoint was used notify_call = mock_post.call_args_list[1] assert "/mms" in notify_call[0][0] # Inaccessible attachment causes failure (token is still cached) mock_post.reset_mock() with mock.patch("os.path.isfile", return_value=False): bad_attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Bad attach", attach=bad_attach) is False # No HTTP calls should have been made (failed at attachment check) assert mock_post.call_count == 0 # OSError from open() is caught and treated as a failed attachment mock_post.reset_mock() with mock.patch("builtins.open", side_effect=OSError("disk error")): assert obj.notify(body="OSError test", attach=attach) is False assert mock_post.call_count == 0 # MMS send HTTP error (attachment opens fine but server rejects) mock_post.reset_mock() bad_mms = mock.Mock() bad_mms.status_code = requests.codes.internal_server_error bad_mms.content = b"{}" mock_post.side_effect = [bad_mms] assert obj.notify(body="MMS HTTP fail", attach=attach) is False assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_ringc_parse_url(mock_post): """NotifyRingCentral() URL parsing edge cases.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"{}" # URL with all params in query string; client_id goes in the host url = "ringc://client_id?token={}&secret={}&from={}".format( "a" * 8, "b" * 16, "5" * 11 ) result = NotifyRingCentral.parse_url(url) assert result is not None assert result["client_id"] == "client_id" assert result["token"] == "a" * 8 assert result["client_secret"] == "b" * 16 obj = NotifyRingCentral(**result) assert isinstance(obj, NotifyRingCentral) # Reverse URL form: token@client_id/secret/source url2 = "ringc://{}@client_id/secret/15551234567".format("c" * 8) result2 = NotifyRingCentral.parse_url(url2) assert result2 is not None assert result2["source"] == "15551234567" # ?to= appends targets url3 = "ringc://15559990000:password@client_id/secret?to=15551234567" result3 = NotifyRingCentral.parse_url(url3) assert result3 is not None assert "15551234567" in result3["targets"] # ?env= override url4 = "ringc://15559990000:password@client_id/secret?env=sandbox" result4 = NotifyRingCentral.parse_url(url4) assert result4 is not None assert result4["environment"] == "sandbox" # ?source= override (alias for ?from=) url5 = "ringc://client_id?token=abc&secret=xyz&source=15559990000" result5 = NotifyRingCentral.parse_url(url5) assert result5 is not None assert result5["source"] == "15559990000" # ?mode= override url6 = "ringc://15559990000:password@client_id/secret?mode=basic" result6 = NotifyRingCentral.parse_url(url6) assert result6 is not None assert result6["mode"] == "basic" # JWT auto-detection from token length (>60 chars) in URL userinfo url7 = "ringc://15559990000:{}@client_id/secret".format("x" * 70) result7 = NotifyRingCentral.parse_url(url7) assert result7 is not None assert result7["mode"] == RingCentralAuthMode.JWT # Short token -> BASIC mode auto-detection url8 = "ringc://15559990000:shortpw@client_id/secret" result8 = NotifyRingCentral.parse_url(url8) assert result8 is not None assert result8["mode"] == RingCentralAuthMode.BASIC # JWT auto-detection via ?token= query param; ?token= must be applied # before mode detection so a long JWT supplied via query param is # correctly classified as JWT, not BASIC url9 = "ringc://client_id?token={}&secret=secret&from=15559990000".format( "y" * 70 ) result9 = NotifyRingCentral.parse_url(url9) assert result9 is not None assert result9["mode"] == RingCentralAuthMode.JWT assert result9["token"] == "y" * 70 # Invalid URL returns None assert NotifyRingCentral.parse_url(None) is None @mock.patch("requests.post") def test_plugin_ringc_logout(mock_post): """NotifyRingCentral() logout sends revocation request.""" from json import dumps as jdumps good = mock.Mock() good.status_code = requests.codes.ok good.content = jdumps(GOOD_RESPONSE).encode() bad_revoke = mock.Mock() bad_revoke.status_code = requests.codes.internal_server_error bad_revoke.content = b"{}" # login succeeds, then logout fails gracefully mock_post.side_effect = [good, good, bad_revoke] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998888"], ) assert obj.notify(body="Test") is True assert obj._access_token is not None # Explicit logout (token is still valid, sends revoke) obj.logout() assert obj._access_token is None # Logout a second time (nothing to revoke -- no-op) obj.logout() assert mock_post.call_count == 3 @mock.patch("requests.post") def test_plugin_ringc_del_exception(mock_post): """NotifyRingCentral() __del__ handles OSError from logout.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"{}" with mock.patch( "apprise.plugins.ringcentral.NotifyRingCentral.logout", side_effect=OSError(), ): obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret="valid-secret", source=SOURCE, ) # __del__ must not propagate the exception del obj @mock.patch("requests.post") def test_plugin_ringc_invalid_json_response(mock_post): """NotifyRingCentral() handles non-JSON response bodies.""" from json import dumps as jdumps good_auth = mock.Mock() good_auth.status_code = requests.codes.ok good_auth.content = jdumps(GOOD_RESPONSE).encode() bad_json = mock.Mock() bad_json.status_code = requests.codes.ok bad_json.content = b"not-json" mock_post.side_effect = [good_auth, bad_json] obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["15559998888"], ) # Should succeed (status 200 regardless of body parseability) assert obj.notify(body="Test") is True @mock.patch("requests.post") def test_plugin_ringc_dropped_targets(mock_post): """NotifyRingCentral() drops invalid phone numbers silently.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"{}" obj = NotifyRingCentral( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, source=SOURCE, token="password", targets=["abc", "15559998888"], ) # "abc" is invalid and should be dropped assert len(obj.targets) == 1 assert "15559998888" in obj.targets[0] @mock.patch("requests.post") def test_plugin_ringc_apprise_integration(mock_post): """NotifyRingCentral() works through the Apprise interface.""" from json import dumps as jdumps good = mock.Mock() good.status_code = requests.codes.ok good.content = jdumps(GOOD_RESPONSE).encode() mock_post.return_value = good a = Apprise() url = "ringc://18005554321:{}@client_id/secret/15559998888".format( "c" * 60 ) assert a.add(url) is True assert a.notify(body="Apprise integration test") is True caronc-apprise-182f859/tests/test_plugin_rocket_chat.py000066400000000000000000000330651524161175200233420ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests import apprise from apprise import NotifyFormat, NotifyType from apprise.plugins.rocketchat import NotifyRocketChat logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "rocket://", { "instance": None, }, ), ( "rockets://", { "instance": None, }, ), ( "rocket://:@/", { "instance": None, }, ), # No username or pass ( "rocket://localhost", { "instance": TypeError, }, ), # No room or channel ( "rocket://user:pass@localhost", { "instance": TypeError, }, ), # No valid rooms or channels ( "rocket://user:pass@localhost/#/!/@", { "instance": TypeError, }, ), # No user/pass combo ( "rocket://user@localhost/room/", { "instance": TypeError, }, ), # No user/pass combo ( "rocket://localhost/room/", { "instance": TypeError, }, ), # A room and port identifier ( "rocket://user:pass@localhost:8080/room/", { "instance": NotifyRocketChat, # The response text is expected to be the following on a success "requests_response_text": { "status": "success", "data": { "authToken": "abcd", "userId": "user", }, }, "privacy_url": "rocket://user:****@localhost", }, ), # A channel (using the to=) ( "rockets://user:pass@localhost?to=#channel", { "instance": NotifyRocketChat, # The response text is expected to be the following on a success "requests_response_text": { "status": "success", "data": { "authToken": "abcd", "userId": "user", }, }, "privacy_url": "rockets://user:****@localhost", }, ), # A channel ( "rockets://user:pass@localhost/#channel", { "instance": NotifyRocketChat, # The response text is expected to be the following on a success "requests_response_text": { "status": "success", "data": { "authToken": "abcd", "userId": "user", }, }, "privacy_url": "rockets://user:****@localhost", }, ), # A channel using token based ( "rockets://user:token@localhost/#channel?mode=token", { "instance": NotifyRocketChat, "privacy_url": "rockets://user:****@localhost", }, ), # Token is detected based o it's length ( "rockets://user:{}@localhost/#channel".format("t" * 40), { "instance": NotifyRocketChat, "privacy_url": "rockets://user:****@localhost", }, ), # Several channels ( "rocket://user:pass@localhost/#channel1/#channel2/?avatar=Yes", { "instance": NotifyRocketChat, # The response text is expected to be the following on a success "requests_response_text": { "status": "success", "data": { "authToken": "abcd", "userId": "user", }, }, "privacy_url": "rocket://user:****@localhost", }, ), # Several Rooms ( "rocket://user:pass@localhost/room1/room2", { "instance": NotifyRocketChat, # The response text is expected to be the following on a success "requests_response_text": { "status": "success", "data": { "authToken": "abcd", "userId": "user", }, }, "privacy_url": "rocket://user:****@localhost", }, ), # A room and channel ( "rocket://user:pass@localhost/room/#channel?mode=basic&avatar=Yes", { "instance": NotifyRocketChat, # The response text is expected to be the following on a success "requests_response_text": { "status": "success", "data": { "authToken": "abcd", "userId": "user", }, }, # Our expected url(privacy=True) startswith() response: "privacy_url": "rocket://user:****@localhost", }, ), # A user/pass where the pass matches a webtoken # to ensure we get the right mode, we enforce basic mode # so that web/token gets interpreted as a password ( "rockets://user:pass%2Fwithslash@localhost/#channel/?mode=basic", { "instance": NotifyRocketChat, # The response text is expected to be the following on a success "requests_response_text": { "status": "success", "data": { "authToken": "abcd", "userId": "user", }, }, "privacy_url": "rockets://user:****@localhost", }, ), # A room and channel ( "rockets://user:pass@localhost/rooma/#channela", { # The response text is expected to be the following on a success "requests_response_code": requests.codes.ok, "requests_response_text": { # return something other then a success message type "status": "failure", }, # Exception is thrown in this case "instance": NotifyRocketChat, # Notifications will fail in this event "response": False, }, ), # A web token ( "rockets://web/token@localhost/@user/#channel/roomid", { "instance": NotifyRocketChat, "privacy_url": "rockets://****@localhost/#channel/roomid", }, ), ( "rockets://user:web/token@localhost/@user/?mode=webhook", { "instance": NotifyRocketChat, "privacy_url": "rockets://user:****@localhost", }, ), ( "rockets://user:web/token@localhost?to=@user2,#channel2", { "instance": NotifyRocketChat, }, ), ( "rockets://web/token@localhost/?avatar=No", { # a simple webhook token with default values "instance": NotifyRocketChat, # Our expected url(privacy=True) startswith() response: "privacy_url": "rockets://****@localhost/", }, ), ( "rockets://localhost/@user/?mode=webhook&webhook=web/token", { "instance": NotifyRocketChat, "privacy_url": "rockets://****@localhost/@user", }, ), ( "rockets://user:web/token@localhost/@user/?mode=invalid", { # invalid mode "instance": TypeError, }, ), ( "rocket://user:pass@localhost:8081/room1/room2", { "instance": NotifyRocketChat, # force a failure using basic mode "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "rockets://user:web/token@localhost?to=@user3,#channel3", { "instance": NotifyRocketChat, # force a failure using webhook mode "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "rocket://user:pass@localhost:8082/#channel", { "instance": NotifyRocketChat, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "rocket://user:pass@localhost:8083/#chan1/#chan2/room", { "instance": NotifyRocketChat, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_rocket_chat_urls(): """NotifyRocketChat() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_rocket_chat_edge_cases(mock_post, mock_get): """NotifyRocketChat() Edge Cases.""" # Chat ID recipients = "AbcD1245, @l2g, @lead2gold, #channel, #channel2" # Authentication user = "myuser" password = "mypass" # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok mock_post.return_value.content = "" mock_get.return_value.content = "" obj = NotifyRocketChat(user=user, password=password, targets=recipients) assert isinstance(obj, NotifyRocketChat) is True assert len(obj.channels) == 2 assert len(obj.users) == 2 assert len(obj.rooms) == 1 # No Webhook specified with pytest.raises(TypeError): obj = NotifyRocketChat(webhook=None, mode="webhook") # # Logout # assert obj.logout() is True # Invalid JSON during Login mock_post.return_value.content = "{" mock_get.return_value.content = "}" assert obj.login() is False # Prepare Mock to fail mock_post.return_value.content = "" mock_get.return_value.content = "" mock_post.return_value.status_code = requests.codes.internal_server_error mock_get.return_value.status_code = requests.codes.internal_server_error # # Send Notification # assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert obj._send(payload="test", notify_type=NotifyType.INFO) is False # # Logout # assert obj.logout() is False # KeyError handling mock_post.return_value.status_code = 999 mock_get.return_value.status_code = 999 # # Send Notification # assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert obj._send(payload="test", notify_type=NotifyType.INFO) is False # # Logout # assert obj.logout() is False # Generate exceptions mock_get.side_effect = requests.ConnectionError( 0, "requests.ConnectionError() not handled" ) mock_post.side_effect = mock_get.side_effect # # Send Notification # assert obj._send(payload="test", notify_type=NotifyType.INFO) is False # Attempt the check again but fake a successful login obj.login = mock.Mock() obj.login.return_value = True assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # # Logout # assert obj.logout() is False @mock.patch("requests.post") def test_plugin_rocket_chat_html_to_markdown_format(mock_post): """NotifyRocketChat(): HTML body is converted to Markdown.""" # Prepare Mock -- webhook mode needs only one POST, no login mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = "" # Use webhook mode to avoid the login/logout GET flow aobj = apprise.Apprise() assert aobj.add("rockets://web/token@localhost/") # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to RocketChat assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown, not as stripped plain text payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["text"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_rsyslog.py000066400000000000000000000151551524161175200225560ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import socket from unittest import mock import pytest import apprise logging.disable(logging.CRITICAL) from apprise.plugins.rsyslog import NotifyRSyslog # noqa E402 @mock.patch("socket.socket") @mock.patch("os.getpid") def test_plugin_rsyslog_by_url(mock_getpid, mock_socket): """NotifyRSyslog() Apprise URLs.""" payload = "test" mock_connection = mock.Mock() # Fix pid response since it can vary in length and this impacts the # sendto() payload response mock_getpid.return_value = 123 # our payload length mock_connection.sendto.return_value = 16 mock_socket.return_value = mock_connection # an invalid URL assert NotifyRSyslog.parse_url(object) is None assert NotifyRSyslog.parse_url(42) is None assert NotifyRSyslog.parse_url(None) is None # localhost does not lookup to any of the facility codes so this # gets interpreted as a host obj = apprise.Apprise.instantiate("rsyslog://localhost") assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost") is True assert r"logpid=yes" in obj.url() assert obj.notify(body=payload) is True mock_connection.sendto.return_value = 18 obj = apprise.Apprise.instantiate("rsyslog://localhost/?facility=local5") assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost/local5") is True assert r"logpid=yes" in obj.url() assert obj.notify(body=payload) is True # Invalid instantiation assert ( apprise.Apprise.instantiate("rsyslog://localhost/?facility=invalid") is None ) mock_connection.sendto.return_value = 17 # j will cause a search to take place and match to daemon obj = apprise.Apprise.instantiate("rsyslog://localhost/?facility=d") assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost/daemon") is True assert r"logpid=yes" in obj.url() assert obj.notify(body=payload) is True # Test bad return count mock_connection.sendto.return_value = 0 assert obj.notify(body=payload) is False # Test with port mock_connection.sendto.return_value = 17 obj = apprise.Apprise.instantiate("rsyslog://localhost:518") assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost:518") is True assert r"logpid=yes" in obj.url() assert obj.notify(body=payload) is True # Set length to include title (for test) mock_connection.sendto.return_value = 39 assert obj.notify(body=payload, title="Testing a title entry") is True # Return length back to where it was mock_connection.sendto.return_value = 16 # Test with default port obj = apprise.Apprise.instantiate("rsyslog://localhost:514") assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost") is True assert r"logpid=yes" in obj.url() assert obj.notify(body=payload) is True # Specify a facility obj = apprise.Apprise.instantiate("rsyslog://localhost/kern") assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost/kern") is True assert r"logpid=yes" in obj.url() assert obj.notify(body=payload) is True # Specify a facility requiring a lookup and having the port identified # resolves any ambiguity obj = apprise.Apprise.instantiate("rsyslog://localhost:514/d") assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost/daemon") is True assert r"logpid=yes" in obj.url() mock_connection.sendto.return_value = 17 # daemon is one more byte in size assert obj.notify(body=payload) is True obj = apprise.Apprise.instantiate("rsyslog://localhost:9000/d?logpid=no") assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost:9000/daemon") is True assert r"logpid=no" in obj.url() # Verify our URL ID is generated assert isinstance(obj.url_id(), str) # Test notifications # + 1 byte in size due to user # + length of pid returned mock_connection.sendto.return_value = ( len(payload) + 5 + len(str(mock_getpid.return_value)) ) assert obj.notify(body=payload) is True # This only fails because the underlining sendto() will return a # length different then what was expected assert obj.notify(body="a different payload size") is False # Test timeouts and errors that can occur mock_connection.sendto.return_value = None mock_connection.sendto.side_effect = socket.gaierror assert obj.notify(body=payload) is False mock_connection.sendto.side_effect = socket.timeout assert obj.notify(body=payload) is False def test_plugin_rsyslog_edge_cases(): """NotifyRSyslog() Edge Cases.""" # Default obj = NotifyRSyslog(host="localhost", facility=None) assert isinstance(obj, NotifyRSyslog) assert obj.url().startswith("rsyslog://localhost/user") is True assert r"logpid=yes" in obj.url() # Exception should be thrown about the fact no bot token was specified with pytest.raises(TypeError): NotifyRSyslog(host="localhost", facility="invalid") with pytest.raises(TypeError): NotifyRSyslog(host="localhost", facility=object) caronc-apprise-182f859/tests/test_plugin_ryver.py000066400000000000000000000127061524161175200222220ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import pytest import requests from apprise.plugins.ryver import NotifyRyver logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "ryver://", { "instance": TypeError, }, ), ( "ryver://:@/", { "instance": TypeError, }, ), ( "ryver://apprise", { # Just org provided (no token) "instance": TypeError, }, ), ( "ryver://apprise/ckhrjW8w672m6HG?mode=invalid", { # invalid mode provided "instance": TypeError, }, ), ( "ryver://x/ckhrjW8w672m6HG?mode=slack", { # Invalid org "instance": TypeError, }, ), ( "ryver://apprise/ckhrjW8w672m6HG?mode=slack", { # No username specified; this is still okay as we use whatever # the user told the webhook to use; set our slack mode "instance": NotifyRyver, }, ), ( "ryver://apprise/ckhrjW8w672m6HG?mode=ryver", { # No username specified; this is still okay as we use whatever # the user told the webhook to use; set our ryver mode "instance": NotifyRyver, }, ), # Legacy webhook mode setting: # Legacy webhook mode setting: ( "ryver://apprise/ckhrjW8w672m6HG?webhook=slack", { # No username specified; this is still okay as we use whatever # the user told the webhook to use; set our slack mode "instance": NotifyRyver, }, ), ( "ryver://apprise/ckhrjW8w672m6HG?webhook=ryver", { # No username specified; this is still okay as we use whatever # the user told the webhook to use; set our ryver mode "instance": NotifyRyver, # Our expected url(privacy=True) startswith() response: "privacy_url": "ryver://apprise/c...G", }, ), # Support Native URLs ( "https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG", { "instance": NotifyRyver, }, ), # Support Native URLs with arguments ( ( "https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG" "?webhook=ryver" ), { "instance": NotifyRyver, }, ), ( "ryver://caronc@apprise/ckhrjW8w672m6HG", { "instance": NotifyRyver, # don't include an image by default "include_image": False, }, ), ( "ryver://apprise/ckhrjW8w672m6HG", { "instance": NotifyRyver, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "ryver://apprise/ckhrjW8w672m6HG", { "instance": NotifyRyver, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "ryver://apprise/ckhrjW8w672m6HG", { "instance": NotifyRyver, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_ryver_urls(): """NotifyRyver() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_ryver_edge_cases(): """NotifyRyver() Edge Cases.""" # No token with pytest.raises(TypeError): NotifyRyver(organization="abc", token=None) with pytest.raises(TypeError): NotifyRyver(organization="abc", token=" ") # No organization with pytest.raises(TypeError): NotifyRyver(organization=None, token="abc") with pytest.raises(TypeError): NotifyRyver(organization=" ", token="abc") caronc-apprise-182f859/tests/test_plugin_sendgrid.py000066400000000000000000000174601524161175200226540ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.sendgrid import NotifySendGrid logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # a test UUID we can use UUID4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752" # Our Testing URLs apprise_url_tests = ( ( "sendgrid://", { "instance": None, }, ), ( "sendgrid://:@/", { "instance": None, }, ), ( "sendgrid://abcd", { # Just an broken email (no api key or email) "instance": None, }, ), ( "sendgrid://abcd@host", { # Just an Email specified, no API Key "instance": None, }, ), ( "sendgrid://invalid-api-key+*-d:user@example.com", { # An invalid API Key "instance": TypeError, }, ), ( "sendgrid://abcd:user@example.com", { # No To/Target Address(es) specified; so we sub in the same From # address "instance": NotifySendGrid, }, ), ( "sendgrid://abcd:user@example.com/newuser@example.com", { # A good email "instance": NotifySendGrid, }, ), ( "sendgrid://abcd:user@example.com/bademailaddress", { # won't be able to send email "instance": NotifySendGrid, "notify_response": False, }, ), ( ( "sendgrid://abcd:user@example.com/newuser@example.com" "?bcc=l2g@nuxref.com" ), { # A good email with Blind Carbon Copy "instance": NotifySendGrid, "force_debug": True, }, ), ( ( "sendgrid://abcd:user@example.com/newuser@example.com" "?cc=l2g@nuxref.com" ), { # A good email with Carbon Copy "instance": NotifySendGrid, }, ), ( ( "sendgrid://abcd:user@example.com/newuser@example.com" "?to=l2g@nuxref.com" ), { # A good email with Carbon Copy "instance": NotifySendGrid, }, ), ( ( "sendgrid://abcd:user@example.com/newuser@example.com" f"?template={UUID4}" ), { # A good email with a template + no substitutions "instance": NotifySendGrid, }, ), ( ( "sendgrid://abcd:user@example.com/newuser@example.com" f"?template={UUID4}&+sub=value&+sub2=value2" ), { # A good email with a template + substitutions "instance": NotifySendGrid, # Our expected url(privacy=True) startswith() response: "privacy_url": "sendgrid://a...d:user@example.com/", }, ), ( "sendgrid://abcd:user@example.ca/newuser@example.ca", { "instance": NotifySendGrid, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "sendgrid://abcd:user@example.uk/newuser@example.uk", { "instance": NotifySendGrid, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "sendgrid://abcd:user@example.au/newuser@example.au", { "instance": NotifySendGrid, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_sendgrid_urls(): """NotifySendGrid() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_sendgrid_edge_cases(mock_post, mock_get): """NotifySendGrid() Edge Cases.""" # no apikey with pytest.raises(TypeError): NotifySendGrid(apikey=None, from_email="user@example.com") # invalid from email with pytest.raises(TypeError): NotifySendGrid(apikey="abcd", from_email="!invalid") # no email with pytest.raises(TypeError): NotifySendGrid(apikey="abcd", from_email=None) # Invalid To email address NotifySendGrid( apikey="abcd", from_email="user@example.com", targets="!invalid" ) # Test invalid bcc/cc entries mixed with good ones assert isinstance( NotifySendGrid( apikey="abcd", from_email="l2g@example.com", bcc=("abc@def.com", "!invalid"), cc=("abc@test.org", "!invalid"), ), NotifySendGrid, ) @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_sendgrid_attachments(mock_post, mock_get): """NotifySendGrid() Attachments.""" request = mock.Mock() request.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = request mock_get.return_value = request path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) obj = Apprise.instantiate("sendgrid://abcd:user@example.com") assert isinstance(obj, NotifySendGrid) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) mock_post.reset_mock() mock_get.reset_mock() # Try again in a use case where we can't access the file with mock.patch("os.path.isfile", return_value=False): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Try again in a use case where we can't access the file with mock.patch("builtins.open", side_effect=OSError): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) caronc-apprise-182f859/tests/test_plugin_sendpulse.py000066400000000000000000000525051524161175200230560ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps, loads # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.sendpulse import NotifySendPulse logging.disable(logging.CRITICAL) SENDPULSE_GOOD_RESPONSE = dumps( { "access_token": "abc123", "expires_in": 3600, } ) SENDPULSE_BAD_RESPONSE = "{" # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "sendpulse://", { "instance": TypeError, }, ), ( "sendpulse://:@/", { "instance": TypeError, }, ), ( "sendpulse://abcd", { # invalid from email "instance": TypeError, }, ), ( "sendpulse://abcd@host.com", { # Just an Email specified, no client_id or client_secret "instance": TypeError, }, ), ( "sendpulse://user@example.com/client_id/cs/?template=invalid", { # Invalid template "instance": TypeError, }, ), ( "sendpulse://user@example.com/client_id/cs1/?template=123", { "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs1/", { "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs1/?format=text", { "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs1/?format=html", { "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://chris@example.com/client_id/cs1/?from=Chris", { # Set name only "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, "force_debug": True, }, ), ( "sendpulse://?id=ci&secret=cs&user=chris@example.com", { # Set login through user= only "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://?id=ci&secret=cs&user=Chris", { # Set login through user= only "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://?id=ci&secret=cs&user=chris", { # Set login through user= only - invaild email "instance": TypeError, }, ), ( "sendpulse://example.com/client_id/cs1/?user=chris", { # Set user as a name only "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://client_id/cs1/?user=chris@example.ca", { # Set user as email "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://client_id/cs1/?from=Chris", { # set full email with name "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://?from=Chris&id=ci&secret=cs", { # leverage all get params from URL "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs1a/", { "instance": NotifySendPulse, "requests_response_text": SENDPULSE_BAD_RESPONSE, # Notify will fail because auth failed "response": False, }, ), ( "sendpulse://user@example.com/client_id/cs2/?bcc=l2g@nuxref.com", { # A good email with Blind Carbon Copy "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs2/?bcc=invalid", { # A good email with Blind Carbon Copy "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs3/?cc=l2g@nuxref.com", { # A good email with Carbon Copy "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs4/?cc=Chris", { # A good email with Carbon Copy "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs5/?cc=invalid", { # A good email with Carbon Copy "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs6/?to=invalid", { # an invalid to email "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs7/chris@example.com", { # An email with a designated to email "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs8/" "?to=Chris", { # An email with a full name in in To field "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs9/" "chris@example.com/chris2@example.com/Test", { # Several emails to notify "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs10/" "?cc=Chris", { # An email with a full name in cc "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs11/?cc=chris@example.com", { # An email with a full name in cc "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs12/" "?bcc=Chris", { # An email with a full name in bcc "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs13/?bcc=chris@example.com", { # An email with a full name in bcc "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs14/" "?to=Chris", { # An email with a full name in bcc "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs15/?to=chris@example.com", { # An email with a full name in bcc "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs16/" "?template=1234&+sub=value&+sub2=value2", { # A good email with a template + substitutions "instance": NotifySendPulse, "requests_response_text": SENDPULSE_GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: "privacy_url": "sendpulse://user@example.com/c...d/c...6/", }, ), ( "sendpulse://user@example.com/client_id/cs17/", { "instance": NotifySendPulse, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs18/", { "instance": NotifySendPulse, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ( "sendpulse://user@example.com/client_id/cs19/", { "instance": NotifySendPulse, # Throws connection and transfer exceptions when this # flag is set and tests that we gracefully handle them "test_requests_exceptions": True, "requests_response_text": SENDPULSE_GOOD_RESPONSE, }, ), ) def test_plugin_sendpulse_urls(): """ NotifySendPulse() Apprise URLs """ # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_sendpulse_edge_cases(mock_post): """ NotifySendPulse() Edge Cases """ request = mock.Mock() request.status_code = requests.codes.ok request.content = SENDPULSE_GOOD_RESPONSE # Prepare Mock mock_post.return_value = request obj = Apprise.instantiate( "sendpulse://user@example.com/ci/cs/Test" ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Test our call count assert mock_post.call_count == 2 # Authentication assert ( mock_post.call_args_list[0][0][0] == "https://api.sendpulse.com/oauth/access_token" ) payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload == { "grant_type": "client_credentials", "client_id": "ci", "client_secret": "cs", } assert ( mock_post.call_args_list[1][0][0] == "https://api.sendpulse.com/smtp/emails" ) payload = loads(mock_post.call_args_list[1][1]["data"]) assert payload == { "email": { "from": {"email": "user@example.com", "name": "Apprise"}, "to": [{"email": "test@example.com", "name": "Test"}], "subject": "title", "text": "body", "html": "Ym9keQ==", } } mock_post.reset_mock() obj = Apprise.instantiate("sendpulse://user@example.com/ci/cs/?from=John") assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Test our call count assert mock_post.call_count == 2 # Authentication assert ( mock_post.call_args_list[0][0][0] == "https://api.sendpulse.com/oauth/access_token" ) payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload == { "grant_type": "client_credentials", "client_id": "ci", "client_secret": "cs", } assert ( mock_post.call_args_list[1][0][0] == "https://api.sendpulse.com/smtp/emails" ) payload = loads(mock_post.call_args_list[1][1]["data"]) assert payload == { "email": { "from": {"email": "user@example.com", "name": "John"}, "to": [{"email": "user@example.com", "name": "John"}], "subject": "title", "text": "body", "html": "Ym9keQ==", } } mock_post.reset_mock() # Second call no longer needs to authenticate assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.sendpulse.com/smtp/emails" ) # force an exception mock_post.side_effect = requests.RequestException assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # Set an invalid return code mock_post.side_effect = None request.status_code = 403 assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # Test re-authentication mock_post.reset_mock() request = mock.Mock() obj = Apprise.instantiate("sendpulse://usr2@example.com/ci/cs/?from=Retry") class sendpulse: def __init__(self): # 200 login okay # 401 on retrival # recursive re-attempt to login returns 200 # fetch after works self._side_effect = iter( [ requests.codes.ok, requests.codes.unauthorized, requests.codes.ok, requests.codes.ok, ] ) @property def status_code(self): return next(self._side_effect) @property def content(self): return SENDPULSE_GOOD_RESPONSE mock_post.return_value = sendpulse() assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_post.call_count == 4 # Authentication assert ( mock_post.call_args_list[0][0][0] == "https://api.sendpulse.com/oauth/access_token" ) # 401 received assert ( mock_post.call_args_list[1][0][0] == "https://api.sendpulse.com/smtp/emails" ) # Re-authenticate assert ( mock_post.call_args_list[2][0][0] == "https://api.sendpulse.com/oauth/access_token" ) # Try again assert ( mock_post.call_args_list[3][0][0] == "https://api.sendpulse.com/smtp/emails" ) # Test re-authentication (no recursive loops) mock_post.reset_mock() request = mock.Mock() obj = Apprise.instantiate("sendpulse://usr2@example.com/ci/cs/?from=Retry") class sendpulse: def __init__(self): # oauth always returns okay but notify returns 401 # recursive re-attempt only once self._side_effect = iter( [ requests.codes.ok, requests.codes.unauthorized, requests.codes.ok, requests.codes.unauthorized, requests.codes.ok, requests.codes.unauthorized, requests.codes.ok, requests.codes.unauthorized, requests.codes.ok, requests.codes.unauthorized, requests.codes.ok, requests.codes.unauthorized, requests.codes.ok, requests.codes.unauthorized, requests.codes.ok, requests.codes.unauthorized, ] ) @property def status_code(self): return next(self._side_effect) @property def content(self): return SENDPULSE_GOOD_RESPONSE mock_post.return_value = sendpulse() assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert mock_post.call_count == 4 # Authentication assert ( mock_post.call_args_list[0][0][0] == "https://api.sendpulse.com/oauth/access_token" ) # 401 received assert ( mock_post.call_args_list[1][0][0] == "https://api.sendpulse.com/smtp/emails" ) # Re-authenticate assert ( mock_post.call_args_list[2][0][0] == "https://api.sendpulse.com/oauth/access_token" ) # Last failed attempt assert ( mock_post.call_args_list[3][0][0] == "https://api.sendpulse.com/smtp/emails" ) mock_post.side_effect = None request = mock.Mock() request.status_code = requests.codes.ok request.content = SENDPULSE_GOOD_RESPONSE mock_post.return_value = request for expires_in in (None, -1, "garbage", 3600, 300000): request.content = dumps( { "access_token": "abc123", "expires_in": expires_in, } ) # Instantiate our object obj = Apprise.instantiate("sendpulse://user@example.com/ci/cs/") # Test variations of responses obj.notify(body="body", title="title", notify_type=NotifyType.INFO) # expires_in is missing request.content = dumps( { "access_token": "abc123", } ) # Instantiate our object obj = Apprise.instantiate("sendpulse://user@example.com/ci/cs/") assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) def test_plugin_sendpulse_fail_cases(): """ NotifySendPulse() Fail Cases """ # no client_id with pytest.raises(TypeError): NotifySendPulse( client_id="abcd", client_secret=None, from_addr="user@example.com" ) with pytest.raises(TypeError): NotifySendPulse( client_id=None, client_secret="abcd123", from_addr="user@example.com", ) # invalid from email with pytest.raises(TypeError): NotifySendPulse( client_id="abcd", client_secret="abcd456", from_addr="!invalid" ) # no email with pytest.raises(TypeError): NotifySendPulse( client_id="abcd", client_secret="abcd789", from_addr=None ) # Invalid To email address NotifySendPulse( client_id="abcd", client_secret="abcd321", from_addr="user@example.com", targets="!invalid", ) # Test invalid bcc/cc entries mixed with good ones assert isinstance( NotifySendPulse( client_id="abcd", client_secret="abcd654", from_addr="l2g@example.com", bcc=("abc@def.com", "!invalid"), cc=("abc@test.org", "!invalid"), ), NotifySendPulse, ) @mock.patch("requests.post") def test_plugin_sendpulse_attachments(mock_post): """ NotifySendPulse() Attachments """ request = mock.Mock() request.status_code = requests.codes.ok request.content = SENDPULSE_GOOD_RESPONSE # Prepare Mock mock_post.return_value = request path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) obj = Apprise.instantiate("sendpulse://user@example.com/aaaa/bbbb") assert isinstance(obj, NotifySendPulse) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) mock_post.reset_mock() # Try again in a use case where we can't access the file with mock.patch("os.path.isfile", return_value=False): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Try again in a use case where we can't access the file with mock.patch("builtins.open", side_effect=OSError): assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) caronc-apprise-182f859/tests/test_plugin_serverchan.py000066400000000000000000000054431524161175200232130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester from apprise.plugins.serverchan import NotifyServerChan logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "schan://", { # No Access Token specified "instance": TypeError, }, ), ( "schan://a_bd_/", { # invalid Access Token "instance": TypeError, }, ), ( "schan://12345678", { # access token "instance": NotifyServerChan, # Our expected url(privacy=True) startswith() response: "privacy_url": "schan://1...8", }, ), ( "schan://{}".format("a" * 8), { "instance": NotifyServerChan, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "schan://{}".format("a" * 8), { "instance": NotifyServerChan, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_serverchan_urls(): """NotifyServerChan() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_serwersms.py000066400000000000000000000644611524161175200231120ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import loads # Disable logging for a cleaner testing output import logging import os from unittest import mock from urllib.parse import urlparse from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.serwersms import NotifySerwerSMS # Attachment test fixtures directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") logging.disable(logging.CRITICAL) # The API always returns 200 with a JSON body; use this as the mock response # for successful cases so our success-field check passes. SUCCESS_RESPONSE = {"success": True} FAILURE_RESPONSE = {"success": False, "error": {"code": 101, "message": "err"}} FAILURE_NO_ERROR = {"success": False} # Our Testing URLs apprise_url_tests = ( ( "serwersms://", { # Missing credentials "instance": TypeError, }, ), ( "serwersms://:@/", { # Empty credentials "instance": TypeError, }, ), ( "serwersms://user@SenderA/+48123456789", { # Missing password "instance": TypeError, }, ), ( "serwersms://user:pass@/+48123456789", { # Missing sender (empty host) "instance": TypeError, }, ), ( "serwersms://user:pass@!!invalid!!", { # Invalid sender name (fails regex) "instance": TypeError, }, ), ( "serwersms://user:pass@SenderA", { # Valid credentials + sender but no targets "instance": NotifySerwerSMS, # No targets -- send() returns False "notify_response": False, }, ), ( "serwersms://user:pass@SenderA/+48123456789", { # Single phone number target "instance": NotifySerwerSMS, "privacy_url": "serwersms://u...r:****@SenderA/+48123456789?", "requests_response_text": SUCCESS_RESPONSE, }, ), ( "serwersms://user:pass@SenderA/%23456", { # Single group ID target (# encoded as %23) "instance": NotifySerwerSMS, "privacy_url": "serwersms://u...r:****@SenderA/%23456?", "requests_response_text": SUCCESS_RESPONSE, }, ), ( "serwersms://user:pass@SenderA/+48123456789/%23789", { # Mixed: one phone + one group "instance": NotifySerwerSMS, "requests_response_text": SUCCESS_RESPONSE, }, ), ( "serwersms://user:pass@SenderA/+48111222333?to=%23999", { # Phone in path + group via ?to= "instance": NotifySerwerSMS, "requests_response_text": SUCCESS_RESPONSE, }, ), ( "serwersms://user:pass@SenderA/+48123456789", { # HTTP 500 error "instance": NotifySerwerSMS, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "serwersms://user:pass@SenderA/+48123456789", { # Unknown HTTP status code "instance": NotifySerwerSMS, "response": False, "requests_response_code": 999, }, ), ( "serwersms://user:pass@SenderA/+48123456789", { # Connection exceptions "instance": NotifySerwerSMS, "test_requests_exceptions": True, }, ), ( "serwersms://user:pass@SenderA/%23123", { # Group target -- HTTP 500 "instance": NotifySerwerSMS, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "serwersms://user:pass@SenderA/%23123", { # Group target -- connection exceptions "instance": NotifySerwerSMS, "test_requests_exceptions": True, }, ), ) def test_plugin_serwersms_urls(): """NotifySerwerSMS() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_serwersms_init(mock_post): """NotifySerwerSMS() Initialisation tests.""" # Missing username with pytest.raises(TypeError): NotifySerwerSMS(sender="SenderA", targets=["+48123456789"]) # Missing password with pytest.raises(TypeError): NotifySerwerSMS( user="user", sender="SenderA", targets=["+48123456789"], ) # Missing sender with pytest.raises(TypeError): NotifySerwerSMS( user="user", password="pass", targets=["+48123456789"], ) # Invalid sender (too long - >11 chars) with pytest.raises(TypeError): NotifySerwerSMS( user="user", password="pass", sender="A" * 12, targets=["+48123456789"], ) # Invalid sender (starts with non-alphanumeric) with pytest.raises(TypeError): NotifySerwerSMS( user="user", password="pass", sender="!invalid", targets=["+48123456789"], ) # Valid instance with no targets -- loads OK; __len__ returns minimum 1 obj = NotifySerwerSMS( user="user", password="pass", sender="SenderA", ) assert obj is not None assert len(obj) == 1 # Valid instance with a phone target obj = NotifySerwerSMS( user="user", password="pass", sender="SenderA", targets=["+48123456789"], ) assert len(obj.target_phones) == 1 assert len(obj.target_groups) == 0 # Valid instance with a group target (#123) obj = NotifySerwerSMS( user="user", password="pass", sender="SenderA", targets=["#456"], ) assert len(obj.target_phones) == 0 assert len(obj.target_groups) == 1 assert obj.target_groups[0] == "456" # Group target with %23 prefix is also accepted obj = NotifySerwerSMS( user="user", password="pass", sender="SenderA", targets=["%23789"], ) assert len(obj.target_groups) == 1 assert obj.target_groups[0] == "789" # Invalid targets are stored in invalid_targets obj = NotifySerwerSMS( user="user", password="pass", sender="SenderA", targets=["notaphone", "+48123456789"], ) assert len(obj.invalid_targets) == 1 assert len(obj.target_phones) == 1 @mock.patch("requests.post") def test_plugin_serwersms_send_phone(mock_post): """NotifySerwerSMS() send to phone numbers.""" # Prepare our success response mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": true}' obj = NotifySerwerSMS( user="myuser", password="mypass", sender="AppName", targets=["+48111222333", "+48444555666"], ) # Two phone targets means two POST calls assert obj.send(body="Hello", title="Title") is True assert mock_post.call_count == 2 # Verify request URL call_url = mock_post.call_args_list[0][0][0] assert urlparse(call_url).hostname == "api2.serwersms.pl" # Verify payload contents for first call payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["username"] == "myuser" assert payload["password"] == "mypass" assert payload["sender"] == "AppName" assert payload["text"] == "Hello" assert payload["phone"] == "+48111222333" assert "group_id" not in payload # Verify second call targets the second number payload2 = loads(mock_post.call_args_list[1][1]["data"]) assert payload2["phone"] == "+48444555666" @mock.patch("requests.post") def test_plugin_serwersms_send_group(mock_post): """NotifySerwerSMS() send to contact groups.""" # Prepare our success response mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": true}' obj = NotifySerwerSMS( user="myuser", password="mypass", sender="AppName", targets=["#100", "#200"], ) # Two group targets means two POST calls assert obj.send(body="Hello") is True assert mock_post.call_count == 2 # Verify payload for group send payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["group_id"] == "100" assert "phone" not in payload payload2 = loads(mock_post.call_args_list[1][1]["data"]) assert payload2["group_id"] == "200" @mock.patch("requests.post") def test_plugin_serwersms_send_mixed(mock_post): """NotifySerwerSMS() send to phones and groups together.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": true}' obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789", "#500"], ) # One phone + one group = two calls assert obj.send(body="msg") is True assert mock_post.call_count == 2 # First call: phone p1 = loads(mock_post.call_args_list[0][1]["data"]) assert "phone" in p1 # Second call: group p2 = loads(mock_post.call_args_list[1][1]["data"]) assert "group_id" in p2 @mock.patch("requests.post") def test_plugin_serwersms_no_targets(mock_post): """NotifySerwerSMS() with no targets returns False without calling API.""" obj = NotifySerwerSMS( user="u", password="p", sender="Sender", ) assert obj.send(body="msg") is False assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_serwersms_http_error(mock_post): """NotifySerwerSMS() handles HTTP errors gracefully.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.internal_server_error mock_post.return_value.content = b"" obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789", "#100"], ) # Both calls fail due to HTTP 500 assert obj.send(body="msg") is False assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_serwersms_http_error_partial(mock_post): """NotifySerwerSMS() returns False on any partial HTTP failure.""" # First call succeeds, second fails good = mock.Mock() good.status_code = requests.codes.ok good.content = b'{"success": true}' bad = mock.Mock() bad.status_code = 999 bad.content = b"" mock_post.side_effect = [good, bad] obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48111000000", "+48222000000"], ) # One success, one failure -> overall False assert obj.send(body="msg") is False assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_serwersms_request_exception(mock_post): """NotifySerwerSMS() handles RequestException gracefully.""" mock_post.side_effect = requests.RequestException("Connection refused") obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_serwersms_request_exception_group(mock_post): """NotifySerwerSMS() handles RequestException on group send.""" mock_post.side_effect = requests.RequestException("timeout") obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["#100"], ) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_serwersms_api_failure_with_error(mock_post): """NotifySerwerSMS() handles API success=false with error object.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = ( b'{"success": false, "error": {"code": 101, "message": "Auth fail"}}' ) obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_serwersms_api_failure_no_error(mock_post): """NotifySerwerSMS() handles API success=false without error detail.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": false}' obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["#200"], ) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_serwersms_api_bad_json(mock_post): """NotifySerwerSMS() handles unparseable JSON response body.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"not-json" obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) # Bad JSON treated as failure (success field absent -> falsy) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_serwersms_api_null_json(mock_post): """NotifySerwerSMS() handles null JSON response body.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok # JSON null parses to Python None; plugin normalises to {} mock_post.return_value.content = b"null" obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) assert obj.send(body="msg") is False @mock.patch("requests.post") def test_plugin_serwersms_url(mock_post): """NotifySerwerSMS() url() and url_identifier() tests.""" obj = NotifySerwerSMS( user="myuser", password="mypass", sender="AppName", targets=["+48123456789", "#100"], ) # url() produces a string full_url = obj.url() assert full_url.startswith("serwersms://") assert "myuser" in full_url assert "mypass" in full_url assert "AppName" in full_url # Privacy URL masks the password and abbreviates the user priv = obj.url(privacy=True) assert "mypass" not in priv assert "myuser" not in priv assert "AppName" in priv # url_identifier excludes targets ident = obj.url_identifier assert "serwersms" in ident assert "myuser" in ident assert "AppName" in ident assert "+48123456789" not in str(ident) @mock.patch("requests.post") def test_plugin_serwersms_url_roundtrip(mock_post): """NotifySerwerSMS() round-trips through url() -> parse_url().""" obj = NotifySerwerSMS( user="myuser", password="mypass", sender="AppName", targets=["+48123456789", "#100"], ) # Parse the generated URL back into a new object result = NotifySerwerSMS.parse_url(obj.url()) assert result is not None obj2 = NotifySerwerSMS(**result) assert obj.url_identifier == obj2.url_identifier assert len(obj.target_phones) == len(obj2.target_phones) assert len(obj.target_groups) == len(obj2.target_groups) @mock.patch("requests.post") def test_plugin_serwersms_url_invalid_targets(mock_post): """NotifySerwerSMS() preserves invalid targets in url() round-trip.""" obj = NotifySerwerSMS( user="u", password="p", sender="S", targets=["+48123456789", "badtarget"], ) assert len(obj.invalid_targets) == 1 assert len(obj.target_phones) == 1 # The generated URL must include the invalid target so it survives # a round-trip without silent loss. url = obj.url() assert "badtarget" in url def test_plugin_serwersms_parse_url(): """NotifySerwerSMS() parse_url() edge cases.""" # Standard URL with phone and group result = NotifySerwerSMS.parse_url( "serwersms://user:pass@SenderA/+48123456789/%23100" ) assert result is not None assert result["user"] == "user" assert result["password"] == "pass" assert result["sender"] == "SenderA" # Path entries come back decoded; +48123456789 and #100 assert "+48123456789" in result["targets"] assert "#100" in result["targets"] # ?to= query param adds targets result = NotifySerwerSMS.parse_url( "serwersms://user:pass@SenderA?to=%2348123456789" ) assert result is not None assert len(result["targets"]) >= 1 # ?from= sets the sender result = NotifySerwerSMS.parse_url( "serwersms://user:pass@SenderA/+48111?from=NewSender" ) assert result["sender"] == "NewSender" # ?sender= sets the sender (takes priority over ?from=) result = NotifySerwerSMS.parse_url( "serwersms://user:pass@SenderA/+48111?from=Old&sender=New" ) assert result["sender"] == "New" # A URL with verify_host=False accepts empty host; verify None is returned # for a genuinely unparseable URL (non-string) assert NotifySerwerSMS.parse_url(None) is None @mock.patch("requests.post") def test_plugin_serwersms_apprise_integration(mock_post): """NotifySerwerSMS() integration with Apprise object.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": true}' aobj = Apprise() assert aobj.add("serwersms://user:pass@AppName/+48123456789") assert aobj.notify( body="Integration test", title="Title", notify_type=NotifyType.INFO, ) assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_serwersms_mms_phone_success(mock_post): """NotifySerwerSMS() MMS to phone -- success path.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": true}' obj = NotifySerwerSMS( user="myuser", password="mypass", sender="AppName", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="MMS test", attach=attach) is True assert mock_post.call_count == 1 # Confirm multipart files kwarg was set (MMS endpoint used) call_kwargs = mock_post.call_args[1] assert call_kwargs["files"] is not None # Confirm form fields (not JSON body) assert call_kwargs["data"]["phone"] == "+48123456789" assert call_kwargs["data"]["sender"] == "AppName" assert call_kwargs["data"]["text"] == "MMS test" # Confirm MMS endpoint was used call_url = mock_post.call_args[0][0] assert "send_mms" in call_url # Confirm Content-Type was not forced (requests sets multipart boundary) assert "Content-Type" not in call_kwargs["headers"] @mock.patch("requests.post") def test_plugin_serwersms_mms_group_success(mock_post): """NotifySerwerSMS() MMS to group -- success path.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": true}' obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["#500"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Group MMS", attach=attach) is True assert mock_post.call_count == 1 # Confirm group_id was sent as a form field call_kwargs = mock_post.call_args[1] assert call_kwargs["data"]["group_id"] == "500" assert "phone" not in call_kwargs["data"] @mock.patch("requests.post") def test_plugin_serwersms_mms_guard1_inaccessible(mock_post): """NotifySerwerSMS() MMS -- guard 1 inaccessible attachment.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": true}' obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) # Simulate file being inaccessible (bool(attachment) -> False) with mock.patch("os.path.isfile", return_value=False): assert obj.notify(body="Test", attach=attach) is False # No HTTP call should have been made assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_serwersms_mms_guard2_oserror(mock_post): """NotifySerwerSMS() MMS -- guard 2 OSError on open.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": true}' obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) with mock.patch("builtins.open", side_effect=OSError("IO fail")): assert obj.notify(body="Test", attach=attach) is False # No HTTP call should have been made assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_serwersms_mms_http_error(mock_post): """NotifySerwerSMS() MMS -- HTTP error response.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.internal_server_error mock_post.return_value.content = b"" obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Test", attach=attach) is False assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_serwersms_mms_api_failure_with_error(mock_post): """NotifySerwerSMS() MMS -- API success=false with error object.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = ( b'{"success": false, "error": {"code": 201, "message": "MMS fail"}}' ) obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Test", attach=attach) is False @mock.patch("requests.post") def test_plugin_serwersms_mms_api_failure_no_error(mock_post): """NotifySerwerSMS() MMS -- API success=false without error detail.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b'{"success": false}' obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Test", attach=attach) is False @mock.patch("requests.post") def test_plugin_serwersms_mms_bad_json(mock_post): """NotifySerwerSMS() MMS -- unparseable JSON response.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = b"not-json" obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) # Bad JSON -> content = {} -> success absent -> False assert obj.notify(body="Test", attach=attach) is False @mock.patch("requests.post") def test_plugin_serwersms_mms_null_json(mock_post): """NotifySerwerSMS() MMS -- null JSON response body.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.ok # JSON null parses to Python None; plugin normalises to {} mock_post.return_value.content = b"null" obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Test", attach=attach) is False @mock.patch("requests.post") def test_plugin_serwersms_mms_request_exception(mock_post): """NotifySerwerSMS() MMS -- RequestException guard.""" mock_post.side_effect = requests.RequestException("Connection refused") obj = NotifySerwerSMS( user="u", password="p", sender="Sender", targets=["+48123456789"], ) attach = AppriseAttachment.instantiate( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert obj.notify(body="Test", attach=attach) is False caronc-apprise-182f859/tests/test_plugin_ses.py000066400000000000000000000537661524161175200216600ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os import sys from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment from apprise.plugins.ses import NotifySES logging.disable(logging.CRITICAL) if hasattr(sys, "pypy_version_info"): raise pytest.skip( reason="Skipping test cases which stall on PyPy", allow_module_level=True, ) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") AWS_SES_GOOD_RESPONSE = """ 010f017d87656ee2-a2ea291f-79ea- 44f3-9d25-00d041de3007-000000 7abb454e-904b-4e46-a23c-2f4d2fc127a6 """ TEST_ACCESS_KEY_ID = "AHIAJGNT76XIMXDBIJYA" TEST_ACCESS_KEY_SECRET = "bu1dHSdO22pfaaVy/wmNsdljF4C07D3bndi9PQJ9" TEST_REGION = "us-east-2" TEST_SESSION_TOKEN = "FwoGZXIvYXdzSESSIONTOKENABCDEFGHIJKLMNOPQRS" # Our Testing URLs apprise_url_tests = ( ( "ses://", { "instance": TypeError, }, ), ( "ses://:@/", { "instance": TypeError, }, ), ( "ses://user@example.com/T1JJ3T3L2", { # Just Token 1 provided "instance": TypeError, }, ), ( "ses://user@example.com/T1JJ3TD4JD/TIiajkdnlazk7FQ/", { # Missing a region "instance": TypeError, }, ), ( "ses://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2", { # No email "instance": TypeError, }, ), ( "ses://user@example.com/T1JJ3TD4JD/TIiajkdnlazk7FQ/user2@example.com", { # Missing a region (but has email) "instance": TypeError, }, ), ( ( "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/" "us-west-2?reply=invalid-email" ), { # An invalid reply-to address "instance": TypeError, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ( ( "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/" "us-west-2" ), { # we have a valid URL and we'll use our own email as a target "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ( ( "ses://user@example.com/T1JJ3TD4JD/TIiajkdnlazk7FQ/us-west-2/" "user2@example.ca/user3@example.eu" ), { # Multi Email Suppport "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: "privacy_url": "ses://user@example.com/T...D/****/us-west-2", }, ), ( ( "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlaevi7FQ/us-east-1" "?to=user2@example.ca" ), { # leveraging to: keyword "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ( ( "ses://?from=user@example.com®ion=us-west-2&access=T1JJ3T3L2" "&secret=A1BRTD4JD/TIiajkdnlaevi7FQ" "&reply=No One " "&bcc=user.bcc@example.com,user2.bcc@example.com,invalid-email" "&cc=user.cc@example.com,user2.cc@example.com,invalid-email" "&to=user2@example.ca" ), { # leveraging a ton of our keywords # We also test invlid emails specified on the bcc and cc list "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ( ( "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/" "?name=From%20Name&to=user2@example.ca,invalid-email" ), { # leveraging a ton of our keywords "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ( ( "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/" "?format=text" ), { # Send email as a text (instead of HTML) "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ( ( "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/" "?to=invalid-email" ), { # An invalid email will get dropped during the initialization # we'll have no targets to notify afterwards "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, # As a result, we won't be able to notify anyone "notify_response": False, }, ), ( ( "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/" "user2@example.com" ), { "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( ( "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlavi7FQ/us-west-2/" "user2@example.com" ), { "instance": NotifySES, # Our response expected server response "requests_response_text": AWS_SES_GOOD_RESPONSE, # Throws a series of connection and transfer exceptions when this # flag is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( # Session token via ?token= query parameter "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/" "user2@example.com?token=SESSIONTOKEN", { "instance": NotifySES, "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ( # Access key via ?key= alias "ses://user@example.com/?key=T1JJ3T3L2" "&secret=A1BRTD4JD/TIiacevi7FQ®ion=us-west-2" "&to=user2@example.com", { "instance": NotifySES, "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ( # Session token + HTTP error "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/" "user2@example.com?token=SESSIONTOKEN", { "instance": NotifySES, "requests_response_text": AWS_SES_GOOD_RESPONSE, "response": False, "requests_response_code": 999, }, ), ( # Session token + request exceptions "ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/" "user2@example.com?token=SESSIONTOKEN", { "instance": NotifySES, "requests_response_text": AWS_SES_GOOD_RESPONSE, "test_requests_exceptions": True, }, ), ( # Session token in the URL password field "ses://user:SESSIONTOKEN@example.com/T1JJ3T3L2/A1BRTD4JD/" "TIiacevi7FQ/us-west-2/user2@example.com", { "instance": NotifySES, "requests_response_text": AWS_SES_GOOD_RESPONSE, }, ), ) def test_plugin_ses_urls(): """NotifySES() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() # We initialize a post object just incase a test fails below # we don't want it sending any notifications upstream @mock.patch("requests.post") def test_plugin_ses_edge_cases(mock_post): """NotifySES() Edge Cases.""" # Initializes the plugin with a valid access, but invalid access key with pytest.raises(TypeError): # No access_key_id specified NotifySES( from_addr="user@example.eu", access_key_id=None, secret_access_key=TEST_ACCESS_KEY_SECRET, region_name=TEST_REGION, targets="user@example.ca", ) with pytest.raises(TypeError): # No secret_access_key specified NotifySES( from_addr="user@example.eu", access_key_id=TEST_ACCESS_KEY_ID, secret_access_key=None, region_name=TEST_REGION, targets="user@example.ca", ) with pytest.raises(TypeError): # No region_name specified NotifySES( from_addr="user@example.eu", access_key_id=TEST_ACCESS_KEY_ID, secret_access_key=TEST_ACCESS_KEY_SECRET, region_name=None, targets="user@example.ca", ) # No recipients obj = NotifySES( from_addr="user@example.eu", access_key_id=TEST_ACCESS_KEY_ID, secret_access_key=TEST_ACCESS_KEY_SECRET, region_name=TEST_REGION, targets=None, ) # The object initializes properly but would not be able to send anything assert obj.notify(body="test", title="test") is False # The phone number is invalid, and without it, there is nothing # to notify; we obj = NotifySES( from_addr="user@example.eu", access_key_id=TEST_ACCESS_KEY_ID, secret_access_key=TEST_ACCESS_KEY_SECRET, region_name=TEST_REGION, targets="invalid-email", ) # The object initializes properly but would not be able to send anything assert obj.notify(body="test", title="test") is False def test_plugin_ses_url_parsing(): """NotifySES() URL Parsing.""" # No recipients results = NotifySES.parse_url( "ses://{}/{}/{}/{}/".format( "user@example.com", TEST_ACCESS_KEY_ID, TEST_ACCESS_KEY_SECRET, TEST_REGION, ) ) # Confirm that there were no recipients found assert len(results["targets"]) == 0 assert "region_name" in results assert results["region_name"] == TEST_REGION assert "access_key_id" in results assert results["access_key_id"] == TEST_ACCESS_KEY_ID assert "secret_access_key" in results assert results["secret_access_key"] == TEST_ACCESS_KEY_SECRET # Detect recipients results = NotifySES.parse_url( "ses://{}/{}/{}/{}/{}/{}/".format( "user@example.com", TEST_ACCESS_KEY_ID, TEST_ACCESS_KEY_SECRET, # Uppercase Region won't break anything TEST_REGION.upper(), "user1@example.ca", "user2@example.eu", ) ) # Confirm that our recipients were found assert len(results["targets"]) == 2 assert "user1@example.ca" in results["targets"] assert "user2@example.eu" in results["targets"] assert "region_name" in results assert results["region_name"] == TEST_REGION assert "access_key_id" in results assert results["access_key_id"] == TEST_ACCESS_KEY_ID assert "secret_access_key" in results assert results["secret_access_key"] == TEST_ACCESS_KEY_SECRET def test_plugin_ses_aws_response_handling(): """NotifySES() AWS Response Handling.""" # Not a string response = NotifySES.aws_response_to_dict(None) assert response["type"] is None assert response["request_id"] is None # Invalid XML response = NotifySES.aws_response_to_dict( '' ) assert response["type"] is None assert response["request_id"] is None # Single Element in XML response = NotifySES.aws_response_to_dict( "" ) assert response["type"] == "SingleElement" assert response["request_id"] is None # Empty String response = NotifySES.aws_response_to_dict("") assert response["type"] is None assert response["request_id"] is None response = NotifySES.aws_response_to_dict(""" 010f017d87656ee2-a2ea291f-79ea-44f3-9d25-00d041de307 7abb454e-904b-4e46-a23c-2f4d2fc127a6 """) assert response["type"] == "SendRawEmailResponse" assert response["request_id"] == "7abb454e-904b-4e46-a23c-2f4d2fc127a6" assert ( response["message_id"] == "010f017d87656ee2-a2ea291f-79ea-44f3-9d25-00d041de307" ) response = NotifySES.aws_response_to_dict(""" Sender InvalidParameter Invalid parameter b5614883-babe-56ca-93b2-1c592ba6191e """) assert response["type"] == "ErrorResponse" assert response["request_id"] == "b5614883-babe-56ca-93b2-1c592ba6191e" assert response["error_type"] == "Sender" assert response["error_code"] == "InvalidParameter" assert response["error_message"] == "Invalid parameter" @mock.patch("requests.post") def test_plugin_ses_attachments(mock_post): """NotifySES() Attachment Checks.""" # Prepare Mock return object response = mock.Mock() response.content = AWS_SES_GOOD_RESPONSE response.status_code = requests.codes.ok mock_post.return_value = response # prepare our attachment attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Test our markdown obj = Apprise.instantiate( "ses://{}/{}/{}/{}/".format( "user@example.com", TEST_ACCESS_KEY_ID, TEST_ACCESS_KEY_SECRET, TEST_REGION, ) ) # Send a good attachment assert obj.notify(body="test", attach=attach) is True # Reset our mock object mock_post.reset_mock() # Add another attachment so we drop into the area of the PushBullet code # that sends remaining attachments (if more detected) attach.add(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our attachments assert obj.notify(body="test", attach=attach) is True # Test our call count assert mock_post.call_count == 1 # Reset our mock object mock_post.reset_mock() # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") attach = AppriseAttachment(path) assert obj.notify(body="test", attach=attach) is False @mock.patch("requests.post") def test_plugin_ses_session_token(mock_post): """NotifySES() session token for temporary/IAM credentials.""" response = mock.MagicMock() response.status_code = requests.codes.ok response.content = AWS_SES_GOOD_RESPONSE mock_post.return_value = response # Init with session token obj = NotifySES( access_key_id=TEST_ACCESS_KEY_ID, secret_access_key=TEST_ACCESS_KEY_SECRET, region_name=TEST_REGION, from_addr="sender@example.com", targets=["recipient@example.com"], session_token=TEST_SESSION_TOKEN, ) assert obj.aws_session_token == TEST_SESSION_TOKEN # send() must include X-Amz-Security-Token in request headers assert obj.notify(body="test") is True call_kwargs = mock_post.call_args[1] assert "X-Amz-Security-Token" in call_kwargs["headers"] assert call_kwargs["headers"]["X-Amz-Security-Token"] == TEST_SESSION_TOKEN # x-amz-security-token must appear in the Authorization SignedHeaders auth = call_kwargs["headers"]["Authorization"] assert "x-amz-security-token" in auth # URL round-trip: token appears in the password field, not ?token= url = obj.url() assert f":{TEST_SESSION_TOKEN}@" in url assert "token=" not in url results = NotifySES.parse_url(url) assert results["session_token"] == TEST_SESSION_TOKEN assert results["access_key_id"] == TEST_ACCESS_KEY_ID obj2 = NotifySES(**results) assert obj2.aws_session_token == TEST_SESSION_TOKEN assert obj2.url_identifier == obj.url_identifier # Privacy URL must mask the token value (in the password position) priv_url = obj.url(privacy=True) assert TEST_SESSION_TOKEN not in priv_url assert "token=" not in priv_url # Without a session token no X-Amz-Security-Token header is sent mock_post.reset_mock() obj_plain = NotifySES( access_key_id=TEST_ACCESS_KEY_ID, secret_access_key=TEST_ACCESS_KEY_SECRET, region_name=TEST_REGION, from_addr="sender@example.com", targets=["recipient@example.com"], ) assert obj_plain.aws_session_token is None assert obj_plain.notify(body="test") is True call_kwargs = mock_post.call_args[1] assert "X-Amz-Security-Token" not in call_kwargs["headers"] # url() without token has no token= param plain_url = obj_plain.url() assert "token=" not in plain_url def test_plugin_ses_session_token_via_kwarg(): """NotifySES() session token parsed from ?token= kwarg.""" url = ( f"ses://sender@example.com/{TEST_ACCESS_KEY_ID}" f"/{TEST_ACCESS_KEY_SECRET}/{TEST_REGION}" f"/recipient@example.com?token={TEST_SESSION_TOKEN}" ) results = NotifySES.parse_url(url) assert results["session_token"] == TEST_SESSION_TOKEN assert results["access_key_id"] == TEST_ACCESS_KEY_ID obj = NotifySES(**results) assert obj.aws_session_token == TEST_SESSION_TOKEN def test_plugin_ses_key_alias(): """NotifySES() ?key= alias for access_key_id.""" url = ( "ses://sender@example.com/" f"?key={TEST_ACCESS_KEY_ID}" f"&secret={TEST_ACCESS_KEY_SECRET}" f"®ion={TEST_REGION}" "&to=recipient@example.com" ) results = NotifySES.parse_url(url) assert results["access_key_id"] == TEST_ACCESS_KEY_ID obj = NotifySES(**results) assert obj.aws_access_key_id == TEST_ACCESS_KEY_ID # ?key= takes priority over ?access= when both appear url_both = ( "ses://sender@example.com/" f"?key={TEST_ACCESS_KEY_ID}&access=OTHERID" f"&secret={TEST_ACCESS_KEY_SECRET}" f"®ion={TEST_REGION}" "&to=recipient@example.com" ) results2 = NotifySES.parse_url(url_both) assert results2["access_key_id"] == TEST_ACCESS_KEY_ID def test_plugin_ses_session_token_via_password_field(): """NotifySES() session token parsed from URL password position.""" # Token in the password field: # ses://sender:{token}@example.com/{access_key_id}/... url = ( f"ses://sender:{TEST_SESSION_TOKEN}@example.com" f"/{TEST_ACCESS_KEY_ID}/{TEST_ACCESS_KEY_SECRET}" f"/{TEST_REGION}/recipient@example.com" ) results = NotifySES.parse_url(url) assert results["session_token"] == TEST_SESSION_TOKEN assert results["access_key_id"] == TEST_ACCESS_KEY_ID obj = NotifySES(**results) assert obj.aws_session_token == TEST_SESSION_TOKEN assert obj.from_addr == "sender@example.com" # Round-trip: url() emits password-field form, which re-parses correctly regenerated = obj.url() assert f":{TEST_SESSION_TOKEN}@" in regenerated assert "token=" not in regenerated results2 = NotifySES.parse_url(regenerated) assert results2["session_token"] == TEST_SESSION_TOKEN obj2 = NotifySES(**results2) assert obj2.aws_session_token == TEST_SESSION_TOKEN assert obj2.from_addr == obj.from_addr # ?token= takes priority over the password field when both are present url_both = ( f"ses://sender:WRONGTOKEN@example.com" f"/{TEST_ACCESS_KEY_ID}/{TEST_ACCESS_KEY_SECRET}" f"/{TEST_REGION}/recipient@example.com" f"?token={TEST_SESSION_TOKEN}" ) results3 = NotifySES.parse_url(url_both) assert results3["session_token"] == TEST_SESSION_TOKEN obj3 = NotifySES(**results3) assert obj3.aws_session_token == TEST_SESSION_TOKEN caronc-apprise-182f859/tests/test_plugin_seven.py000066400000000000000000000121411524161175200221640ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.seven import NotifySeven logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "seven://", { # No hostname/apikey specified "instance": TypeError, }, ), ( "seven://{}/15551232000".format("a" * 25), { # target phone number becomes who we text too; all is good "instance": NotifySeven, # Our expected url(privacy=True) startswith() response: "privacy_url": "seven://a...a/15551232000", }, ), ( "seven://{}/15551232000".format("a" * 25), { "instance": NotifySeven, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "seven://{}/15551232000".format("a" * 25), { "instance": NotifySeven, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "seven://{}/15551232000".format("a" * 25), { "instance": NotifySeven, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "seven://{}/?to=15551232000".format("a" * 25), { # target phone number using to= "instance": NotifySeven, # Our expected url(privacy=True) startswith() response: "privacy_url": "seven://a...a/15551232000", }, ), ( "seven://{}/15551".format("a" * 25), { # target phone number invalid "instance": NotifySeven, # Our call to notify() under the hood will fail "notify_response": False, }, ), ( "seven://{}/15551232000?from=apprise".format("3" * 14), { # valid number, utilizing the optional from= variable "instance": NotifySeven, }, ), ( "seven://{}/15551232000?source=apprise".format("3" * 14), { # valid number, utilizing the optional source= variable (same as # from) "instance": NotifySeven, }, ), ( "seven://{}/15551232000?from=apprise&flash=true".format("3" * 14), { # valid number, utilizing the optional from= variable "instance": NotifySeven, }, ), ( "seven://{}/15551232000?source=apprise&flash=true".format("3" * 14), { # valid number, utilizing the optional source= variable (same as # from) "instance": NotifySeven, }, ), ( "seven://{}/15551232000?source=AR&flash=1&label=123".format("3" * 14), { # valid number, utilizing the optional source= variable (same as # from) "instance": NotifySeven, }, ), ) def test_plugin_seven_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_seven_edge_cases(mock_post): """NotifySeven() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response source = "+1 (555) 123-3456" # No apikey specified with pytest.raises(TypeError): NotifySeven(apikey=None, source=source) caronc-apprise-182f859/tests/test_plugin_sfr.py000066400000000000000000000452761524161175200216550ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.sfr import NotifySFR logging.disable(logging.CRITICAL) SFR_GOOD_RESPONSE = json.dumps( { "success": True, "reponse": 8888888, } ) SFR_BAD_RESPONSE = json.dumps( { "success": False, "errorCode": "THIS_IS_AN_ERROR", "errorDetail": "Appel api en erreur", "fatal": True, "invalidParams": True, } ) # Our Testing URLs apprise_url_tests = ( ( "sfr://", { # No host specified "instance": TypeError, }, ), ( "sfr://:@/", { # Invalid host "instance": TypeError, }, ), ( "sfr://:service_password", { # No user specified "instance": TypeError, }, ), ( "sfr://testing:serv@ice_password", { # Invalid Password "instance": TypeError, }, ), ( "sfr://testing:service_password@/5555555555", { # No spaceId provided "instance": TypeError, }, ), ( "sfr://testing:service_password@12345/", { # No target provided "instance": TypeError, }, ), ( f"sfr://:service_password@12345/{3 * 13}", { # No host but everything else provided "instance": TypeError, }, ), ( "sfr://:service_password@space_id/targets?media=TEST", { "instance": TypeError, }, ), ( "sfr://service_id:", { "instance": TypeError, }, ), ( "sfr://service_id:@", { "instance": TypeError, }, ), ( "sfr://service_id:@{}".format("0" * 3), { "instance": TypeError, }, ), ( "sfr://service_id:@{}/".format("0" * 3), { "instance": TypeError, }, ), ( "sfr://service_id:@{}/targets".format("0" * 3), { "instance": TypeError, }, ), ( "sfr://service_id:@{}/targets?media=TEST".format("0" * 3), { "instance": TypeError, }, ), ( "sfr://service_id:service_password@{}/{}?from=MyApp&timeout=30".format( "0" * 3, "0" * 10 ), { # a valid group "instance": NotifySFR, # Our expected url(privacy=True) startswith() response: "privacy_url": ( "sfr://service_id:****@0...0/0000000000?" "from=MyApp&timeout=30&voice=claire08s&" "lang=fr_FR&media=SMSUnicode" ), # Our response expected server response "requests_response_text": SFR_GOOD_RESPONSE, }, ), ( "sfr://service_id:service_password@{}/{}?voice=laura8k&lang=en_US".format( "0" * 3, "0" * 10 ), { # a valid group "instance": NotifySFR, # Our expected url(privacy=True) startswith() response: "privacy_url": ( "sfr://service_id:****@0...0/0000000000?" "from=&timeout=2880&voice=laura8k&" "lang=en_US&media=SMSUnicode" ), # Our response expected server response "requests_response_text": SFR_GOOD_RESPONSE, }, ), ( "sfr://service_id:service_password@{}/{}?media=SMS".format( "0" * 3, "0" * 10 ), { # a valid group "instance": NotifySFR, # Our expected url(privacy=True) startswith() response: "privacy_url": ( "sfr://service_id:****@0...0/0000000000?" "from=&timeout=2880&voice=claire08s&" "lang=fr_FR&media=SMS" ), # Our response expected server response "requests_response_text": SFR_GOOD_RESPONSE, }, ), ( "sfr://service_id:service_password@{}/{}".format("0" * 3, "0" * 10), { # Test case where we get a bad response "instance": NotifySFR, # Our expected url(privacy=True) startswith() response: "privacy_url": ( "sfr://service_id:****@0...0/0000000000?" "from=&timeout=2880&voice=claire08s&" "lang=fr_FR&media=SMSUnicode" ), # Our failed notification expected server response "requests_response_text": SFR_BAD_RESPONSE, "requests_response_code": requests.codes.ok, # as a result, we expect a failed notification "response": False, }, ), ) def test_plugin_sfr_urls(): """NotifySFR() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_sfr_notification_ok(mock_post): """NotifySFR() Notifications Ok response.""" # Prepare Mock # Create a mock response object response = mock.Mock() response.status_code = requests.codes.ok response.content = SFR_GOOD_RESPONSE mock_post.return_value = response # Test our URL parsing results = NotifySFR.parse_url( "sfr://srv:pwd@{}/{}?media=SMSLong".format("1" * 8, "0" * 10) ) assert isinstance(results, dict) assert results["user"] == "srv" assert results["password"] == "pwd" assert results["space_id"] == "11111111" assert results["targets"] == ["0000000000"] assert results["media"] == "SMSLong" assert results["timeout"] == "" assert results["voice"] == "" assert results["lang"] == "" assert results["sender"] == "" instance = NotifySFR(**results) assert isinstance(instance, NotifySFR) assert len(instance) == 1 assert instance.lang == "fr_FR" assert instance.lang == "fr_FR" assert instance.sender == "" assert isinstance(instance.targets, list) assert isinstance(instance.timeout, int) assert isinstance(instance.voice, str) assert isinstance(instance.space_id, str) response = instance.send(body="test") assert response is True assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_sfr_notification_multiple_targets_ok(mock_post): """NotifySFR() Notifications ko response.""" # Reset our object mock_post.reset_mock() # Prepare Mock # Create a mock response object response = mock.Mock() response.status_code = requests.codes.ok response.content = SFR_GOOD_RESPONSE mock_post.return_value = response # Test "real" parameters results = NotifySFR.parse_url( "sfr://{}:other_fjv&8password@{}/?to={},{}&from=MyCustomUser".format( "4" * 6, "1" * 8, "6" * 10, "8" * 10 ) ) assert isinstance(results, dict) assert results["user"] == "444444" assert results["password"] == "other_fjv&8password" assert results["space_id"] == "11111111" assert results["targets"] == ["6666666666", "8888888888"] assert results["media"] == "" assert results["timeout"] == "" assert results["voice"] == "" assert results["lang"] == "" assert results["sender"] == "MyCustomUser" instance = NotifySFR(**results) assert isinstance(instance, NotifySFR) assert len(instance) == 2 assert instance.lang == "fr_FR" assert instance.sender == "MyCustomUser" assert instance.media == "SMSUnicode" assert isinstance(instance.targets, list) assert instance.timeout == 2880 assert instance.voice == "claire08s" assert isinstance(instance.space_id, str) response = instance.send(body="test") assert response is True assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_sfr_notification_ko(mock_post): """NotifySFR() Notifications ko response.""" # Reset our object mock_post.reset_mock() # Prepare Mock # Create a mock response object response = mock.Mock() response.status_code = requests.codes.ok response.content = SFR_BAD_RESPONSE mock_post.return_value = response # Test "real" parameters results = NotifySFR.parse_url( "sfr://{}:other_fjv&8password@{}/{}?timeout=30&media=SMS".format( "4" * 6, "1" * 8, "2" * 10 ) ) assert isinstance(results, dict) assert results["user"] == "444444" assert results["password"] == "other_fjv&8password" assert results["space_id"] == "11111111" assert results["media"] == "SMS" assert results["targets"] == ["2222222222"] assert results["timeout"] == "30" assert results["voice"] == "" assert results["lang"] == "" assert results["sender"] == "" instance = NotifySFR(**results) assert isinstance(instance, NotifySFR) assert len(instance) == 1 assert instance.lang == "fr_FR" assert instance.sender == "" assert instance.media == "SMS" assert isinstance(instance.targets, list) assert instance.timeout == 30 assert instance.voice == "claire08s" assert isinstance(instance.space_id, str) response = instance.send(body="test") assert response is False assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_sfr_notification_multiple_targets_all_ko(mock_post): """NotifySFR() Notifications ko response.""" # Reset our object mock_post.reset_mock() # Prepare Mock # Create a mock response object response = mock.Mock() response.status_code = requests.codes.ok response.content = SFR_BAD_RESPONSE mock_post.return_value = response # Test "real" parameters results = NotifySFR.parse_url( "sfr://{}:other_fjv&8password@{}/?to={},{}&voice=laura8k".format( "4" * 6, "1" * 8, "6" * 4, "8" * 4 ) ) assert isinstance(results, dict) assert results["user"] == "444444" assert results["password"] == "other_fjv&8password" assert results["space_id"] == "11111111" assert results["targets"] == ["6666", "8888"] assert results["voice"] == "laura8k" assert results["media"] == "" assert results["timeout"] == "" assert results["lang"] == "" assert results["sender"] == "" # No valid phone number provided with pytest.raises(TypeError): NotifySFR(**results) @mock.patch("requests.post") def test_plugin_sfr_notification_multiple_targets_one_ko(mock_post): """NotifySFR() Notifications ko response.""" # Reset our object mock_post.reset_mock() # Prepare Mock # Create a mock response object response = mock.Mock() response.status_code = requests.codes.ok response.content = SFR_BAD_RESPONSE mock_post.return_value = response # Test "real" parameters results = NotifySFR.parse_url( "sfr://{}:&pass@{}/?to={},{}&media=SMSUnicodeLong&lang=en_US".format( "4" * 6, "1" * 8, "6" * 10, "8" * 4 ) ) assert isinstance(results, dict) assert results["user"] == "444444" assert results["password"] == "&pass" assert results["space_id"] == "11111111" assert results["targets"] == ["6666666666", "8888"] assert results["voice"] == "" assert results["media"] == "SMSUnicodeLong" assert results["timeout"] == "" assert results["lang"] == "en_US" assert results["sender"] == "" instance = NotifySFR(**results) assert isinstance(instance, NotifySFR) assert len(instance) == 1 assert instance.lang == "en_US" assert instance.sender == "" assert instance.media == "SMSUnicodeLong" assert isinstance(instance.targets, list) assert instance.timeout == 2880 assert instance.voice == "claire08s" assert isinstance(instance.space_id, str) # One phone number failed to be parsed, therefore notify fails response = instance.send(body="test") assert response is False assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_sfr_notification_exceptions(mock_post): """NotifySFR() Notifications exceptions.""" mock_post.reset_mock() # Prepare Mock # Create a mock response object response = mock.Mock() response.status_code = requests.codes.internal_server_error response.content = SFR_GOOD_RESPONSE mock_post.return_value = response # Test "real" parameters results = NotifySFR.parse_url( "sfr://{}:str0*fn_ppw0rd@{}/{}".format( "404ghwo89144", "9993384", "0959290404" ) ) assert isinstance(results, dict) assert results["user"] == "404ghwo89144" assert results["password"] == "str0*fn_ppw0rd" assert results["space_id"] == "9993384" assert results["targets"] == ["0959290404"] assert results["media"] == "" assert results["timeout"] == "" assert results["lang"] == "" assert results["sender"] == "" instance = NotifySFR(**results) assert isinstance(instance, NotifySFR) assert len(instance) == 1 assert instance.lang == "fr_FR" assert instance.sender == "" assert instance.media == "SMSUnicode" assert isinstance(instance.targets, list) assert instance.timeout == 2880 assert instance.voice == "claire08s" assert isinstance(instance.space_id, str) response = instance.send(body="test") # Must return False assert response is False assert mock_post.call_count == 1 # Test invalid content returned by requests mock_post.reset_mock() response = mock.Mock() response.status_code = requests.codes.ok response.content = b"Invalid JSON Content" mock_post.return_value = response # Test "real" parameters results = NotifySFR.parse_url( "sfr://{}:str0*fn_ppw0rd@{}/{}".format( "404ghwo89144", "9993384", "0959290404" ) ) assert isinstance(results, dict) assert results["user"] == "404ghwo89144" assert results["password"] == "str0*fn_ppw0rd" assert results["space_id"] == "9993384" assert results["targets"] == ["0959290404"] assert results["media"] == "" assert results["timeout"] == "" assert results["lang"] == "" assert results["sender"] == "" instance = NotifySFR(**results) assert isinstance(instance, NotifySFR) assert len(instance) == 1 assert instance.lang == "fr_FR" assert instance.sender == "" assert instance.media == "SMSUnicode" assert isinstance(instance.targets, list) assert instance.timeout == 2880 assert instance.voice == "claire08s" assert isinstance(instance.space_id, str) response = instance.send(body="test") # Must return False assert response is False assert mock_post.call_count == 1 @mock.patch( "requests.post", side_effect=requests.RequestException("Connection error"), ) def test_plugin_sfr_notification_exceptions_requests(mock_post): """NotifySFR() Notifications requests exceptions.""" # Test requests socket error return mock_post.reset_mock() # Prepare Mock # Create a mock response object response = mock.Mock() response.status_code = requests.codes.internal_server_error response.content = b"Invalid content" mock_post.return_value = response # Test "real" parameters results = NotifySFR.parse_url( "sfr://{}:str0*fn_ppw0rd@{}/{}".format( "404ghwo89144", "9993384", "0959290404" ) ) assert isinstance(results, dict) assert results["user"] == "404ghwo89144" assert results["password"] == "str0*fn_ppw0rd" assert results["space_id"] == "9993384" assert results["targets"] == ["0959290404"] assert results["media"] == "" assert results["timeout"] == "" assert results["lang"] == "" assert results["sender"] == "" instance = NotifySFR(**results) assert isinstance(instance, NotifySFR) assert len(instance) == 1 assert instance.lang == "fr_FR" assert instance.sender == "" assert instance.media == "SMSUnicode" assert isinstance(instance.targets, list) assert instance.timeout == 2880 assert instance.voice == "claire08s" assert isinstance(instance.space_id, str) response = instance.send(body="test") # Must return False do to requests error assert response is False assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_sfr_failure(mock_post): """NotifySFR() Failure Cases.""" mock_post.reset_mock() # Prepare Mock # Create a mock response object response = mock.Mock() response.status_code = requests.codes.no_content mock_post.return_value = response # Invalid service_id with pytest.raises(TypeError): NotifySFR( user=None, password="service_password", space_id=int("8" * 10), targets=int("8" * 10), ) # Invalid service_password with pytest.raises(TypeError): NotifySFR( user="service_id", password=None, space_id=int("8" * 10), targets=int("8" * 10), ) # Invalid space_id with pytest.raises(TypeError): NotifySFR( user="service_id", password="service_password", space_id=None, targets=int("8" * 10), ) # Invalid targets with pytest.raises(TypeError): NotifySFR( user="service_id", password="service_password", space_id=int("8" * 10), targets=None, ) caronc-apprise-182f859/tests/test_plugin_signal.py000066400000000000000000000373521524161175200223340ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc from json import loads # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.config import ConfigBase from apprise.plugins.base import NotifyFormat from apprise.plugins.signal_api import NotifySignalAPI logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") @pytest.fixture def request_mock(mocker): """Prepare requests mock.""" mock_post = mocker.patch("requests.post") mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = "" return mock_post # Our Testing URLs apprise_url_tests = ( ( "signal://", { # No host specified "instance": TypeError, }, ), ( "signal://:@/", { # invalid host "instance": TypeError, }, ), ( "signal://localhost", { # Just a host provided "instance": TypeError, }, ), ( "signal://localhost", { # key and secret provided and from but invalid from no "instance": TypeError, }, ), ( "signal://localhost/123", { # invalid from phone "instance": TypeError, }, ), ( "signal://localhost/{}/123/".format("1" * 11), { # invalid 'to' phone number "instance": NotifySignalAPI, # Notify will fail because it couldn't send to anyone "response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "signal://localhost/+{}/123".format("1" * 11), }, ), ( "signal://localhost:8080/{}/".format("1" * 11), { # one phone number will notify ourselves "instance": NotifySignalAPI, }, ), ( "signal://localhost:8082/+{}/@group.abcd/".format("2" * 11), { # a valid group "instance": NotifySignalAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "signal://localhost:8082/+{}/@abcd".format( "2" * 11 ), }, ), ( "signals://localhost/{}/{}?format=markdown".format("1" * 11, "3" * 11), { # Test our markdown flag "instance": NotifySignalAPI, }, ), ( "signal://localhost:8080/+{}/group.abcd/".format("1" * 11), { # another valid group (without @ symbol) "instance": NotifySignalAPI, # Our expected url(privacy=True) startswith() response: "privacy_url": "signal://localhost:8080/+{}/@abcd".format( "1" * 11 ), }, ), ( "signal://localhost:8080/?from={}&to={},{}".format( "1" * 11, "2" * 11, "3" * 11 ), { # use get args to acomplish the same thing "instance": NotifySignalAPI, }, ), ( "signal://localhost:8080/?from={}&to={},{},{}".format( "1" * 11, "2" * 11, "3" * 11, "5" * 3 ), { # 2 good targets and one invalid one "instance": NotifySignalAPI, }, ), ( "signal://localhost:8080/{}/{}/?from={}".format( "1" * 11, "2" * 11, "3" * 11 ), { # If we have from= specified, then all elements take on the to= # value "instance": NotifySignalAPI, }, ), ( "signals://user@localhost/{}/{}".format("1" * 11, "3" * 11), { # use get args to acomplish the same thing (use source instead of # from) "instance": NotifySignalAPI, # Run through code with debug logging enabled "force_debug": True, }, ), ( "signals://user:password@localhost/{}/{}".format("1" * 11, "3" * 11), { # use get args to acomplish the same thing (use source instead of # from) "instance": NotifySignalAPI, }, ), ( "signals://user:password@localhost/{}/{}".format("1" * 11, "3" * 11), { "instance": NotifySignalAPI, # Test that a 201 response code is still accepted "requests_response_code": 201, }, ), ( "signals://localhost/{}/{}/{}?batch=True".format( "1" * 11, "3" * 11, "4" * 11 ), { # test batch mode "instance": NotifySignalAPI, }, ), ( "signals://localhost/{}/{}/{}?status=True".format( "1" * 11, "3" * 11, "4" * 11 ), { # test status switch "instance": NotifySignalAPI, }, ), ( "signal://localhost/{}/{}".format("1" * 11, "4" * 11), { "instance": NotifySignalAPI, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "signal://localhost/{}/{}".format("1" * 11, "4" * 11), { "instance": NotifySignalAPI, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_signal_urls(): """NotifySignalAPI() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_signal_edge_cases(request_mock): """NotifySignalAPI() Edge Cases.""" # Initialize some generic (but valid) tokens source = "+1 (555) 123-3456" target = "+1 (555) 987-5432" body = "test body" title = "My Title" # No apikey specified with pytest.raises(TypeError): NotifySignalAPI(source=None) aobj = Apprise() assert aobj.add(f"signals://localhost:231/{source}/{target}") assert aobj.notify(title=title, body=body) assert request_mock.call_count == 1 details = request_mock.call_args_list[0] assert details[0][0] == "https://localhost:231/v2/send" payload = loads(details[1]["data"]) assert payload["message"] == "My Title\r\ntest body" # Reset our mock object request_mock.reset_mock() aobj = Apprise() assert aobj.add( f"signals://user@localhost:231/{source}/{target}?status=True" ) assert aobj.notify(title=title, body=body) assert request_mock.call_count == 1 details = request_mock.call_args_list[0] assert details[0][0] == "https://localhost:231/v2/send" payload = loads(details[1]["data"]) # Status flag is set assert payload["message"] == "[i] My Title\r\ntest body" def test_plugin_signal_yaml_config(request_mock): """NotifySignalAPI() YAML Configuration.""" # Load our configuration result, _ = ConfigBase.config_parse_yaml( cleandoc(""" urls: - signal://signal:8080/+1234567890: - to: +0987654321 tag: signal """) ) # Verify we loaded correctly assert isinstance(result, list) assert len(result) == 1 assert len(result[0].tags) == 1 assert "signal" in result[0].tags # Let's get our plugin plugin = result[0] assert len(plugin.targets) == 1 assert plugin.source == "+1234567890" assert "+0987654321" in plugin.targets # # Test another way to get the same results # # Load our configuration result, _config = ConfigBase.config_parse_yaml( cleandoc(""" urls: - signal://signal:8080/+1234567890/+0987654321: - tag: signal """) ) # Verify we loaded correctly assert isinstance(result, list) assert len(result) == 1 assert len(result[0].tags) == 1 assert "signal" in result[0].tags # Let's get our plugin plugin = result[0] assert len(plugin.targets) == 1 assert plugin.source == "+1234567890" assert "+0987654321" in plugin.targets def test_plugin_signal_based_on_feedback(request_mock): """NotifySignalAPI() User Feedback Test.""" body = "test body" title = "My Title" aobj = Apprise() aobj.add( "signal://10.0.0.112:8080/+12512222222/+12513333333/" "12514444444?batch=yes" ) assert aobj.notify(title=title, body=body) # If a batch, there is only 1 post assert request_mock.call_count == 1 details = request_mock.call_args_list[0] assert details[0][0] == "http://10.0.0.112:8080/v2/send" payload = loads(details[1]["data"]) assert payload["message"] == "My Title\r\ntest body" assert payload["number"] == "+12512222222" assert len(payload["recipients"]) == 2 assert "+12513333333" in payload["recipients"] # The + is appended assert "+12514444444" in payload["recipients"] # Reset our test and turn batch mode off request_mock.reset_mock() aobj = Apprise() aobj.add( "signal://10.0.0.112:8080/+12512222222/+12513333333/" "12514444444?batch=no" ) assert aobj.notify(title=title, body=body) # If a batch, there is only 1 post assert request_mock.call_count == 2 details = request_mock.call_args_list[0] assert details[0][0] == "http://10.0.0.112:8080/v2/send" payload = loads(details[1]["data"]) assert payload["message"] == "My Title\r\ntest body" assert payload["number"] == "+12512222222" assert len(payload["recipients"]) == 1 assert "+12513333333" in payload["recipients"] details = request_mock.call_args_list[1] assert details[0][0] == "http://10.0.0.112:8080/v2/send" payload = loads(details[1]["data"]) assert payload["message"] == "My Title\r\ntest body" assert payload["number"] == "+12512222222" assert len(payload["recipients"]) == 1 # The + is appended assert "+12514444444" in payload["recipients"] request_mock.reset_mock() # Test group names aobj = Apprise() aobj.add( "signal://10.0.0.112:8080/+12513333333/@group1/@group2/" "12514444444?batch=yes" ) assert aobj.notify(title=title, body=body) # If a batch, there is only 1 post assert request_mock.call_count == 1 details = request_mock.call_args_list[0] assert details[0][0] == "http://10.0.0.112:8080/v2/send" payload = loads(details[1]["data"]) assert payload["message"] == "My Title\r\ntest body" assert payload["number"] == "+12513333333" assert len(payload["recipients"]) == 3 assert "+12514444444" in payload["recipients"] # our groups assert "group.group1" in payload["recipients"] assert "group.group2" in payload["recipients"] # Groups are stored properly assert "/@group1" in aobj[0].url() assert "/@group2" in aobj[0].url() # Our target phone number is also in the path assert "/+12514444444" in aobj[0].url() def test_notify_signal_plugin_attachments(request_mock): """NotifySignalAPI() Attachments.""" obj = Apprise.instantiate( "signal://10.0.0.112:8080/+12512222222/+12513333333/" "12514444444?batch=no" ) assert isinstance(obj, NotifySignalAPI) # Test Valid Attachment path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test invalid attachment path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # Test Valid Attachment (load 3) path = ( os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ) attach = AppriseAttachment(path) # Return our good configuration with mock.patch("builtins.open", side_effect=OSError()): # We can't send the message we can't open the attachment for reading assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # test the handling of our batch modes obj = Apprise.instantiate( "signal://10.0.0.112:8080/+12512222222/+12513333333/" "12514444444?batch=yes" ) assert isinstance(obj, NotifySignalAPI) # Now send an attachment normally without issues request_mock.reset_mock() assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert request_mock.call_count == 1 def test_plugin_signal_text_mode_markdown_from_url(request_mock): """NotifySignalAPI() sets text_mode=styled when ?format=markdown""" source = "+1 (555) 123-3456" target = "+1 (555) 987-5432" body = "Body **bold** _italic_" title = "Title" aobj = Apprise() # Use URL path tokens, add the markdown format via query string assert aobj.add( f"signals://localhost:231/{source}/{target}?format=markdown" ) assert aobj.notify(title=title, body=body) assert request_mock.call_count == 1 details = request_mock.call_args_list[0] payload = loads(details[1]["data"]) # Core behaviour we are validating assert payload.get("text_mode") == "styled" def test_plugin_signal_text_mode_markdown_from_library(request_mock): """NotifySignalAPI() sets text_mode=styled when class format=MARKDOWN""" source = "+1 (555) 123-3456" target = "+1 (555) 987-5432" obj = NotifySignalAPI( host="localhost", port=231, secure=True, source=source, targets=[target], format=NotifyFormat.MARKDOWN, ) assert obj.notify(title="Title", body="Body **bold** _italic_") is True assert request_mock.call_count == 1 details = request_mock.call_args_list[0] payload = loads(details[1]["data"]) # Core behaviour we are validating assert payload.get("text_mode") == "styled" caronc-apprise-182f859/tests/test_plugin_signl4.py000066400000000000000000000124751524161175200222560ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE.# BSD 2-Clause License from json import dumps # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester from apprise.plugins.signl4 import ( NotifySIGNL4, NotifyType, ) logging.disable(logging.CRITICAL) SIGNL4_GOOD_RESPONSE = dumps( { "eventId": "2516485120936941747_76d5cf30-27d2-4529-84ed-f31a8f2c72b1", } ) # Our Testing URLs apprise_url_tests = ( ( "signl4://", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "signl4://:@/", { # We failed to identify any valid authentication "instance": TypeError, }, ), ( "signl4://%20%20/", { # invalid secret specified "instance": TypeError, }, ), ( "signl4://secret/", { # No targets specified; this is allowed "instance": NotifySIGNL4, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": SIGNL4_GOOD_RESPONSE, }, ), ( "signl4://?secret=secret", { # No targets specified; this is allowed "instance": NotifySIGNL4, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": SIGNL4_GOOD_RESPONSE, }, ), ( "signl4://secret/?service=IoT", { # European Region "instance": NotifySIGNL4, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": SIGNL4_GOOD_RESPONSE, }, ), ( "signl4://secret/?filtering=yes", { # European Region "instance": NotifySIGNL4, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": SIGNL4_GOOD_RESPONSE, }, ), ( "signl4://secret/?location=40.6413111,-73.7781391", { # European Region "instance": NotifySIGNL4, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": SIGNL4_GOOD_RESPONSE, }, ), ( "signl4://secret/?alerting_scenario=singl4_ack", { # European Region "instance": NotifySIGNL4, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": SIGNL4_GOOD_RESPONSE, }, ), ( "signl4://secret/?filtering=False", { # European Region "instance": NotifySIGNL4, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": SIGNL4_GOOD_RESPONSE, }, ), ( "signl4://secret/?external_id=ar1234&status=new", { # European Region "instance": NotifySIGNL4, "notify_type": NotifyType.FAILURE, # Our response expected server response "requests_response_text": SIGNL4_GOOD_RESPONSE, }, ), ( "signl4://secret/", { "instance": NotifySIGNL4, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "signl4://secret/", { "instance": NotifySIGNL4, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_signl4_urls(): """NotifySIGNL4() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_simplepush.py000066400000000000000000000136111524161175200232400ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging import sys from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.plugins.simplepush import NotifySimplePush logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "spush://", { # No api key "instance": TypeError, }, ), ( "spush://{}".format("A" * 14), { # API Key specified however expected server response # didn't have 'OK' in JSON response "instance": NotifySimplePush, # Expected notify() response "notify_response": False, }, ), ( "spush://{}".format("Y" * 14), { # API Key valid and expected response was valid "instance": NotifySimplePush, # Set our response to OK "requests_response_text": { "status": "OK", }, # Our expected url(privacy=True) startswith() response: "privacy_url": "spush://Y...Y/", }, ), ( "spush://{}?event=Not%20So%20Good".format("X" * 14), { # API Key valid and expected response was valid "instance": NotifySimplePush, # Set our response to something that is not okay "requests_response_text": { "status": "NOT-OK", }, # Expected notify() response "notify_response": False, }, ), ( "spush://salt:pass@{}".format("X" * 14), { # Now we'll test encrypted messages with new salt "instance": NotifySimplePush, # Set our response to OK "requests_response_text": { "status": "OK", }, # Our expected url(privacy=True) startswith() response: "privacy_url": "spush://****:****@X...X/", }, ), ( "spush://{}".format("Y" * 14), { "instance": NotifySimplePush, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, # Set a failing message too "requests_response_text": { "status": "BadRequest", "message": "Title or message too long", }, }, ), ( "spush://{}".format("Z" * 14), { "instance": NotifySimplePush, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_simplepush_urls(): """NotifySimplePush() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_plugin_simpepush_cryptography_import_error(): """ NotifySimplePush() Cryptography loading failure """ # Attempt to instantiate our object obj = Apprise.instantiate("spush://{}".format("Y" * 14)) # It's not possible because our cryptography depedancy is missing assert obj is None @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_simplepush_edge_cases(): """ NotifySimplePush() Edge Cases """ # No token with pytest.raises(TypeError): NotifySimplePush(apikey=None) with pytest.raises(TypeError): NotifySimplePush(apikey=" ") # Bad event with pytest.raises(TypeError): NotifySimplePush(apikey="abc", event=object) with pytest.raises(TypeError): NotifySimplePush(apikey="abc", event=" ") @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_simplepush_general(mock_post): """NotifySimplePush() General Tests.""" # Prepare a good response response = mock.Mock() response.content = json.dumps( { "status": "OK", } ) response.status_code = requests.codes.ok mock_post.return_value = response obj = Apprise.instantiate("spush://{}".format("Y" * 14)) # Verify our content works as expected assert obj.notify(title="test", body="test") is True caronc-apprise-182f859/tests/test_plugin_sinch.py000066400000000000000000000153711524161175200221600ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.sinch import NotifySinch logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "sinch://", { # No Account SID specified "instance": TypeError, }, ), ( "sinch://:@/", { # invalid Auth token "instance": TypeError, }, ), ( "sinch://{}@12345678".format("a" * 32), { # Just spi provided "instance": TypeError, }, ), ( "sinch://{}:{}@_".format("a" * 32, "b" * 32), { # spi and token provided but invalid from "instance": TypeError, }, ), ( "sinch://{}:{}@{}".format("a" * 32, "b" * 32, "3" * 5), { # using short-code (5 characters) without a target # We can still instantiate ourselves with a valid short code "instance": NotifySinch, # Expected notify() response because we have no one to notify "notify_response": False, }, ), ( "sinch://{}:{}@{}".format("a" * 32, "b" * 32, "3" * 9), { # spi and token provided and from but invalid from no "instance": TypeError, }, ), ( "sinch://{}:{}@{}/123/{}/abcd/".format( "a" * 32, "b" * 32, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifySinch, }, ), ( "sinch://{}:{}@12345/{}".format("a" * 32, "b" * 32, "4" * 11), { # using short-code (5 characters) "instance": NotifySinch, # Our expected url(privacy=True) startswith() response: "privacy_url": "sinch://...aaaa:b...b@12345", }, ), ( "sinch://{}:{}@123456/{}".format("a" * 32, "b" * 32, "4" * 11), { # using short-code (6 characters) "instance": NotifySinch, }, ), ( "sinch://{}:{}@{}".format("a" * 32, "b" * 32, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifySinch, }, ), ( "sinch://{}:{}@{}?region=eu".format("a" * 32, "b" * 32, "5" * 11), { # Specify a region "instance": NotifySinch, }, ), ( "sinch://{}:{}@{}?region=invalid".format("a" * 32, "b" * 32, "5" * 11), { # Invalid region "instance": TypeError, }, ), ( "sinch://_?spi={}&token={}&from={}".format( "a" * 32, "b" * 32, "5" * 11 ), { # use get args to acomplish the same thing "instance": NotifySinch, }, ), ( "sinch://_?spi={}&token={}&source={}".format( "a" * 32, "b" * 32, "5" * 11 ), { # use get args to acomplish the same thing (use source instead of # from) "instance": NotifySinch, }, ), ( "sinch://_?spi={}&token={}&from={}&to={}".format( "a" * 32, "b" * 32, "5" * 11, "7" * 13 ), { # use to= "instance": NotifySinch, }, ), ( "sinch://{}:{}@{}".format("a" * 32, "b" * 32, "6" * 11), { "instance": NotifySinch, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "sinch://{}:{}@{}".format("a" * 32, "b" * 32, "6" * 11), { "instance": NotifySinch, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_sinch_urls(): """NotifyTemplate() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_sinch_edge_cases(mock_post): """NotifySinch() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens service_plan_id = "{}".format("b" * 32) api_token = "{}".format("b" * 32) source = "+1 (555) 123-3456" # No service_plan_id specified with pytest.raises(TypeError): NotifySinch(service_plan_id=None, api_token=api_token, source=source) # No api_token specified with pytest.raises(TypeError): NotifySinch( service_plan_id=service_plan_id, api_token=None, source=source ) # a error response response.status_code = 400 response.content = dumps( { "code": 21211, "message": "The 'To' number +1234567 is not a valid phone number.", } ) mock_post.return_value = response # Initialize our object obj = NotifySinch( service_plan_id=service_plan_id, api_token=api_token, source=source ) # We will fail with the above error code assert obj.notify("title", "body", "info") is False caronc-apprise-182f859/tests/test_plugin_slack.py000066400000000000000000002214641524161175200221530ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc from json import dumps, loads # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType from apprise.plugins.slack import NotifySlack, SlackMode logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "slack://", { "instance": TypeError, }, ), ( "slack://:@/", { "instance": TypeError, }, ), ( "slack://T1JJ3T3L2", { # Just Token 1 provided "instance": TypeError, }, ), ( "slack://T1JJ3T3L2/A1BRTD4JD/", { # Just 2 tokens provided "instance": TypeError, }, ), ( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/?mode=invalid", { # invalid Mode provided "instance": TypeError, }, ), ( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#hmm/#-invalid-", { # No username specified; this is still okay as we sub in # default; The one invalid channel is skipped when sending a # message "instance": NotifySlack, # There is an invalid channel that we will fail to deliver to # as a result the response type will be false "response": False, "requests_response_text": { "ok": False, "message": "Bad Channel", }, }, ), ( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#channel", { # No username specified; this is still okay as we sub in # default; The one invalid channel is skipped when sending a # message "instance": NotifySlack, # don't include an image by default "include_image": False, "requests_response_text": "ok", }, ), ( ( "slack://username@xoxe.xoxb-1234-1234-abc124/#nuxref?footer=no" "×tamp=yes" ), { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, }, ), ( ( "slack://username@xoxe.xoxp-1234-1234-abc124/#nuxref?footer=yes" "×tamp=no" ), { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, }, ), # Test using a rotating bot-token as argument ( ( "slack://?token=xoxe.xoxb-1234-1234-abc124&to=#nuxref&footer=no" "&user=test" ), { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, "privacy_url": "slack://test@x...4/nuxref/", }, ), ( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+id/@id/", { # + encoded id, # @ userid "instance": NotifySlack, "requests_response_text": "ok", }, ), ( ( "slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?to=#nuxref" ), { "instance": NotifySlack, # Our expected url(privacy=True) startswith() response: "privacy_url": "slack://username@T...2/A...D/T...Q/", "requests_response_text": "ok", }, ), ( ( "slack://username@T1JJ3T3L2/A1BRTD4JD/" "TIiajkdnlazkcOXrIdevi7FQ/#nuxref" ), { "instance": NotifySlack, "requests_response_text": "ok", }, ), # You can't send to email using webhook ( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnl/user@gmail.com", { "instance": NotifySlack, "requests_response_text": "ok", # we'll have a notify response failure in this case "notify_response": False, }, ), # Specify Token on argument string (with username) ( "slack://bot@_/#nuxref?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnadfdajkjkfl/", { "instance": NotifySlack, "requests_response_text": "ok", }, ), # Specify Token and channels on argument string (no username) ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD" "/TIiajkdnlazkcOXrIdevi7FQ/&to=#chan" ), { "instance": NotifySlack, "requests_response_text": "ok", }, ), # Test webhook that doesn't have a proper response ( ( "slack://username@T1JJ3T3L2/A1BRTD4JD/" "TIiajkdnlazkcOXrIdevi7FQ/#nuxref" ), { "instance": NotifySlack, "requests_response_text": "fail", # we'll have a notify response failure in this case "notify_response": False, }, ), # Test using a bot-token (also test footer set to no flag) ( ( "slack://username@xoxb-1234-1234-abc124/#nuxref?footer=no" "×tamp=yes" ), { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, }, ), ( ( "slack://username@xoxb-1234-1234-abc124/#nuxref?footer=yes" "×tamp=yes" ), { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, }, ), ( ( "slack://username@xoxb-1234-1234-abc124/#nuxref?footer=yes" "×tamp=no" ), { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, }, ), ( ( "slack://username@xoxb-1234-1234-abc124/#nuxref?footer=yes" "×tamp=no" ), { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, }, ), # Testing modes ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&mode=hook" ), {"instance": NotifySlack, "requests_response_text": "ok"}, ), # Forced mode on a url that does not have enough details to accommodate ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&mode=bot" ), {"instance": TypeError}, ), # Test blocks mode with timestamp variation ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&blocks=yes&footer=yes×tamp=no" ), {"instance": NotifySlack, "requests_response_text": "ok"}, ), # Test blocks mode with another timestamp ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&blocks=yes&footer=yes×tamp=yes" ), {"instance": NotifySlack, "requests_response_text": "ok"}, ), # footer being disabled means timestamp isn't shown ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&blocks=yes&footer=no×tamp=yes" ), {"instance": NotifySlack, "requests_response_text": "ok"}, ), # footer and timestamp disabled ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&blocks=yes&footer=no×tamp=no" ), {"instance": NotifySlack, "requests_response_text": "ok"}, ), ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&blocks=yes&footer=yes&image=no" ), {"instance": NotifySlack, "requests_response_text": "ok"}, ), ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&blocks=yes&format=text" ), {"instance": NotifySlack, "requests_response_text": "ok"}, ), ( ( "slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "&to=#chan&blocks=no&format=text" ), {"instance": NotifySlack, "requests_response_text": "ok"}, ), # Test using a bot-token as argument ( "slack://?token=xoxb-1234-1234-abc124&to=#nuxref&footer=no&user=test", { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, # Our expected url(privacy=True) startswith() response: "privacy_url": "slack://test@x...4/nuxref/", }, ), # We contain 1 or more invalid channels, so we'll fail on our notify call ( "slack://?token=xoxb-1234-1234-abc124&to=#nuxref,#$,#-&footer=no", { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, # We fail because of the empty channel #$ and #- "notify_response": False, }, ), ( "slack://username@xoxb-1234-1234-abc124/#nuxref", { "instance": NotifySlack, "requests_response_text": { "ok": True, "message": "", }, # we'll fail to send attachments because we had no 'file' response # in our object "response": False, }, ), ( "slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ", { # Missing a channel, falls back to webhook channel bindings "instance": NotifySlack, "requests_response_text": "ok", }, ), # Native URL Support, take the slack URL and still build from it ( "https://hooks.slack.com/services/{}/{}/{}".format( "A" * 9, "B" * 9, "c" * 24 ), { "instance": NotifySlack, "requests_response_text": "ok", "url_matches": "mode=hook", }, ), ( "https://hooks.slack-gov.com/services/{}/{}/{}".format( "A" * 9, "B" * 9, "c" * 24 ), { "instance": NotifySlack, "requests_response_text": "ok", "url_matches": "mode=gov-hook", }, ), # Native URL Support with arguments ( "https://hooks.slack.com/services/{}/{}/{}?format=text".format( "A" * 9, "B" * 9, "c" * 24 ), { "instance": NotifySlack, "requests_response_text": "ok", }, ), ( "slack://username@-INVALID-/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#cool", { # invalid 1st Token "instance": TypeError, }, ), ( "slack://username@T1JJ3T3L2/-INVALID-/TIiajkdnlazkcOXrIdevi7FQ/#great", { # invalid 2rd Token "instance": TypeError, }, ), ( "slack://username@T1JJ3T3L2/A1BRTD4JD/-INVALID-/#channel", { # invalid 3rd Token "instance": TypeError, }, ), ( "slack://l2g@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#usenet", { "instance": NotifySlack, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, "requests_response_text": "ok", }, ), ( "slack://respect@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#a", { "instance": NotifySlack, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, "requests_response_text": "ok", }, ), ( "slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b", { "instance": NotifySlack, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, "requests_response_text": "ok", }, ), ( "slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b:100", { "instance": NotifySlack, "requests_response_text": "ok", }, ), ( "slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+124:100", { "instance": NotifySlack, "requests_response_text": "ok", }, ), # test a case where we have a channel defined alone (without a thread_ts) # that exists after a definition where a thread_ts does exist. this # tests the branch of code that ensures we do not pass the same thread_ts # twice ( ( "slack://notify@T1JJ3T3L2/A1BRTD4JD/" "TIiajkdnlazkcOXrIdevi7FQ/+124:100/@chan" ), { "instance": NotifySlack, "requests_response_text": "ok", }, ), ( "slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b:bad", { "instance": NotifySlack, "requests_response_text": "ok", # we'll fail because our thread_ts is bad "response": False, }, ), # Workflow Builder webhook (4-segment /workflows/ path) ( "slack://T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY/?mode=workflow", { "instance": NotifySlack, }, ), # Workflow trigger webhook (3-segment /triggers/ path) ( "slack://T1JJ3T3L2/XXXXXXXX/YYYYYYYY/?mode=trigger", { "instance": NotifySlack, }, ), # Workflow native URL (/workflows/) ( ( "https://hooks.slack.com/workflows/" "T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY" ), { "instance": NotifySlack, "privacy_url": "slack://T...2/F...X/X...X/Y...Y/", }, ), # Workflow trigger native URL (/triggers/) ( "https://hooks.slack.com/triggers/T1JJ3T3L2/XXXXXXXX/YYYYYYYY", { "instance": NotifySlack, "privacy_url": "slack://T...2/X...X/Y...Y/", }, ), # Workflow mode -- too few segments (2 of required 4) ( "slack://T1JJ3T3L2/XXXXXXXX/?mode=workflow", { "instance": TypeError, }, ), # Workflow mode -- wrong count: trigger's 3 segments rejected ( "slack://T1JJ3T3L2/XXXXXXXX/YYYYYYYY/?mode=workflow", { "instance": TypeError, }, ), # Trigger mode -- wrong count: workflow's 4 segments rejected ( "slack://T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY/?mode=trigger", { "instance": TypeError, }, ), ) def test_plugin_slack_urls(): """NotifySlack() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.request") def test_plugin_slack_oauth_access_token(mock_request): """NotifySlack() OAuth Access Token Tests.""" # Generate an invalid bot token token = "xo-invalid" request = mock.Mock() request.content = dumps( { "ok": True, "message": "", "channel": "C123456", } ) request.status_code = requests.codes.ok # We'll fail to validate the access_token with pytest.raises(TypeError): NotifySlack(access_token=token) # Generate a (valid) bot token token = "xoxb-1234-1234-abc124" # Generate a (valid) rotating bot token rotating_token = "xoxe.xoxb-1234-1234-abc124" # Prepare Mock mock_request.return_value = request # Variation Initializations obj = NotifySlack(access_token=token, targets="#apprise") assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True # apprise room was found assert obj.send(body="test") is True # Validate rotating token is accepted too obj = NotifySlack(access_token=rotating_token, targets="#apprise") assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True assert obj.send(body="test") is True # A poorly formatted xoxe prefix should still be rejected with pytest.raises(TypeError): NotifySlack(access_token="xoxe.xo-invalid", targets="#apprise") # Test Valid Attachment mock_request.reset_mock() mock_request.side_effect = [ request, mock.Mock( **{ "content": dumps( { "ok": True, "upload_url": "https://files.slack.com/upload/v1/ABC123", "file_id": "F123ABC456", } ), "status_code": requests.codes.ok, } ), mock.Mock( **{"content": b"OK - 123", "status_code": requests.codes.ok} ), mock.Mock( **{ "content": dumps( { "ok": True, "files": [{"id": "F123ABC456", "title": "slack-test"}], } ), "status_code": requests.codes.ok, } ), ] path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_request.call_count == 4 assert mock_request.call_args_list[0][0][0] == "post" assert ( mock_request.call_args_list[0][0][1] == "https://slack.com/api/chat.postMessage" ) assert mock_request.call_args_list[1][0][0] == "get" assert ( mock_request.call_args_list[1][0][1] == "https://slack.com/api/files.getUploadURLExternal" ) assert mock_request.call_args_list[2][0][0] == "post" assert ( mock_request.call_args_list[2][0][1] == "https://files.slack.com/upload/v1/ABC123" ) assert mock_request.call_args_list[3][0][0] == "post" assert ( mock_request.call_args_list[3][0][1] == "https://slack.com/api/files.completeUploadExternal" ) # Test a valid attachment that throws an Connection Error mock_request.return_value = None mock_request.side_effect = ( request, requests.ConnectionError(0, "requests.ConnectionError() not handled"), ) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Test a valid attachment that throws an OSError mock_request.return_value = None mock_request.side_effect = (request, OSError(0, "OSError")) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Reset our mock object back to how it was mock_request.return_value = request mock_request.side_effect = None # Test invalid attachment path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # Test case where expected return attachment payload is invalid mock_request.reset_mock() mock_request.side_effect = [ request, mock.Mock( **{ "content": dumps( { "ok": False, } ), "status_code": requests.codes.internal_server_error, } ), ] path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) # We'll fail because of the bad 'file' response assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Slack requests pay close attention to the response to determine # if things go well... this is not a good JSON response: request.content = "{" mock_request.reset_mock() mock_request.return_value = request mock_request.side_effect = None # As a result, we'll fail to send our notification assert obj.send(body="test", attach=attach) is False request.content = dumps( { "ok": False, "message": "We failed", } ) # A response from Slack (even with a 200 response) still # results in a failure: assert obj.send(body="test", attach=attach) is False # Handle exceptions reading our attachment from disk (should it happen) mock_request.side_effect = OSError("Attachment Error") mock_request.return_value = None # We'll fail now because of an internal exception assert obj.send(body="test") is False @mock.patch("requests.request") def test_plugin_slack_webhook_mode(mock_request): """NotifySlack() Webhook Mode Tests.""" # Prepare Mock mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok mock_request.return_value.content = b"ok" mock_request.return_value.text = "ok" # Initialize some generic (but valid) tokens token_a = "A" * 9 token_b = "B" * 9 token_c = "c" * 24 channels = "chan1,#chan2,+BAK4K23G5,@user,,," obj = NotifySlack( token_a=token_a, token_b=token_b, token_c=token_c, targets=channels ) assert len(obj.channels) == 4 # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Missing first Token with pytest.raises(TypeError): NotifySlack( token_a=None, token_b=token_b, token_c=token_c, targets=channels ) # Test include_image obj = NotifySlack( token_a=token_a, token_b=token_b, token_c=token_c, targets=channels, include_image=True, ) # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) @mock.patch("requests.request") def test_plugin_slack_username_payload_by_mode(mock_request): """NotifySlack() Username payload behavior by mode.""" # Prepare Mock response = mock.Mock() response.status_code = requests.codes.ok response.content = b"ok" response.text = "ok" mock_request.return_value = response token_a = "A" * 9 token_b = "B" * 9 token_c = "c" * 24 # Webhook mode without botname should not force username, # allowing Slack's configured webhook identity to be used. obj = NotifySlack(token_a=token_a, token_b=token_b, token_c=token_c) assert obj.notify(body="body", title="title") is True payload = loads(mock_request.call_args.kwargs["data"]) assert "username" not in payload # Explicit botname in webhook mode should be honored. mock_request.reset_mock() obj = NotifySlack( token_a=token_a, token_b=token_b, token_c=token_c, user="CustomWebhookName", ) assert obj.notify(body="body", title="title") is True payload = loads(mock_request.call_args.kwargs["data"]) assert payload.get("username") == "CustomWebhookName" # Bot mode without botname should also omit username, allowing Slack # to use the app's configured display name. mock_request.reset_mock() response.content = dumps({"ok": True, "channel": "C123456"}) obj = NotifySlack(access_token="xoxb-1234-1234-abc124", targets="#test") assert obj.notify(body="body", title="title") is True payload = loads(mock_request.call_args.kwargs["data"]) assert "username" not in payload # WEBHOOK_GOV mode without botname should also omit username, # deferring to the government webhook's configured identity. mock_request.reset_mock() response.content = b"ok" obj = NotifySlack( token_a=token_a, token_b=token_b, token_c=token_c, mode=SlackMode.WEBHOOK_GOV, ) assert obj.notify(body="body", title="title") is True payload = loads(mock_request.call_args.kwargs["data"]) assert "username" not in payload # Webhook mode with blocks enabled and no botname should also omit # username, so Slack's webhook identity is used. mock_request.reset_mock() response.content = b"ok" obj = NotifySlack( token_a=token_a, token_b=token_b, token_c=token_c, use_blocks=True, ) assert obj.notify(body="body", title="title") is True payload = loads(mock_request.call_args.kwargs["data"]) assert "username" not in payload @mock.patch("requests.request") @mock.patch("requests.get") def test_plugin_slack_send_by_email(mock_get, mock_request): """NotifySlack() Send by Email Tests.""" # Generate a (valid) bot token token = "xoxb-1234-1234-abc124" request = mock.Mock() request.content = dumps( {"ok": True, "message": "", "user": {"id": "ABCD1234"}} ) request.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = request mock_get.return_value = request # Variation Initializations obj = NotifySlack(access_token=token, targets="user@gmail.com") assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True # No calls made yet assert mock_request.call_count == 0 assert mock_get.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # 2 calls were made, one to perform an email lookup, the second # was the notification itself assert mock_get.call_count == 1 assert mock_request.call_count == 1 assert ( mock_get.call_args_list[0][0][0] == "https://slack.com/api/users.lookupByEmail" ) assert ( mock_request.call_args_list[0][0][1] == "https://slack.com/api/chat.postMessage" ) # Reset our mock object mock_request.reset_mock() mock_get.reset_mock() # Prepare Mock mock_request.return_value = request mock_get.return_value = request # Send our notification again (cached copy of user id associated with # email is used) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert mock_get.call_count == 0 assert mock_request.call_count == 1 assert ( mock_request.call_args_list[0][0][1] == "https://slack.com/api/chat.postMessage" ) # # Now test a case where we can't look up the valid email # request.content = dumps( { "ok": False, "message": "", } ) # Reset our mock object mock_request.reset_mock() mock_get.reset_mock() # Prepare Mock mock_request.return_value = request mock_get.return_value = request # Variation Initializations obj = NotifySlack(access_token=token, targets="user@gmail.com") assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True # No calls made yet assert mock_request.call_count == 0 assert mock_get.call_count == 0 # Send our notification; it will fail because we failed to look up # the user id assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # We would have failed to look up the email, therefore we wouldn't have # even bothered to attempt to send the notification assert mock_get.call_count == 1 assert mock_request.call_count == 0 assert ( mock_get.call_args_list[0][0][0] == "https://slack.com/api/users.lookupByEmail" ) # # Now test a case where we have a poorly formatted JSON response # request.content = "}" # Reset our mock object mock_request.reset_mock() mock_get.reset_mock() # Prepare Mock mock_request.return_value = request mock_get.return_value = request # Variation Initializations obj = NotifySlack(access_token=token, targets="user@gmail.com") assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True # No calls made yet assert mock_request.call_count == 0 assert mock_get.call_count == 0 # Send our notification; it will fail because we failed to look up # the user id assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # We would have failed to look up the email, therefore we wouldn't have # even bothered to attempt to send the notification assert mock_get.call_count == 1 assert mock_request.call_count == 0 assert ( mock_get.call_args_list[0][0][0] == "https://slack.com/api/users.lookupByEmail" ) # # Now test a case where we have a poorly formatted JSON response # request.content = "}" # Reset our mock object mock_request.reset_mock() mock_get.reset_mock() # Prepare Mock mock_request.return_value = request mock_get.return_value = request # Variation Initializations obj = NotifySlack(access_token=token, targets="user@gmail.com") assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True # No calls made yet assert mock_request.call_count == 0 assert mock_get.call_count == 0 # Send our notification; it will fail because we failed to look up # the user id assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # We would have failed to look up the email, therefore we wouldn't have # even bothered to attempt to send the notification assert mock_get.call_count == 1 assert mock_request.call_count == 0 assert ( mock_get.call_args_list[0][0][0] == "https://slack.com/api/users.lookupByEmail" ) # # Now test a case where we throw an exception trying to perform the lookup # request.content = dumps( {"ok": True, "message": "", "user": {"id": "ABCD1234"}} ) # Create an unauthorized response request.status_code = requests.codes.ok # Reset our mock object mock_request.reset_mock() mock_get.reset_mock() # Prepare Mock mock_request.return_value = request mock_get.side_effect = requests.ConnectionError( 0, "requests.ConnectionError() not handled" ) # Variation Initializations obj = NotifySlack(access_token=token, targets="user@gmail.com") assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True # No calls made yet assert mock_request.call_count == 0 assert mock_get.call_count == 0 # Send our notification; it will fail because we failed to look up # the user id assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) # We would have failed to look up the email, therefore we wouldn't have # even bothered to attempt to send the notification assert mock_get.call_count == 1 assert mock_request.call_count == 0 assert ( mock_get.call_args_list[0][0][0] == "https://slack.com/api/users.lookupByEmail" ) @mock.patch("requests.request") @mock.patch("requests.get") def test_plugin_slack_markdown(mock_get, mock_request): """NotifySlack() Markdown tests.""" request = mock.Mock() request.content = b"ok" request.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = request mock_get.return_value = request # Variation Initializations aobj = Apprise() assert aobj.add( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#channel" ) body = cleandoc(""" Here is a we want to support as part of it's markdown. This one has arguments we want to preserve: . We also want to be able to support links without the description. Channel Testing User ID Testing <@U1ZQL9N3Y> <@U1ZQL9N3Y|heheh> """) # Send our notification assert aobj.notify(body=body, title="title", notify_type=NotifyType.INFO) # We would have failed to look up the email, therefore we wouldn't have # even bothered to attempt to send the notification assert mock_get.call_count == 0 assert mock_request.call_count == 1 assert ( mock_request.call_args_list[0][0][1] == "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/" "TIiajkdnlazkcOXrIdevi7FQ" ) data = loads(mock_request.call_args_list[0][1]["data"]) assert ( data["attachments"][0]["text"] == "Here is a we want to support as" " part " "of it's\nmarkdown.\n\nThis one has arguments we want to preserve:" "\n .\n" "We also want to be able to support " "links without the\ndescription." "\n\nChannel Testing\n\n\n\n" "User ID Testing\n<@U1ZQL9N3Y>\n<@U1ZQL9N3Y|heheh>" ) @mock.patch("requests.request") def test_plugin_slack_single_thread_reply(mock_request): """NotifySlack() Send Notification as a Reply.""" # Generate a (valid) bot token token = "xoxb-1234-1234-abc124" thread_id = 100 request = mock.Mock() request.content = dumps( {"ok": True, "message": "", "user": {"id": "ABCD1234"}} ) request.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = request # Variation Initializations obj = NotifySlack(access_token=token, targets=[f"#general:{thread_id}"]) assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True # No calls made yet assert mock_request.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Post was made assert mock_request.call_count == 1 assert ( mock_request.call_args_list[0][0][1] == "https://slack.com/api/chat.postMessage" ) assert loads(mock_request.call_args_list[0][1]["data"]).get( "thread_ts" ) == str(thread_id) @mock.patch("requests.request") def test_plugin_slack_multiple_thread_reply(mock_request): """NotifySlack() Send Notification to multiple channels as Reply.""" # Generate a (valid) bot token token = "xoxb-1234-1234-abc124" thread_id_1, thread_id_2 = 100, 200 request = mock.Mock() request.content = dumps( {"ok": True, "message": "", "user": {"id": "ABCD1234"}} ) request.status_code = requests.codes.ok # Prepare Mock mock_request.return_value = request # Variation Initializations obj = NotifySlack( access_token=token, targets=[f"#general:{thread_id_1}", f"#other:{thread_id_2}"], ) assert isinstance(obj, NotifySlack) is True assert isinstance(obj.url(), str) is True # No calls made yet assert mock_request.call_count == 0 # Send our notification assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Post was made assert mock_request.call_count == 2 assert ( mock_request.call_args_list[0][0][1] == "https://slack.com/api/chat.postMessage" ) assert loads(mock_request.call_args_list[0][1]["data"]).get( "thread_ts" ) == str(thread_id_1) assert loads(mock_request.call_args_list[1][1]["data"]).get( "thread_ts" ) == str(thread_id_2) @mock.patch("requests.request") def test_plugin_slack_file_upload_success(mock_request): """Test Slack BOT attachment upload success path.""" token = "xoxb-1234-1234-abc124" path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) # Simulate all successful Slack API responses mock_request.side_effect = [ mock.Mock( **{ "content": dumps( { "ok": True, "channel": "C123456", } ), "status_code": requests.codes.ok, } ), mock.Mock( **{ "content": dumps( { "ok": True, "upload_url": "https://files.slack.com/upload/v1/ABC123", "file_id": "F123ABC456", } ), "status_code": requests.codes.ok, } ), mock.Mock( **{ "content": b"OK - 123", "status_code": requests.codes.ok, } ), mock.Mock( **{ "content": dumps( { "ok": True, "files": [ {"id": "F123ABC456", "title": "apprise-test"} ], } ), "status_code": requests.codes.ok, } ), ] obj = NotifySlack(access_token=token, targets=["#general"]) assert ( obj.notify( body="Success path test", title="Slack Upload OK", notify_type=NotifyType.INFO, attach=attach, ) is True ) @mock.patch("requests.request") def test_plugin_slack_file_upload_fails_missing_files(mock_request): """Test that file upload fails when 'files' is missing or empty.""" token = "xoxb-1234-1234-abc124" path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) # Mock sequence: # 1. chat.postMessage returns valid channel # 2. files.getUploadURLExternal returns file_id and upload_url # 3. Upload returns 'OK' # 4. files.completeUploadExternal returns missing/empty 'files' mock_request.side_effect = [ mock.Mock( **{ "content": dumps( { "ok": True, "channel": "C555555", } ), "status_code": requests.codes.ok, } ), mock.Mock( **{ "content": dumps( { "ok": True, "upload_url": "https://files.slack.com/upload/v1/X99999", "file_id": "F999XYZ888", } ), "status_code": requests.codes.ok, } ), mock.Mock( **{ "content": b"OK - 2048", "status_code": requests.codes.ok, } ), # <== This response will trigger the error condition mock.Mock( **{ "content": dumps( { "ok": True, "files": [], } ), "status_code": requests.codes.ok, } ), ] obj = NotifySlack(access_token=token, targets=["#fail-channel"]) result = obj.notify( body="This should trigger a failed file upload", title="Trigger failure", notify_type=NotifyType.INFO, attach=attach, ) assert result is False @mock.patch("requests.request") def test_plugin_slack_attach_memory(mock_request): """Regression: AttachMemory must be sendable without OSError.""" from apprise.attachment.memory import AttachMemory token = "xoxb-1234-1234-abc124" mock_request.side_effect = [ mock.Mock( **{ "content": dumps({"ok": True, "channel": "C123456"}), "status_code": requests.codes.ok, } ), mock.Mock( **{ "content": dumps( { "ok": True, "upload_url": "https://files.slack.com/upload/v1/ABC123", "file_id": "F123ABC456", } ), "status_code": requests.codes.ok, } ), mock.Mock( **{ "content": b"OK - 100", "status_code": requests.codes.ok, } ), mock.Mock( **{ "content": dumps( { "ok": True, "files": [{"id": "F123ABC456", "title": "report"}], } ), "status_code": requests.codes.ok, } ), ] obj = NotifySlack(access_token=token, targets=["#general"]) mem = AttachMemory( content=b"

    Test

    ", name="report.html", mimetype="text/html", ) assert obj.notify(body="Test", attach=mem) is True assert mock_request.call_count >= 1 @mock.patch("requests.request") def test_plugin_slack_template_blocks(mock_request, tmpdir): """NotifySlack() - blocks mode with JSON template.""" # Valid webhook mock response mock_request.return_value = mock.Mock( **{ "content": b"ok", "status_code": requests.codes.ok, } ) # Write a minimal Block Kit JSON template to disk template = tmpdir.join("blocks.json") template.write( cleandoc(""" { "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "{{app_title}}" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "{{app_body}}" } } ], "color": "{{app_color}}" } """) ) # Instantiate via URL with blocks=yes and template path obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?blocks=yes&template={}&:mykey=myval".format(str(template)) ) assert isinstance(obj, NotifySlack) # Verify tokens and template were parsed correctly assert "mykey" in obj.tokens assert obj.tokens["mykey"] == "myval" assert obj.template # Notification should succeed assert ( obj.notify(body="hello", title="world", notify_type=NotifyType.INFO) is True ) assert mock_request.called is True # Inspect the posted payload posted = loads(mock_request.call_args_list[0][1]["data"]) assert "attachments" in posted assert "blocks" in posted["attachments"][0] # Header and section blocks should be present blocks = posted["attachments"][0]["blocks"] assert any(b.get("type") == "header" for b in blocks) assert any(b.get("type") == "section" for b in blocks) # Title and body substituted correctly header = next(b for b in blocks if b.get("type") == "header") assert header["text"]["text"] == "world" section = next(b for b in blocks if b.get("type") == "section") assert section["text"]["text"] == "hello" @mock.patch("requests.request") def test_plugin_slack_template_blocks_implied(mock_request, tmpdir): """NotifySlack() - template= alone implies blocks=yes.""" # Valid webhook mock response mock_request.return_value = mock.Mock( **{ "content": b"ok", "status_code": requests.codes.ok, } ) template = tmpdir.join("implied.json") template.write( cleandoc(""" { "blocks": [ { "type": "section", "text": {"type": "mrkdwn", "text": "{{app_body}}"} } ] } """) ) # No blocks=yes in the URL -- should still use the template obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) # use_blocks must have been forced on by the template assert obj.use_blocks is True assert ( obj.notify(body="implied", title="t", notify_type=NotifyType.INFO) is True ) assert mock_request.called is True posted = loads(mock_request.call_args_list[0][1]["data"]) blocks = posted["attachments"][0]["blocks"] section = next(b for b in blocks if b.get("type") == "section") assert section["text"]["text"] == "implied" @mock.patch("requests.request") def test_plugin_slack_blocks_payload_uses_username(mock_request): """NotifySlack() - blocks payload includes explicit username.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) obj = NotifySlack( token_a="T1JJ3T3L2", token_b="A1BRTD4JD", token_c="TIiajkdnlazkcOXrIdevi7FQ", use_blocks=True, user="BlockBot", ) assert obj.notify(body="hello", title="world", notify_type=NotifyType.INFO) payload = loads(mock_request.call_args.kwargs["data"]) assert payload["username"] == "BlockBot" @mock.patch("requests.request") def test_plugin_slack_template_blocks_payload_uses_username( mock_request, tmpdir ): """NotifySlack() - template blocks payload includes explicit username.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) template = tmpdir.join("username_blocks.json") template.write( cleandoc(""" { "blocks": [ { "type": "section", "text": {"type": "mrkdwn", "text": "{{app_body}}"} } ] } """) ) obj = NotifySlack( token_a="T1JJ3T3L2", token_b="A1BRTD4JD", token_c="TIiajkdnlazkcOXrIdevi7FQ", template=str(template), user="TemplateBot", ) assert obj.notify(body="hello", title="world", notify_type=NotifyType.INFO) payload = loads(mock_request.call_args.kwargs["data"]) assert payload["username"] == "TemplateBot" @mock.patch("requests.request") def test_plugin_slack_template_blocks_no_username(mock_request, tmpdir): """NotifySlack() - template blocks omit username when no botname set.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) template = tmpdir.join("no_username_blocks.json") template.write( cleandoc(""" { "blocks": [ { "type": "section", "text": {"type": "mrkdwn", "text": "{{app_body}}"} } ] } """) ) # Webhook mode with template and no botname should not force username, # allowing Slack's configured webhook identity to be used. obj = NotifySlack( token_a="T1JJ3T3L2", token_b="A1BRTD4JD", token_c="TIiajkdnlazkcOXrIdevi7FQ", template=str(template), ) assert obj.notify(body="hello", title="world", notify_type=NotifyType.INFO) payload = loads(mock_request.call_args.kwargs["data"]) assert "username" not in payload @mock.patch("requests.request") def test_plugin_slack_template_invalid_json(mock_request, tmpdir): """NotifySlack() - blocks template with invalid JSON fails gracefully.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) template = tmpdir.join("bad.json") template.write("{ not valid json }") obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?blocks=yes&template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) # Notification must fail due to bad JSON assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) # No HTTP call should have been made assert mock_request.called is False @mock.patch("requests.request") def test_plugin_slack_template_blocks_not_list(mock_request, tmpdir): """NotifySlack() - blocks template where 'blocks' is not a list fails.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) # 'blocks' present but not a list template = tmpdir.join("bad_blocks.json") template.write('{"blocks": "not-a-list"}') obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?blocks=yes&template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_request.called is False # Empty list is also rejected template.write('{"blocks": []}') obj2 = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?blocks=yes&template={}".format(str(template)) ) assert isinstance(obj2, NotifySlack) assert ( obj2.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_request.called is False @mock.patch("requests.request") def test_plugin_slack_template_block_missing_type(mock_request, tmpdir): """NotifySlack() - block missing 'type' string is rejected.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) # Block present but has no 'type' key template = tmpdir.join("no_type.json") template.write('{"blocks": [{"text": {"type": "mrkdwn", "text": "hi"}}]}') obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?blocks=yes&template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_request.called is False @mock.patch("requests.request") def test_plugin_slack_template_load_error(mock_request, tmpdir): """NotifySlack() - template OSError during read fails gracefully.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) # Write an empty file so the attachment resolves but open() can be mocked template = tmpdir.join("empty.json") template.write("") obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?blocks=yes&template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) with mock.patch("builtins.open", side_effect=OSError): # Notification must fail because the file cannot be read assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_request.called is False def test_plugin_slack_template_bad_tokens(): """NotifySlack() - invalid tokens type raises TypeError.""" with pytest.raises(TypeError): NotifySlack( token_a="T1JJ3T3L2", token_b="A1BRTD4JD", token_c="TIiajkdnlazkcOXrIdevi7FQ", tokens="not-a-dict", ) @mock.patch("requests.request") def test_plugin_slack_template_url_roundtrip(mock_request, tmpdir): """NotifySlack() - template + tokens survive url()/parse_url() round-trip.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) template = tmpdir.join("rt.json") template.write( cleandoc(""" { "blocks": [ { "type": "section", "text": {"type": "mrkdwn", "text": "{{app_body}}"} } ] } """) ) # Build an instance with template and tokens obj1 = NotifySlack( token_a="T1JJ3T3L2", token_b="A1BRTD4JD", token_c="TIiajkdnlazkcOXrIdevi7FQ", use_blocks=True, template=str(template), tokens={"key1": "val1", "key2": "val2"}, ) # Round-trip through url() -> parse_url() url = obj1.url() result = NotifySlack.parse_url(url) assert result is not None obj2 = NotifySlack(**result) assert isinstance(obj2, NotifySlack) # Connection identity must be preserved assert obj1.url_identifier == obj2.url_identifier # Tokens must survive the round-trip assert obj2.tokens.get("key1") == "val1" assert obj2.tokens.get("key2") == "val2" # Template must be present after round-trip assert obj2.template @mock.patch("requests.request") def test_plugin_slack_template_inaccessible(mock_request, tmpdir): """NotifySlack() - template attachment that cannot be accessed fails.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) # Point to a template file that does not exist missing = str(tmpdir.join("missing.json")) obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?blocks=yes&template={}".format(missing) ) assert isinstance(obj, NotifySlack) # Template attachment resolves to falsy because the file is missing assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_request.called is False def test_plugin_slack_template_add_failure(): """NotifySlack() - TypeError when AppriseAttachment.add() drops entry.""" # Simulate add() silently failing (returns 0 / len stays 0) with mock.patch("apprise.plugins.slack.AppriseAttachment") as mock_cls: inst = mock.MagicMock() inst.__len__ = mock.Mock(return_value=0) mock_cls.return_value = inst with pytest.raises(TypeError): NotifySlack( token_a="T1JJ3T3L2", token_b="A1BRTD4JD", token_c="TIiajkdnlazkcOXrIdevi7FQ", template="file:///some/template.json", ) @mock.patch("requests.request") def test_plugin_slack_template_content_not_dict(mock_request, tmpdir): """NotifySlack() - template that parses to a JSON array is rejected.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) # Valid JSON but a list rather than an object template = tmpdir.join("array.json") template.write('[{"type": "section"}]') obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_request.called is False @mock.patch("requests.request") def test_plugin_slack_template_block_not_dict(mock_request, tmpdir): """NotifySlack() - non-dict entry in blocks list is rejected.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) # blocks list contains a string rather than a dict template = tmpdir.join("bad_block.json") template.write('{"blocks": ["not-a-dict"]}') obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_request.called is False @mock.patch("requests.request") def test_plugin_slack_template_none_token_value(mock_request, tmpdir): """NotifySlack() - None token value (e.g. app_image_url) is coerced to empty string before JSON-escaping.""" mock_request.return_value = mock.Mock( **{"content": b"ok", "status_code": requests.codes.ok} ) # Template references app_image_url which will be None when # include_image=False; old code produced corrupted JSON ("ul") template = tmpdir.join("img.json") template.write( '{"blocks": [{"type": "section",' ' "text": {"type": "mrkdwn",' ' "text": "{{app_body}} img={{app_image_url}}"}}]}' ) obj = Apprise.instantiate( "slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/" "?image=no&template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) # Must succeed -- None coerced to "" keeps the JSON valid assert ( obj.notify(body="hello", title="t", notify_type=NotifyType.INFO) is True ) assert mock_request.called is True payload = loads(mock_request.call_args_list[0][1]["data"]) block_text = payload["attachments"][0]["blocks"][0]["text"]["text"] # app_image_url should expand to empty string, not "ul" assert "img=" in block_text assert "ul" not in block_text @mock.patch("requests.request") def test_plugin_slack_workflow_default_payload(mock_request): """NotifySlack() - Workflow Builder mode, default text payload.""" mock_request.return_value = mock.Mock( **{"content": b"", "status_code": requests.codes.ok} ) # 4-segment /workflows/ path via mode=workflow obj = Apprise.instantiate( "slack://T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY/?mode=workflow" ) assert isinstance(obj, NotifySlack) assert obj.mode == "workflow" assert obj.workflow_path == [ "T1JJ3T3L2", "Ft07XXXX", "XXXXXXXX", "YYYYYYYY", ] # Notification with title assert ( obj.notify(body="hello", title="Alert", notify_type=NotifyType.INFO) is True ) assert mock_request.called is True # Verify the POST URL uses the workflow base call_url = mock_request.call_args_list[0][0][1] assert "hooks.slack.com/workflows/" in call_url assert "T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY" in call_url # Verify default payload combines title and body posted = loads(mock_request.call_args_list[0][1]["data"]) assert posted["text"] == "Alert: hello" mock_request.reset_mock() # Notification without title -- body only assert ( obj.notify(body="body only", title="", notify_type=NotifyType.INFO) is True ) posted = loads(mock_request.call_args_list[0][1]["data"]) assert posted["text"] == "body only" @mock.patch("requests.request") def test_plugin_slack_workflow_trigger_mode(mock_request): """NotifySlack() - Workflow trigger mode (/triggers/ URL).""" mock_request.return_value = mock.Mock( **{"content": b"", "status_code": requests.codes.ok} ) # 3-segment /triggers/ path via mode=trigger obj = Apprise.instantiate( "slack://T1JJ3T3L2/XXXXXXXX/YYYYYYYY/?mode=trigger" ) assert isinstance(obj, NotifySlack) assert obj.mode == "trigger" assert obj.workflow_path == ["T1JJ3T3L2", "XXXXXXXX", "YYYYYYYY"] assert obj.notify(body="hi", title="", notify_type=NotifyType.INFO) is True call_url = mock_request.call_args_list[0][0][1] assert "hooks.slack.com/triggers/" in call_url assert "T1JJ3T3L2/XXXXXXXX/YYYYYYYY" in call_url @mock.patch("requests.request") def test_plugin_slack_workflow_native_url(mock_request): """NotifySlack() - parse_native_url() handles workflow/trigger URLs.""" mock_request.return_value = mock.Mock( **{"content": b"", "status_code": requests.codes.ok} ) # /workflows/ native URL obj = Apprise.instantiate( "https://hooks.slack.com/workflows" "/T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY" ) assert isinstance(obj, NotifySlack) assert obj.mode == "workflow" assert obj.workflow_path == [ "T1JJ3T3L2", "Ft07XXXX", "XXXXXXXX", "YYYYYYYY", ] assert ( obj.notify(body="native", title="", notify_type=NotifyType.INFO) is True ) call_url = mock_request.call_args_list[0][0][1] assert "hooks.slack.com/workflows/T1JJ3T3L2" in call_url mock_request.reset_mock() # /triggers/ native URL obj2 = Apprise.instantiate( "https://hooks.slack.com/triggers/T1JJ3T3L2/XXXXXXXX/YYYYYYYY" ) assert isinstance(obj2, NotifySlack) assert obj2.mode == "trigger" assert ( obj2.notify(body="trigger", title="", notify_type=NotifyType.INFO) is True ) call_url2 = mock_request.call_args_list[0][0][1] assert "hooks.slack.com/triggers/T1JJ3T3L2" in call_url2 def test_plugin_slack_parse_native_url_invalid(): """NotifySlack() - parse_native_url() returns None for non-Slack URLs.""" assert ( NotifySlack.parse_native_url( "https://hooks.slack.com/not-a-supported-path/T1JJ3T3L2" ) is None ) @mock.patch("requests.request") def test_plugin_slack_workflow_url_roundtrip(mock_request): """NotifySlack() - workflow url()/parse_url() round-trip.""" mock_request.return_value = mock.Mock( **{"content": b"", "status_code": requests.codes.ok} ) original = "slack://T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY/?mode=workflow" obj = Apprise.instantiate(original) assert isinstance(obj, NotifySlack) # Reconstruct from url() output and re-instantiate rebuilt = Apprise.instantiate(obj.url()) assert isinstance(rebuilt, NotifySlack) assert rebuilt.mode == obj.mode assert rebuilt.workflow_path == obj.workflow_path def test_plugin_slack_workflow_invalid_path(): """NotifySlack() - invalid path segment counts raise TypeError.""" from apprise.plugins.slack import NotifySlack as _NS # Too few segments for explicit workflow mode (needs 4) with pytest.raises(TypeError): _NS(workflow_path=["T1JJ3T3L2", "XXXXXXXX"], mode="workflow") # Trigger's 3 segments rejected when mode=workflow is explicit with pytest.raises(TypeError): _NS( workflow_path=["T1JJ3T3L2", "XXXXXXXX", "YYYYYYYY"], mode="workflow", ) # Workflow's 4 segments rejected when mode=trigger is explicit with pytest.raises(TypeError): _NS( workflow_path=["T1JJ3T3L2", "Ft07XXXX", "XXXXXXXX", "YYYY"], mode="trigger", ) # Empty path -- must raise (auto-detect path, 0 segments) with pytest.raises(TypeError): _NS(workflow_path=[], mode="workflow") # Non-string / non-list type (else branch) -- 0 segments, must raise with pytest.raises(TypeError): _NS(workflow_path=42, mode="workflow") # Auto-detect: 4 segments -> WORKFLOW obj = _NS(workflow_path=["T1JJ3T3L2", "Ft07XXXX", "XXXXXXXX", "YYY"]) assert obj.mode == "workflow" assert obj.workflow_path == ["T1JJ3T3L2", "Ft07XXXX", "XXXXXXXX", "YYY"] # Auto-detect: 3 segments -> WORKFLOW_TRIGGER obj = _NS(workflow_path=["T1JJ3T3L2", "XXXXXXXX", "YYYYYYYY"]) assert obj.mode == "trigger" assert obj.workflow_path == ["T1JJ3T3L2", "XXXXXXXX", "YYYYYYYY"] # Auto-detect: 2 segments -> TypeError (neither 3 nor 4) with pytest.raises(TypeError): _NS(workflow_path=["T1JJ3T3L2", "XXXXXXXX"]) @mock.patch("requests.request") def test_plugin_slack_workflow_template(mock_request, tmpdir): """NotifySlack() - Workflow mode with Block Kit JSON template.""" mock_request.return_value = mock.Mock( **{"content": b"", "status_code": requests.codes.ok} ) template = tmpdir.join("wf_blocks.json") template.write( cleandoc(""" { "blocks": [ { "type": "section", "text": {"type": "mrkdwn", "text": "{{app_body}}"} } ] } """) ) obj = Apprise.instantiate( "slack://T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY/" "?mode=workflow&template={}".format(str(template)) ) assert isinstance(obj, NotifySlack) assert obj.mode == "workflow" assert ( obj.notify(body="wf-tmpl", title="", notify_type=NotifyType.INFO) is True ) assert mock_request.called is True call_url = mock_request.call_args_list[0][0][1] assert "hooks.slack.com/workflows/" in call_url posted = loads(mock_request.call_args_list[0][1]["data"]) blocks = posted["blocks"] section = next(b for b in blocks if b.get("type") == "section") assert section["text"]["text"] == "wf-tmpl" # Invalid JSON template -- gen_payload() fails, send() returns False bad_template = tmpdir.join("bad.json") bad_template.write("not-json!") obj2 = Apprise.instantiate( "slack://T1JJ3T3L2/Ft07XXXX/XXXXXXXX/YYYYYYYY/" "?mode=workflow&template={}".format(str(bad_template)) ) assert isinstance(obj2, NotifySlack) assert ( obj2.notify(body="x", title="", notify_type=NotifyType.INFO) is False ) assert mock_request.call_count == 1 # no new request made @mock.patch("requests.request") def test_plugin_slack_html_to_markdown_format(mock_request): """NotifySlack(): HTML body is converted to Markdown.""" # Use the classic incoming webhook token format slack_token = "T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ" # Slack's _send() uses requests.request(), not requests.post(); # webhook mode confirms success via content == b"ok" mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok mock_request.return_value.content = b"ok" mock_request.return_value.text = "ok" # Instantiate a Slack plugin (notify_format is MARKDOWN by default) aobj = Apprise() assert aobj.add("slack://{}/#general".format(slack_token)) # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Slack assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_request.call_count == 1 # The body must arrive as Markdown in the legacy attachment text, not as # stripped plain text. payload = loads(mock_request.call_args_list[0][1]["data"]) assert payload["attachments"][0]["text"] == "*hello* _world_" mock_request.reset_mock() # Convert CommonMark links to Slack's ```` syntax. assert ( aobj.notify( body='click here', body_format=NotifyFormat.HTML, ) is True ) payload = loads(mock_request.call_args_list[0][1]["data"]) assert ( payload["attachments"][0]["text"] == "" ) mock_request.reset_mock() # '&'/'<'/'>' need Slack's HTML-entity escaping, not CommonMark's backslash # escaping (which Slack doesn't support for these and would otherwise. assert ( aobj.notify( body="

    2 < 3 & 4

    ", body_format=NotifyFormat.HTML, ) is True ) payload = loads(mock_request.call_args_list[0][1]["data"]) assert payload["attachments"][0]["text"] == "2 < 3 & 4" mock_request.reset_mock() # Direct Slack mrkdwn input remains unchanged. assert aobj.notify(body="**already** slack-bound markdown #tag") is True payload = loads(mock_request.call_args_list[0][1]["data"]) assert ( payload["attachments"][0]["text"] == "**already** slack-bound markdown #tag" ) @mock.patch("requests.request") def test_plugin_slack_html_to_markdown_hardening(mock_request): """Test edge cases in the CommonMark-to-Slack dialect adaptation.""" slack_token = "T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ" mock_request.return_value = requests.Request() mock_request.return_value.status_code = requests.codes.ok mock_request.return_value.content = b"ok" mock_request.return_value.text = "ok" def notify(body): aobj = Apprise() assert aobj.add("slack://{}/#general".format(slack_token)) assert aobj.notify(body=body, body_format=NotifyFormat.HTML) is True payload = loads(mock_request.call_args_list[-1][1]["data"]) mock_request.reset_mock() return payload["attachments"][0]["text"] # /
     content is buffered raw by html_to_markdown.
        assert notify("a < b & c") == "`a < b & c`"
    
        # Immediately-adjacent nested emphasis (no text between the outer and inner
        # tag's open) must stay correctly nested ("*_x_*"), not cross ("*_x*_").
        assert notify("x") == "*_x_*"
    
        # Bold/italic text nested inside a link is preserved in Slack's own
        # "" form.
        assert (
            notify('click')
            == ""
        )
    
        # Sibling (non-nested) bold spans stay separate, not merged.
        assert notify("AB") == "*A**B*"
    
        # Entity-escape Slack control characters in link destinations.
        assert (
            notify('click')
            == ""
        )
        assert (
            notify('click')
            == ""
        )
    
        # '|' has no entity form and is what separates the URL from its label in
        # Slack's own syntax.
        assert (
            notify('click')
            == ""
        )
    
        # A literal "\x02\x02"-shaped sequence in ordinary text must pass
        # through completely unaltered.
        assert notify("literal \x020\x02 text, no code or links at all") == (
            "literal \x020\x02 text, no code or links at all"
        )
    
        # An unmatched (unbalanced) bare backtick run.
        assert NotifySlack._commonmark_to_slack("text ``unterminated") == (
            "text ``unterminated"
        )
    
        # An empty entity collapse mid-string (not just a final one at the end of
        # the scan).
        assert NotifySlack._commonmark_to_slack("****x") == "x"
    
        # overflow=split can hand this method just one chunk of a longer body, with
        # a span that doesn't open or close until a different chunk entirely.
        aobj = Apprise()
        assert aobj.add("slack://{}/#general?overflow=split".format(slack_token))
        assert (
            aobj.notify(
                body="" + ("x" * 39990) + "",
                body_format=NotifyFormat.HTML,
            )
            is True
        )
        assert mock_request.call_count == 2
        texts = [
            loads(c[1]["data"])["attachments"][0]["text"]
            for c in mock_request.call_args_list
        ]
        for text in texts:
            assert text.count("*") % 2 == 0
            assert text.count("_") % 2 == 0
    
        # Every backslash-escape branch of the main scan: '>' becomes an HTML
        # entity, '(', ')', '[', ']', '!', '#' have their backslash dropped (none
        assert (
            NotifySlack._commonmark_to_slack(
                "a\\>b\\(c\\)d\\[e\\]f\\!g\\#h\\*i\\_j\\~k\\\\l"
            )
            == "a>b(c)d[e]f!g#h\\*i\\_j\\~k\\\\l"
        )
    
        # A '[' with no matching "]()" close at all by the time the scan ends
        # (e.g.
        assert (
            NotifySlack._commonmark_to_slack("[text](
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    from contextlib import suppress
    import logging
    import sys
    from unittest import mock
    
    from helpers import AppriseURLTester
    import pytest
    
    from apprise import Apprise, NotifyType
    from apprise.plugins.smpp import NotifySMPP
    
    with suppress(ImportError):
        import smpplib
    
    logging.disable(logging.CRITICAL)
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "smpp://",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp:///",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp://@/",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp://user@/",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp://user:pass/",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp://user:pass@/",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp://user@hostname",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp://user:pass@host:/",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp://user:pass@host:2775/",
            {
                "instance": TypeError,
            },
        ),
        (
            "smpp://user:pass@host:2775/{}/{}".format("1" * 10, "a" * 32),
            {
                # valid everything but target numbers
                "instance": NotifySMPP,
                # We have no one to notify
                "notify_response": False,
            },
        ),
        (
            "smpp://user:pass@host:2775/{}".format("1" * 10),
            {
                # everything valid
                "instance": NotifySMPP,
                # We have no one to notify
                "notify_response": False,
            },
        ),
        (
            "smpp://user:pass@host/{}/{}".format("1" * 10, "1" * 10),
            {
                "instance": NotifySMPP,
            },
        ),
        (
            "smpps://_?&from={}&to={},{}&user=user&password=pw".format(
                "1" * 10, "1" * 10, "1" * 10
            ),
            {
                # use get args to accomplish the same thing
                "instance": NotifySMPP,
            },
        ),
    )
    
    
    @pytest.mark.skipif(
        "smpplib" in sys.modules, reason="Requires that smpplib NOT be installed"
    )
    def test_plugin_smpplib_import_error():
        """NotifySMPP() smpplib loading failure."""
    
        # Attempt to instantiate our object
        obj = Apprise.instantiate(
            "smpp://user:pass@host/{}/{}".format("1" * 10, "1" * 10)
        )
    
        # It's not possible because our cryptography depedancy is missing
        assert obj is None
    
    
    @pytest.mark.skipif("smpplib" not in sys.modules, reason="Requires smpplib")
    def test_plugin_smpp_urls():
        """NotifySMPP() Apprise URLs."""
        # mock nested inside of outside function to avoid failing
        # when smpplib is unavailable
        with mock.patch("smpplib.client.Client") as mock_client_class:
            mock_client_instance = mock.Mock()
            mock_client_class.return_value = mock_client_instance
    
            # Raise exception on connect
            mock_client_instance.connect.return_value = True
            mock_client_instance.bind_transmitter.return_value = True
            mock_client_instance.send_message.return_value = True
    
            # Run our general tests
            AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    @pytest.mark.skipif("smpplib" not in sys.modules, reason="Requires smpplib")
    def test_plugin_smpp_edge_case():
        """NotifySMPP() Apprise Edge Case."""
    
        # mock nested inside of outside function to avoid failing
        # when smpplib is unavailable
        with mock.patch("smpplib.client.Client") as mock_client_class:
            mock_client_instance = mock.Mock()
            mock_client_class.return_value = mock_client_instance
    
            # Raise exception on connect
            mock_client_instance.connect.side_effect = (
                smpplib.exceptions.ConnectionError
            )
            mock_client_instance.bind_transmitter.return_value = True
            mock_client_instance.send_message.return_value = True
    
            # Instantiate our object
            obj = Apprise.instantiate(
                "smpp://user:pass@host/{}/{}".format("1" * 10, "1" * 10)
            )
    
            # Well fail to establish a connection
            assert (
                obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
                is False
            )
    
            # Raise exception on connect
            mock_client_instance.connect.side_effect = None
            mock_client_instance.bind_transmitter.return_value = True
            mock_client_instance.send_message.side_effect = (
                smpplib.exceptions.ConnectionError
            )
    
            # Well fail to deliver our message
            assert (
                obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
                is False
            )
    caronc-apprise-182f859/tests/test_plugin_sms_manager.py000066400000000000000000000151201524161175200233400ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    from unittest import mock
    
    from helpers import AppriseURLTester
    import requests
    
    from apprise import Apprise, NotifyType
    from apprise.plugins.smsmanager import NotifySMSManager
    
    logging.disable(logging.CRITICAL)
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "smsmgr://",
            {
                # Instantiated but no auth, so no otification can happen
                "instance": TypeError,
            },
        ),
        (
            "smsmgr://:@/",
            {
                # invalid auth
                "instance": TypeError
            },
        ),
        (
            "smsmgr://{}@{}".format("b" * 10, "3" * 5),
            {
                # invalid nubmer provided
                "instance": NotifySMSManager,
                # Expected notify() response because we have no one to notify
                "notify_response": False,
            },
        ),
        (
            "smsmgr://{}@123/{}/abcd/+{}".format("z" * 10, "3" * 11, "4" * 11),
            {
                # includes a few invalid bits of info
                "instance": NotifySMSManager,
                "privacy_url": "smsmgr://z...z@33333333333/+44444444444",
            },
        ),
        (
            "smsmgr://{}@{}?batch=y".format("b" * 5, "4" * 11),
            {
                "instance": NotifySMSManager,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smsmgr://b...b@44444444444",
            },
        ),
        # Test gateway group
        (
            "smsmgr://{}@{}?gateway=low".format("a" * 10, "1" * 11),
            {
                "instance": NotifySMSManager,
            },
        ),
        (
            "smsmgr://{}@{}?gateway=invalid".format("a" * 10, "1" * 11),
            {
                # invalid gatewwway
                "instance": TypeError,
            },
        ),
        (
            "smsmgr://{}?key={}&from=user".format("1" * 11, "a" * 10),
            {
                # use get args to acomplish the same thing
                "instance": NotifySMSManager,
            },
        ),
        (
            "smsmgr://_?to={},{}&key={}&sender={}".format(
                "1" * 11, "2" * 11, "b" * 10, "5" * 13
            ),
            {
                # use get args to acomplish the same thing
                "instance": NotifySMSManager,
            },
        ),
        (
            "smsmgr://{}@{}".format("a" * 10, "1" * 11),
            {
                "instance": NotifySMSManager,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "smsmgr://{}@{}".format("a" * 10, "1" * 11),
            {
                "instance": NotifySMSManager,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_smsmgr_urls():
        """NotifyTemplate() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    @mock.patch("requests.get")
    def test_plugin_smsmgr_edge_cases(mock_get):
        """NotifySMSManager() Edge Cases."""
    
        # Initialize some generic (but valid) tokens
        apikey = "my-api-key"
        targets = [
            "+1(555) 123-1234",
            "1555 5555555",
            # A garbage entry
            "12",
            # NOw a valid one because a group was implicit
            "@12",
        ]
    
        # Prepare our response
        response = requests.Request()
        response.status_code = requests.codes.ok
    
        # Prepare Mock
        mock_get.return_value = response
    
        # Instantiate our object
        obj = Apprise.instantiate(
            "smsmgr://{}@{}?batch=n".format(apikey, "/".join(targets))
        )
    
        assert (
            obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
            is True
        )
    
        # We know there are 2 (valid) targets
        assert len(obj) == 2
    
        # Test our call count
        assert mock_get.call_count == 2
    
        # Test
        details = mock_get.call_args_list[0]
        payload = details[1]["params"]
        assert payload["apikey"] == apikey
        assert payload["gateway"] == "high"
        assert payload["number"] == "+15551231234"
        assert payload["message"] == "title\r\nbody"
    
        details = mock_get.call_args_list[1]
        payload = details[1]["params"]
        assert payload["apikey"] == apikey
        assert payload["gateway"] == "high"
        assert payload["number"] == "15555555555"
        assert payload["message"] == "title\r\nbody"
    
        # Verify our URL looks good
        assert obj.url().startswith(
            "smsmgr://{}@{}".format(
                apikey, "/".join(["+15551231234", "15555555555"])
            )
        )
    
        assert "batch=no" in obj.url()
    
        # Reset our mock object
        mock_get.reset_mock()
    
        # With our batch in place, our calculations are different
        obj = Apprise.instantiate(
            "smsmgr://{}@{}?batch=y".format(apikey, "/".join(targets))
        )
    
        # 2 phones were loaded but counted as 1 due to batch flag
        assert len(obj) == 1
    
        assert (
            obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
            is True
        )
    
        # Test our call count (batched into 1)
        assert mock_get.call_count == 1
    
        details = mock_get.call_args_list[0]
        payload = details[1]["params"]
        assert payload["apikey"] == apikey
        assert payload["gateway"] == "high"
        assert payload["number"] == "+15551231234;15555555555"
        assert payload["message"] == "title\r\nbody"
    caronc-apprise-182f859/tests/test_plugin_smsc.py000066400000000000000000000373601524161175200220230ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import json
    import logging
    import os
    from unittest import mock
    from urllib.parse import urlparse
    
    from helpers import AppriseURLTester
    import pytest
    import requests
    
    from apprise import Apprise, AppriseAttachment, NotifyType
    from apprise.plugins.smsc import NotifySMSC
    
    logging.disable(logging.CRITICAL)
    
    # Attachment test directory
    TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var")
    
    # Valid login/password
    LOGIN = "mylogin"
    PASSWORD = "mypassword"
    # A valid E.164 phone number (without + prefix in stored form)
    PHONE = "71234567890"
    PHONE2 = "79876543210"
    
    
    # Our Testing URLs
    apprise_url_tests = (
        # Missing everything
        (
            "smsc://",
            {"instance": TypeError},
        ),
        # Missing password
        (
            "smsc://login@+71234567890",
            {"instance": TypeError},
        ),
        # Missing login
        (
            "smsc://:password@+71234567890",
            {"instance": TypeError},
        ),
        # Valid single target
        (
            "smsc://login:password@+71234567890",
            {
                "instance": NotifySMSC,
                "privacy_url": "smsc://l...n:****@71234567890/",
            },
        ),
        # Valid two targets in path
        (
            "smsc://login:password@+71234567890/+79876543210",
            {
                "instance": NotifySMSC,
                "privacy_url": "smsc://l...n:****@71234567890/79876543210/",
            },
        ),
        # With sender ID
        (
            "smsc://login:password@+71234567890?sender=MySender",
            {"instance": NotifySMSC},
        ),
        # With translit enabled
        (
            "smsc://login:password@+71234567890?translit=yes",
            {"instance": NotifySMSC},
        ),
        # ?to= alias for additional targets
        (
            "smsc://login:password@+71234567890?to=+79876543210",
            {"instance": NotifySMSC},
        ),
        # Invalid phone number (only one bad; loads with warning but works)
        (
            "smsc://login:password@+71234567890/notaphone",
            {
                "instance": NotifySMSC,
                "privacy_url": "smsc://l...n:****@71234567890/",
            },
        ),
        # Only invalid phone numbers -- loads but notify returns False
        (
            "smsc://login:password@notaphone",
            {
                "instance": NotifySMSC,
                "notify_response": False,
            },
        ),
        # HTTP 500 response
        (
            "smsc://login:password@+71234567890",
            {
                "instance": NotifySMSC,
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        # Unknown HTTP status
        (
            "smsc://login:password@+71234567890",
            {
                "instance": NotifySMSC,
                "response": False,
                "requests_response_code": 999,
            },
        ),
        # RequestException
        (
            "smsc://login:password@+71234567890",
            {
                "instance": NotifySMSC,
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_smsc_urls():
        """NotifySMSC() Apprise URLs."""
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_init(mock_post):
        """NotifySMSC() initialization and validation."""
    
        # Missing login raises TypeError
        with pytest.raises(TypeError):
            NotifySMSC(targets=["+71234567890"])
    
        # Missing password raises TypeError
        with pytest.raises(TypeError):
            NotifySMSC(user=LOGIN, targets=["+71234567890"])
    
        # Whitespace-only sender is invalid (validate_regex strips to empty)
        with pytest.raises(TypeError):
            NotifySMSC(
                user=LOGIN,
                password=PASSWORD,
                targets=["+71234567890"],
                sender="   ",
            )
    
        # Valid instance, default settings
        obj = NotifySMSC(
            user=LOGIN,
            password=PASSWORD,
            targets=["+71234567890"],
        )
        assert isinstance(obj, NotifySMSC)
        assert obj.sender is None
        assert obj.translit is False
        assert len(obj) == 1
    
        # Valid instance with sender and translit
        obj = NotifySMSC(
            user=LOGIN,
            password=PASSWORD,
            targets=["+71234567890"],
            sender="MyBiz",
            translit=True,
        )
        assert obj.sender == "MyBiz"
        assert obj.translit is True
    
        # No targets - __len__ returns 1 (not 0) per convention
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=[])
        assert len(obj) == 1
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_url(mock_post):
        """NotifySMSC() URL building and round-trip."""
    
        obj = NotifySMSC(
            user=LOGIN,
            password=PASSWORD,
            targets=["+71234567890", "+79876543210"],
            sender="Apprise",
            translit=True,
        )
    
        # url() must contain all relevant parts
        generated = obj.url()
        assert "smsc://" in generated
        assert "71234567890" in generated
        assert "79876543210" in generated
        assert "sender=Apprise" in generated
        assert "translit=yes" in generated
    
        # privacy URL must mask the password and redact the user
        priv = obj.url(privacy=True)
        assert PASSWORD not in priv
        assert "m...n" in priv
    
        # url_identifier uniqueness
        obj2 = NotifySMSC(
            user=LOGIN,
            password=PASSWORD,
            targets=["+71234567890"],
        )
        assert obj.url_identifier == obj2.url_identifier
    
        # Round-trip via parse_url
        result = NotifySMSC.parse_url(obj.url())
        assert result is not None
        obj3 = NotifySMSC(**result)
        assert obj3.url_identifier == obj.url_identifier
        assert len(obj3.targets) == len(obj.targets)
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_send_sms(mock_post):
        """NotifySMSC() SMS send - success and failure paths."""
    
        def _mk_resp(data, code=requests.codes.ok):
            r = mock.Mock()
            r.status_code = code
            r.content = json.dumps(data).encode()
            return r
    
        # Happy path
        mock_post.return_value = _mk_resp({"id": "1234", "cnt": 1})
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
        assert obj.notify(body="Hello") is True
        assert mock_post.call_count == 1
    
        # Verify POST went to the SMSC endpoint
        call_url = mock_post.call_args[0][0]
        assert urlparse(call_url).hostname == "smsc.ru"
    
        # Verify payload fields
        payload = mock_post.call_args[1]["data"]
        assert payload["login"] == LOGIN
        assert payload["psw"] == PASSWORD
        assert "71234567890" in payload["phones"]
        assert payload["mes"] == "Hello"
    
        mock_post.reset_mock()
    
        # API-level error in JSON response
        mock_post.return_value = _mk_resp(
            {"error": "Invalid login", "error_code": 2}
        )
        assert obj.notify(body="Hello") is False
    
        mock_post.reset_mock()
    
        # HTTP error status
        mock_post.return_value = _mk_resp({}, code=requests.codes.bad_request)
        assert obj.notify(body="Hello") is False
    
        mock_post.reset_mock()
    
        # Unknown HTTP status
        mock_post.return_value = _mk_resp({}, code=999)
        assert obj.notify(body="Hello") is False
    
        mock_post.reset_mock()
    
        # RequestException
        mock_post.side_effect = requests.RequestException("bang")
        assert obj.notify(body="Hello") is False
    
        mock_post.reset_mock()
        mock_post.side_effect = None
    
        # Non-JSON response body (loads raises ValueError) -- still succeeds
        # because HTTP 200 is authoritative; the loads exception handler fires
        r = mock.Mock()
        r.status_code = requests.codes.ok
        r.content = b"OK"
        mock_post.return_value = r
        assert obj.notify(body="Hello") is True
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_no_targets(mock_post):
        """NotifySMSC() returns False when no valid targets present."""
    
        obj = NotifySMSC(
            user=LOGIN,
            password=PASSWORD,
            targets=["notaphone"],
        )
        # notify() must return False without calling the API
        assert obj.notify(body="Hello") is False
        assert mock_post.call_count == 0
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_sender_translit(mock_post):
        """NotifySMSC() sender and translit params appear in the request."""
    
        def _mk_resp(data, code=requests.codes.ok):
            r = mock.Mock()
            r.status_code = code
            r.content = json.dumps(data).encode()
            return r
    
        mock_post.return_value = _mk_resp({"id": "99", "cnt": 1})
    
        obj = NotifySMSC(
            user=LOGIN,
            password=PASSWORD,
            targets=["+71234567890"],
            sender="MyBiz",
            translit=True,
        )
        assert obj.notify(body="ΠŸΡ€ΠΈΠ²Π΅Ρ‚") is True
    
        payload = mock_post.call_args[1]["data"]
        assert payload.get("sender") == "MyBiz"
        assert payload.get("translit") == 1
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_send_mms_success(mock_post):
        """NotifySMSC() MMS send succeeds with valid attachment."""
    
        def _mk_resp(data, code=requests.codes.ok):
            r = mock.Mock()
            r.status_code = code
            r.content = json.dumps(data).encode()
            return r
    
        mock_post.return_value = _mk_resp({"id": "5678", "cnt": 1})
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
    
        attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png"))
        assert obj.notify(body="Image", attach=attach) is True
        assert mock_post.call_count == 1
    
        # Confirm MMS flag is set in the payload
        payload = mock_post.call_args[1]["data"]
        assert payload.get("mms") == 1
    
        # Confirm a file was included in the files parameter
        files_arg = mock_post.call_args[1]["files"]
        assert len(files_arg) == 1
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_mms_multi_attach(mock_post):
        """NotifySMSC() MMS send with multiple attachments."""
    
        def _mk_resp(data, code=requests.codes.ok):
            r = mock.Mock()
            r.status_code = code
            r.content = json.dumps(data).encode()
            return r
    
        mock_post.return_value = _mk_resp({"id": "9999", "cnt": 1})
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
    
        attach = AppriseAttachment(
            [
                os.path.join(TEST_VAR_DIR, "apprise-test.png"),
                os.path.join(TEST_VAR_DIR, "apprise-test.gif"),
            ]
        )
        assert obj.notify(body="Two files", attach=attach) is True
    
        files_arg = mock_post.call_args[1]["files"]
        assert len(files_arg) == 2
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_mms_inaccessible(mock_post):
        """NotifySMSC() MMS fails when attachment file is inaccessible."""
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
    
        attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png"))
    
        with mock.patch("os.path.isfile", return_value=False):
            assert obj.notify(body="Image", attach=attach) is False
    
        # The HTTP client must not have been called
        assert mock_post.call_count == 0
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_mms_oserror(mock_post):
        """NotifySMSC() MMS fails cleanly on OSError during open."""
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
    
        attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png"))
    
        with mock.patch("builtins.open", side_effect=OSError("disk error")):
            assert obj.notify(body="Image", attach=attach) is False
    
        assert mock_post.call_count == 0
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_mms_http_error(mock_post):
        """NotifySMSC() MMS returns False on HTTP error response."""
    
        r = mock.Mock()
        r.status_code = requests.codes.internal_server_error
        r.content = b""
        mock_post.return_value = r
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
    
        attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png"))
        assert obj.notify(body="Image", attach=attach) is False
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_mms_api_error(mock_post):
        """NotifySMSC() MMS returns False on API-level error."""
    
        r = mock.Mock()
        r.status_code = requests.codes.ok
        r.content = json.dumps(
            {"error": "Insufficient funds", "error_code": 3}
        ).encode()
        mock_post.return_value = r
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
    
        attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png"))
        assert obj.notify(body="Image", attach=attach) is False
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_mms_request_exception(mock_post):
        """NotifySMSC() MMS returns False on RequestException."""
    
        mock_post.side_effect = requests.RequestException("timeout")
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
    
        attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png"))
        assert obj.notify(body="Image", attach=attach) is False
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_mms_non_json(mock_post):
        """NotifySMSC() MMS succeeds with non-JSON response body on HTTP 200."""
    
        # The loads() exception handler branch (lines 449-450) fires here
        r = mock.Mock()
        r.status_code = requests.codes.ok
        r.content = b"OK"
        mock_post.return_value = r
    
        obj = NotifySMSC(user=LOGIN, password=PASSWORD, targets=["+71234567890"])
    
        attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.png"))
        assert obj.notify(body="Image", attach=attach) is True
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_parse_url(mock_post):
        """NotifySMSC() parse_url covers all input forms."""
    
        # Standard form
        result = NotifySMSC.parse_url(
            "smsc://login:pass@+71234567890/+79876543210"
        )
        assert result is not None
        obj = NotifySMSC(**result)
        assert len(obj.targets) == 2
    
        # ?to= alias
        result = NotifySMSC.parse_url(
            "smsc://login:pass@+71234567890?to=%2B79876543210"
        )
        assert result is not None
        obj = NotifySMSC(**result)
        assert len(obj.targets) == 2
    
        # ?sender= and ?translit=
        result = NotifySMSC.parse_url(
            "smsc://login:pass@+71234567890?sender=Biz&translit=yes"
        )
        assert result is not None
        obj = NotifySMSC(**result)
        assert obj.sender == "Biz"
        assert obj.translit is True
    
        # Bad URL (None) returns None
        result = NotifySMSC.parse_url(None)
        assert result is None
    
    
    @mock.patch("requests.post")
    def test_plugin_smsc_apprise_integration(mock_post):
        """NotifySMSC() integrates correctly with the Apprise engine."""
    
        r = mock.Mock()
        r.status_code = requests.codes.ok
        r.content = json.dumps({"id": "42", "cnt": 1}).encode()
        mock_post.return_value = r
    
        a = Apprise()
        assert a.add("smsc://login:password@+71234567890")
        assert a.notify(body="Integration test", notify_type=NotifyType.INFO)
        assert mock_post.call_count == 1
    caronc-apprise-182f859/tests/test_plugin_smseagle.py000066400000000000000000000646311524161175200226570ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    from json import dumps, loads
    
    # Disable logging for a cleaner testing output
    import logging
    import os
    from unittest import mock
    
    from helpers import AppriseURLTester
    import requests
    
    from apprise import Apprise, AppriseAttachment, NotifyType
    from apprise.plugins.smseagle import NotifySMSEagle
    
    logging.disable(logging.CRITICAL)
    
    SMSEAGLE_GOOD_RESPONSE = dumps(
        {"result": {"message_id": "748", "status": "ok"}}
    )
    
    SMSEAGLE_BAD_RESPONSE = dumps(
        {
            "result": {
                "error_text": "Wrong parameters",
                "status": "error",
            }
        }
    )
    
    
    # Attachment Directory
    TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var")
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "smseagle://",
            {
                # No host specified
                "instance": TypeError,
            },
        ),
        (
            "smseagle://:@/",
            {
                # invalid host
                "instance": TypeError,
            },
        ),
        (
            "smseagle://localhost",
            {
                # Just a host provided (no access token)
                "instance": TypeError,
            },
        ),
        (
            "smseagle://%20@localhost",
            {
                # invalid token
                "instance": TypeError,
            },
        ),
        (
            "smseagle://token@localhost/123/",
            {
                # invalid 'to' phone number
                "instance": NotifySMSEagle,
                # Notify will fail because it couldn't send to anyone
                "response": False,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smseagle://****@localhost/@123",
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://tokenb@localhost/%20/%20/",
            {
                # invalid 'to' phone number
                "instance": NotifySMSEagle,
                # Notify will fail because it couldn't send to anyone
                "notify_response": False,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smseagle://****@localhost/",
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://token@localhost:8080/{}/".format("1" * 11),
            {
                # one phone number will notify ourselves
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://localhost:8080/{}/?token=abc1234".format("1" * 11),
            {
                # pass our token in as an argument
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
                "force_debug": True,
            },
        ),
        # Set priority
        (
            "smseagle://token@localhost/@user/?priority=high",
            {
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        # Support integer value too
        (
            "smseagle://token@localhost/@user/?priority=1",
            {
                "instance": NotifySMSEagle,
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        # Invalid priority
        (
            "smseagle://token@localhost/@user/?priority=invalid",
            {
                # Invalid Priority
                "instance": TypeError,
            },
        ),
        # Invalid priority
        (
            "smseagle://token@localhost/@user/?priority=25",
            {
                # Invalid Priority
                "instance": TypeError,
            },
        ),
        (
            "smseagle://token@localhost:8082/#abcd/",
            {
                # a valid group
                "instance": NotifySMSEagle,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smseagle://****@localhost:8082/#abcd",
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://token@localhost:8082/@abcd/",
            {
                # a valid contact
                "instance": NotifySMSEagle,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smseagle://****@localhost:8082/@abcd",
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagles://token@localhost:8081/contact/",
            {
                # another valid group (without @ symbol)
                "instance": NotifySMSEagle,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smseagles://****@localhost:8081/@contact",
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://token@localhost:8082/@/#/,/",
            {
                # Test case where we provide bad data
                "instance": NotifySMSEagle,
                # Our failed response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
                # as a result, we expect a failed notification
                "response": False,
            },
        ),
        (
            "smseagle://token@localhost:8083/@user/",
            {
                # Test case where we get a bad response
                "instance": NotifySMSEagle,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smseagle://****@localhost:8083/@user",
                # Our failed response
                "requests_response_text": SMSEAGLE_BAD_RESPONSE,
                # as a result, we expect a failed notification
                "response": False,
            },
        ),
        (
            "smseagle://token@localhost:8084/@user/",
            {
                # Test case where we get a bad response
                "instance": NotifySMSEagle,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smseagle://****@localhost:8084/@user",
                # Our failed response
                "requests_response_text": None,
                # as a result, we expect a failed notification
                "response": False,
            },
        ),
        (
            "smseagle://token@localhost:8085/@user/",
            {
                # Test case where we get a bad response
                "instance": NotifySMSEagle,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "smseagle://****@localhost:8085/@user",
                # Our failed response (bad json)
                "requests_response_text": "{",
                # as a result, we expect a failed notification
                "response": False,
            },
        ),
        (
            "smseagle://token@localhost:8086/?to={},{}".format("2" * 11, "3" * 11),
            {
                # use get args to acomplish the same thing
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://token@localhost:8087/?to={},{},{}".format(
                "2" * 11, "3" * 11, "5" * 3
            ),
            {
                # 2 good targets and one invalid one
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://token@localhost:8088/{}/{}/".format("2" * 11, "3" * 11),
            {
                # If we have from= specified, then all elements take on the
                # to= value
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagles://token@localhost/{}".format("3" * 11),
            {
                # use get args to acomplish the same thing (use source instead of
                # from)
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagles://token@localhost/{}/{}?batch=True".format(
                "3" * 11, "4" * 11
            ),
            {
                # test batch mode
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagles://token@localhost/{}/?flash=yes".format("3" * 11),
            {
                # test flash mode
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagles://token@localhost/{}/?test=yes".format("3" * 11),
            {
                # test mode
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagles://token@localhost/{}/{}?status=True".format(
                "3" * 11, "4" * 11
            ),
            {
                # test status switch
                "instance": NotifySMSEagle,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://token@localhost/{}".format("4" * 11),
            {
                "instance": NotifySMSEagle,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
                # Our response expected server response
                "requests_response_text": SMSEAGLE_GOOD_RESPONSE,
            },
        ),
        (
            "smseagle://token@localhost/{}".format("4" * 11),
            {
                "instance": NotifySMSEagle,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_smseagle_urls():
        """NotifySMSEagle() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    @mock.patch("requests.post")
    def test_plugin_smseagle_edge_cases(mock_post):
        """NotifySMSEagle() Edge Cases."""
    
        # Prepare our response
        response = requests.Request()
        response.status_code = requests.codes.ok
        response.content = SMSEAGLE_GOOD_RESPONSE
    
        # Prepare Mock
        mock_post.return_value = response
    
        # Initialize some generic (but valid) tokens
        target = "+1 (555) 987-5432"
        body = "test body"
        title = "My Title"
    
        aobj = Apprise()
        assert aobj.add(f"smseagles://token@localhost:231/{target}")
        assert len(aobj) == 1
        assert aobj.notify(title=title, body=body)
        assert mock_post.call_count == 1
    
        details = mock_post.call_args_list[0]
        assert details[0][0] == "https://localhost:231/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["params"]["message"] == "My Title\r\ntest body"
    
        # Reset our mock object
        mock_post.reset_mock()
    
        aobj = Apprise()
        assert aobj.add(f"smseagles://token@localhost:231/{target}?status=Yes")
        assert len(aobj) == 1
        assert aobj.notify(title=title, body=body)
        assert mock_post.call_count == 1
    
        details = mock_post.call_args_list[0]
        assert details[0][0] == "https://localhost:231/jsonrpc/sms"
        payload = loads(details[1]["data"])
        # Status flag is set
        assert payload["params"]["message"] == "[i] My Title\r\ntest body"
    
    
    @mock.patch("requests.post")
    def test_plugin_smseagle_result_set(mock_post):
        """NotifySMSEagle() Result Sets."""
    
        # Prepare our response
        response = requests.Request()
        response.status_code = requests.codes.ok
        response.content = SMSEAGLE_GOOD_RESPONSE
    
        # Prepare Mock
        mock_post.return_value = response
    
        body = "test body"
        title = "My Title"
    
        aobj = Apprise()
        aobj.add(
            "smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/"
            "12514444444?batch=yes"
        )
        # In a batch mode we can shove them all into 1 call
        assert len(aobj[0]) == 1
    
        assert aobj.notify(title=title, body=body)
    
        # If a batch, there is only 1 post
        assert mock_post.call_count == 1
    
        details = mock_post.call_args_list[0]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert "method" in payload
        assert payload["method"] == "sms.send_sms"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "to" in params
        assert len(params["to"].split(",")) == 3
    
        assert "+12512222222" in params["to"].split(",")
        assert "+12513333333" in params["to"].split(",")
        # The + is not appended
        assert "12514444444" in params["to"].split(",")
        assert params.get("message_type") == "sms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "My Title\r\ntest body"
    
        # Reset our test and turn batch mode off
        mock_post.reset_mock()
    
        aobj = Apprise()
        aobj.add(
            "smseagle://token@10.0.0.112:8080/#group/Contact/123456789?batch=no"
        )
        assert len(aobj[0]) == 3
    
        assert aobj.notify(title=title, body=body)
    
        # If batch is off then there is a post per entry
        assert mock_post.call_count == 3
    
        details = mock_post.call_args_list[0]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["method"] == "sms.send_sms"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "to" in params
        assert len(params["to"].split(",")) == 1
        assert "123456789" in params["to"].split(",")
    
        assert params.get("message_type") == "sms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "My Title\r\ntest body"
    
        details = mock_post.call_args_list[1]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["method"] == "sms.send_togroup"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "groupname" in params
        assert len(params["groupname"].split(",")) == 1
        assert "group" in params["groupname"].split(",")
    
        assert params.get("message_type") == "sms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "My Title\r\ntest body"
    
        details = mock_post.call_args_list[2]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["method"] == "sms.send_tocontact"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "contactname" in params
        assert len(params["contactname"].split(",")) == 1
        assert "Contact" in params["contactname"].split(",")
    
        assert params.get("message_type") == "sms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "My Title\r\ntest body"
    
        mock_post.reset_mock()
    
        # Test groups and contact names
        aobj = Apprise()
        aobj.add(
            "smseagle://token@10.0.0.112:8080/513333333/#group1/@contact1/"
            "contact2/12514444444?batch=yes"
        )
    
        # contacts and numbers can be combined and is calculated in batch response
        assert len(aobj[0]) == 3
        assert aobj.notify(title=title, body=body)
    
        # There is a unique post to each (group, contact x2, and phone x2)
        # The key is the contacts were grouped here in 1 post each
        assert mock_post.call_count == 3
    
        details = mock_post.call_args_list[0]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["method"] == "sms.send_sms"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "to" in params
        assert len(params["to"].split(",")) == 2
        assert "513333333" in params["to"].split(",")
        assert "12514444444" in params["to"].split(",")
    
        assert params.get("message_type") == "sms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "My Title\r\ntest body"
    
        details = mock_post.call_args_list[1]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["method"] == "sms.send_togroup"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "groupname" in params
        assert len(params["groupname"].split(",")) == 1
        assert "group1" in params["groupname"].split(",")
    
        assert params.get("message_type") == "sms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "My Title\r\ntest body"
    
        details = mock_post.call_args_list[2]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["method"] == "sms.send_tocontact"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "contactname" in params
        assert len(params["contactname"].split(",")) == 2
        assert "contact1" in params["contactname"].split(",")
        assert "contact2" in params["contactname"].split(",")
    
        assert params.get("message_type") == "sms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "My Title\r\ntest body"
    
        # Validate our information is also placed back into the assembled URL
        assert "/@contact1" in aobj[0].url()
        assert "/@contact2" in aobj[0].url()
        assert "/#group1" in aobj[0].url()
        assert "/513333333" in aobj[0].url()
        assert "/12514444444" in aobj[0].url()
    
    
    @mock.patch("requests.post")
    def test_notify_smseagle_plugin_result_list(mock_post):
        """NotifySMSEagle() Result List Response."""
    
        okay_response = requests.Request()
        okay_response.status_code = requests.codes.ok
        # We want to test the case where the `result` set returned is a list
        okay_response.content = dumps(
            {"result": [{"message_id": "748", "status": "ok"}]}
        )
    
        # Assign our mock object our return value
        mock_post.return_value = okay_response
    
        obj = Apprise.instantiate("smseagle://token@127.0.0.1/12222222/")
        assert isinstance(obj, NotifySMSEagle)
    
        # We should successfully handle the list
        assert obj.notify("test") is True
    
        # However if one of the elements in the list is bad
        okay_response.content = dumps(
            {
                "result": [
                    {"message_id": "748", "status": "ok"},
                    {"message_id": "749", "status": "error"},
                ]
            }
        )
    
        # Assign our mock object our return value
        mock_post.return_value = okay_response
    
        # We should now fail
        assert obj.notify("test") is False
    
    
    @mock.patch("requests.post")
    def test_notify_smseagle_plugin_attachments(mock_post):
        """NotifySMSEagle() Attachments."""
    
        okay_response = requests.Request()
        okay_response.status_code = requests.codes.ok
        okay_response.content = SMSEAGLE_GOOD_RESPONSE
    
        # Assign our mock object our return value
        mock_post.return_value = okay_response
    
        obj = Apprise.instantiate(
            "smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/"
            "12514444444?batch=no"
        )
        assert isinstance(obj, NotifySMSEagle)
    
        # Test Valid Attachment
        path = os.path.join(TEST_VAR_DIR, "apprise-test.gif")
        attach = AppriseAttachment(path)
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
    
        # Test invalid attachment
        path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg")
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=path,
            )
            is False
        )
    
        path = (
            os.path.join(TEST_VAR_DIR, "apprise-test.gif"),
            os.path.join(TEST_VAR_DIR, "apprise-test.gif"),
            os.path.join(TEST_VAR_DIR, "apprise-test.gif"),
        )
        attach = AppriseAttachment(path)
    
        # Return our good configuration
        mock_post.side_effect = None
        mock_post.return_value = okay_response
        with mock.patch("builtins.open", side_effect=OSError()):
            # We can't send the message we can't open the attachment for reading
            assert (
                obj.notify(
                    body="body",
                    title="title",
                    notify_type=NotifyType.INFO,
                    attach=attach,
                )
                is False
            )
    
        # test the handling of our batch modes
        obj = Apprise.instantiate(
            "smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/"
            "12514444444?batch=yes"
        )
        assert isinstance(obj, NotifySMSEagle)
    
        # Now send an attachment normally without issues
        mock_post.reset_mock()
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
    
        # Verify we posted upstream
        assert mock_post.call_count == 1
    
        details = mock_post.call_args_list[0]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["method"] == "sms.send_sms"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "to" in params
        assert len(params["to"].split(",")) == 3
        assert "+12512222222" in params["to"].split(",")
        assert "+12513333333" in params["to"].split(",")
        assert "12514444444" in params["to"].split(",")
    
        assert params.get("message_type") == "mms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "title\r\nbody"
    
        # Verify our attachments are in place
        assert "attachments" in params
        assert isinstance(params["attachments"], list)
        assert len(params["attachments"]) == 3
        for entry in params["attachments"]:
            assert "content" in entry
            assert "content_type" in entry
            assert entry.get("content_type").startswith("image/")
    
        # Reset our mock object
        mock_post.reset_mock()
    
        # test the handling of our batch modes
        obj = Apprise.instantiate("smseagle://token@10.0.0.112:8080/513333333/")
        assert isinstance(obj, NotifySMSEagle)
    
        # Unsupported (non image types are not sent)
        attach = os.path.join(TEST_VAR_DIR, "apprise-test.mp4")
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
    
        # Verify we still posted upstream
        assert mock_post.call_count == 1
    
        details = mock_post.call_args_list[0]
        assert details[0][0] == "http://10.0.0.112:8080/jsonrpc/sms"
        payload = loads(details[1]["data"])
        assert payload["method"] == "sms.send_sms"
    
        assert "params" in payload
        assert isinstance(payload["params"], dict)
        params = payload["params"]
        assert "to" in params
        assert len(params["to"].split(",")) == 1
        assert "513333333" in params["to"].split(",")
    
        assert params.get("message_type") == "sms"
        assert params.get("responsetype") == "extended"
        assert params.get("access_token") == "token"
        assert params.get("highpriority") == 0
        assert params.get("flash") == 0
        assert params.get("test") == 0
        assert params.get("unicode") == 1
        assert params.get("message") == "title\r\nbody"
    
        # No attachments were added
        assert "attachments" not in params
    caronc-apprise-182f859/tests/test_plugin_smtp2go.py000066400000000000000000000251371524161175200224500ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    import os
    from unittest import mock
    
    from helpers import AppriseURLTester
    import requests
    
    from apprise import Apprise, AppriseAttachment, NotifyType
    from apprise.plugins.smtp2go import NotifySMTP2Go
    
    logging.disable(logging.CRITICAL)
    
    # Attachment Directory
    TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var")
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "smtp2go://",
            {
                "instance": TypeError,
            },
        ),
        (
            "smtp2go://:@/",
            {
                "instance": TypeError,
            },
        ),
        # No Token specified
        (
            "smtp2go://user@localhost.localdomain",
            {
                "instance": TypeError,
            },
        ),
        # Token is valid, but no user name specified
        (
            "smtp2go://localhost.localdomain/{}-{}-{}".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": TypeError,
            },
        ),
        # Invalid from email address
        (
            'smtp2go://"@localhost.localdomain/{}-{}-{}'.format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": TypeError,
            },
        ),
        # No To email address, but everything else is valid
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
            },
        ),
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}?format=markdown".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
            },
        ),
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}?format=html".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
            },
        ),
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}?format=text".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
            },
        ),
        # headers
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}"
            "?+X-Customer-Campaign-ID=Apprise".format("a" * 32, "b" * 8, "c" * 8),
            {
                "instance": NotifySMTP2Go,
                "force_debug": True,
            },
        ),
        # bcc and cc
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}"
            "?bcc=user@example.com&cc=user2@example.com".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
            },
        ),
        # One To Email address
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}/test@example.com".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
            },
        ),
        (
            "smtp2go://user@localhost.localdomain/"
            "{}-{}-{}?to=test@example.com".format("a" * 32, "b" * 8, "c" * 8),
            {"instance": NotifySMTP2Go},
        ),
        # One To Email address, a from name specified too
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}/"
            'test@example.com?name="Frodo"'.format("a" * 32, "b" * 8, "c" * 8),
            {"instance": NotifySMTP2Go},
        ),
        # Invalid 'To' Email address
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}/invalid".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
                # Expected notify() response
                "notify_response": False,
            },
        ),
        # Multiple 'To', 'Cc', and 'Bcc' addresses (with invalid ones)
        (
            "smtp2go://user@example.com/{}-{}-{}/{}?bcc={}&cc={}".format(
                "a" * 32,
                "b" * 8,
                "c" * 8,
                "/".join(
                    ("user1@example.com", "invalid", "User2:user2@example.com")
                ),
                ",".join(("user3@example.com", "i@v", "User1:user1@example.com")),
                ",".join(("user4@example.com", "g@r@b", "Da:user5@example.com")),
            ),
            {
                "instance": NotifySMTP2Go,
            },
        ),
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
                # force a failure
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "smtp2go://user@localhost.localdomain/{}-{}-{}".format(
                "a" * 32, "b" * 8, "c" * 8
            ),
            {
                "instance": NotifySMTP2Go,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_smtp2go_urls():
        """NotifySMTP2Go() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    @mock.patch("requests.post")
    def test_plugin_smtp2go_attachments(mock_post):
        """NotifySMTP2Go() Attachments."""
    
        okay_response = requests.Request()
        okay_response.status_code = requests.codes.ok
        okay_response.content = ""
    
        # Assign our mock object our return value
        mock_post.return_value = okay_response
    
        # API Key
        apikey = "abc123"
    
        obj = Apprise.instantiate(f"smtp2go://user@localhost.localdomain/{apikey}")
        assert isinstance(obj, NotifySMTP2Go)
    
        # Test Valid Attachment
        path = os.path.join(TEST_VAR_DIR, "apprise-test.gif")
        attach = AppriseAttachment(path)
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
    
        # Test invalid attachment
        path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg")
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=path,
            )
            is False
        )
    
        mock_post.return_value = None
        mock_post.side_effect = OSError()
        # We can't send the message if we can't read the attachment
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is False
        )
    
        # Test Valid Attachment (load 3)
        path = (
            os.path.join(TEST_VAR_DIR, "apprise-test.gif"),
            os.path.join(TEST_VAR_DIR, "apprise-test.gif"),
            os.path.join(TEST_VAR_DIR, "apprise-test.gif"),
        )
        attach = AppriseAttachment(path)
    
        # Return our good configuration
        mock_post.side_effect = None
        mock_post.return_value = okay_response
        with mock.patch("builtins.open", side_effect=OSError()):
            # We can't send the message we can't open the attachment for reading
            assert (
                obj.notify(
                    body="body",
                    title="title",
                    notify_type=NotifyType.INFO,
                    attach=attach,
                )
                is False
            )
    
        # test the handling of our batch modes
        obj = Apprise.instantiate(
            f"smtp2go://no-reply@example.com/{apikey}/"
            "user1@example.com/user2@example.com?batch=yes"
        )
        assert isinstance(obj, NotifySMTP2Go)
    
        # objects will be combined into a single post in batch mode
        assert len(obj) == 1
    
        # Force our batch to break into separate messages
        obj.default_batch_size = 1
    
        # We'll send 2 messages now
        assert len(obj) == 2
    
        mock_post.reset_mock()
    
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
        assert mock_post.call_count == 2
    
        # single batch
        mock_post.reset_mock()
        # We'll send 1 message
        obj.default_batch_size = 2
    
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
        assert mock_post.call_count == 1
    
    
    def test_plugin_smtp2go_cc_bcc_invalid_branch():
        """NotifySMTP2Go() CC/BCC validation: invalid entries are dropped."""
    
        # Build a NotifySMTP2Go with:
        #   cc  = one valid address + one that parse_emails passes through but
        #         is_email rejects (hits the FALSE branch in the CC loop).
        #         A list must be used -- parse_emails filters invalid tokens out
        #         of comma-separated strings before is_email is ever called.
        #   bcc = same, for the FALSE branch in the BCC loop
        obj = NotifySMTP2Go(
            apikey="abc123",
            targets=["user@example.com"],
            cc=["good@example.com", "notanemail"],
            bcc=["bcc@example.com", "alsonotanemail"],
            user="user",
            host="example.com",
        )
    
        # Valid CC entry was added; invalid one was silently dropped
        assert "good@example.com" in obj.cc
        assert len(obj.cc) == 1
    
        # Valid BCC entry was added; invalid one was silently dropped
        assert "bcc@example.com" in obj.bcc
        assert len(obj.bcc) == 1
    caronc-apprise-182f859/tests/test_plugin_sns.py000066400000000000000000000724701524161175200216620ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    from unittest import mock
    
    from helpers import AppriseURLTester
    import pytest
    import requests
    
    from apprise import Apprise
    from apprise.plugins.sns import NotifySNS, SNSMode
    
    logging.disable(logging.CRITICAL)
    
    
    TEST_ACCESS_KEY_ID = "AHIAJGNT76XIMXDBIJYA"
    TEST_ACCESS_KEY_SECRET = "bu1dHSdO22pfaaVy/wmNsdljF4C07D3bndi9PQJ9"
    TEST_REGION = "us-east-2"
    
    # A realistic-looking (but fake) AWS session token
    TEST_SESSION_TOKEN = "FwoGZXIvYXdz+SESSION/TOKENABCDEFGHIJKLMNOPQRS="
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "sns://",
            {
                "instance": TypeError,
            },
        ),
        (
            "sns://:@/",
            {
                "instance": TypeError,
            },
        ),
        (
            "sns://T1JJ3T3L2",
            {
                # Just Token 1 provided
                "instance": TypeError,
            },
        ),
        (
            "sns://T1JJ3TD4JD/TIiajkdnlazk7FQ/",
            {
                # Missing a region
                "instance": TypeError,
            },
        ),
        (
            "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/12223334444",
            {
                # we have a valid URL and one number to text
                "instance": NotifySNS,
            },
        ),
        (
            (
                "sns://?access=T1JJ3T3L2&secret=A1BRTD4JD/TIiajkdnlazkcevi7FQ"
                "®ion=us-west-2&to=12223334444"
            ),
            {
                # Initialize using get parameters instead
                "instance": NotifySNS,
            },
        ),
        (
            "sns://T1JJ3TD4JD/TIiajkdnlazk7FQ/us-west-2/12223334444/12223334445",
            {
                # Multi SNS Suppport
                "instance": NotifySNS,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "sns://T...D/****/us-west-2",
            },
        ),
        (
            (
                "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/us-east-1"
                "?to=12223334444"
            ),
            {
                # Missing a topic and/or phone No
                "instance": NotifySNS,
            },
        ),
        (
            "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/12223334444",
            {
                "instance": NotifySNS,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/15556667777",
            {
                "instance": NotifySNS,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
        (
            # Session token in userinfo position (temporary/IAM credentials)
            "sns://SESSIONTOKEN@T1JJ3T3L2"
            "/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/12223334444",
            {
                "instance": NotifySNS,
            },
        ),
        (
            # Session token via ?token= query parameter
            "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ"
            "/us-west-2/12223334444?token=SESSIONTOKEN",
            {
                "instance": NotifySNS,
            },
        ),
        (
            # Access key via ?key= alias
            "sns://?key=T1JJ3T3L2&secret=A1BRTD4JD/TIiajkdnlazkcevi7FQ"
            "®ion=us-west-2&to=12223334444",
            {
                "instance": NotifySNS,
            },
        ),
        (
            # Session token + HTTP error
            "sns://SESSIONTOKEN@T1JJ3T3L2"
            "/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/12223334444",
            {
                "instance": NotifySNS,
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            # Session token + request exceptions
            "sns://SESSIONTOKEN@T1JJ3T3L2"
            "/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/15556667777",
            {
                "instance": NotifySNS,
                "test_requests_exceptions": True,
            },
        ),
        (
            # Explicit SMS mode
            "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ"
            "/us-west-2/12223334444?mode=sms",
            {
                "instance": NotifySNS,
            },
        ),
        (
            # Forced topic mode on a phone target
            "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ"
            "/us-west-2/12223334444?mode=topic",
            {
                "instance": NotifySNS,
            },
        ),
        (
            # Invalid mode raises TypeError
            "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ"
            "/us-west-2/12223334444?mode=invalid",
            {
                "instance": TypeError,
            },
        ),
    )
    
    
    def test_plugin_sns_urls():
        """NotifySNS() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    # We initialize a post object just incase a test fails below
    # we don't want it sending any notifications upstream
    @mock.patch("requests.post")
    def test_plugin_sns_edge_cases(mock_post):
        """NotifySNS() Edge Cases."""
        target = "+1800555999"
        # Initializes the plugin with a valid access, but invalid access key
        with pytest.raises(TypeError):
            # No access_key_id specified
            NotifySNS(
                access_key_id=None,
                secret_access_key=TEST_ACCESS_KEY_SECRET,
                region_name=TEST_REGION,
                targets=target,
            )
    
        with pytest.raises(TypeError):
            # No secret_access_key specified
            NotifySNS(
                access_key_id=TEST_ACCESS_KEY_ID,
                secret_access_key=None,
                region_name=TEST_REGION,
                targets=target,
            )
    
        with pytest.raises(TypeError):
            # No region_name specified
            NotifySNS(
                access_key_id=TEST_ACCESS_KEY_ID,
                secret_access_key=TEST_ACCESS_KEY_SECRET,
                region_name=None,
                targets=target,
            )
    
        # No recipients
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=None,
        )
    
        # The object initializes properly but would not be able to send anything
        assert obj.notify(body="test", title="test") is False
    
        # The phone number is invalid, and without it, there is nothing
        # to notify
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets="+1809",
        )
    
        # The object initializes properly but would not be able to send anything
        assert obj.notify(body="test", title="test") is False
    
        # The phone number is invalid, and without it, there is nothing
        # to notify; we
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets="#(invalid-topic-because-of-the-brackets)",
        )
    
        # The object initializes properly but would not be able to send anything
        assert obj.notify(body="test", title="test") is False
    
    
    def test_plugin_sns_url_parsing():
        """NotifySNS() URL Parsing."""
    
        # No recipients
        results = NotifySNS.parse_url(
            f"sns://{TEST_ACCESS_KEY_ID}/{TEST_ACCESS_KEY_SECRET}/{TEST_REGION}/"
        )
    
        # Confirm that there were no recipients found
        assert len(results["targets"]) == 0
        assert "region_name" in results
        assert results["region_name"] == TEST_REGION
        assert "access_key_id" in results
        assert results["access_key_id"] == TEST_ACCESS_KEY_ID
        assert "secret_access_key" in results
        assert results["secret_access_key"] == TEST_ACCESS_KEY_SECRET
    
        target = "+18001234567"
        topic = "MyTopic"
    
        # Detect recipients
        results = NotifySNS.parse_url(
            f"sns://{TEST_ACCESS_KEY_ID}/"
            f"{TEST_ACCESS_KEY_SECRET}/"
            f"{TEST_REGION.upper()}/"
            f"{target}/"
            f"{topic}/"
        )
    
        # Confirm that our recipients were found
        assert len(results["targets"]) == 2
        assert target in results["targets"]
        assert topic in results["targets"]
        assert "region_name" in results
        assert results["region_name"] == TEST_REGION
        assert "access_key_id" in results
        assert results["access_key_id"] == TEST_ACCESS_KEY_ID
        assert "secret_access_key" in results
        assert results["secret_access_key"] == TEST_ACCESS_KEY_SECRET
    
    
    def test_plugin_sns_object_parsing():
        """NotifySNS() Object Parsing."""
    
        # Create our object
        a = Apprise()
    
        # Now test failing variations of our URL
        assert a.add("sns://") is False
        assert a.add("sns://nosecret") is False
        assert a.add("sns://nosecret/noregion/") is False
    
        # This is valid but without valid recipients; while it's still a valid URL
        # it won't do much when the user goes to send a notification
        assert a.add("sns://norecipient/norecipient/us-west-2") is True
        assert len(a) == 1
    
        # Parse a good one
        assert a.add("sns://oh/yeah/us-west-2/abcdtopic/+12223334444") is True
        assert len(a) == 2
    
        assert a.add("sns://oh/yeah/us-west-2/12223334444") is True
        assert len(a) == 3
    
    
    def test_plugin_sns_aws_response_handling():
        """NotifySNS() AWS Response Handling."""
        # Not a string
        response = NotifySNS.aws_response_to_dict(None)
        assert response["type"] is None
        assert response["request_id"] is None
    
        # Invalid XML
        response = NotifySNS.aws_response_to_dict(
            ''
        )
        assert response["type"] is None
        assert response["request_id"] is None
    
        # Single Element in XML
        response = NotifySNS.aws_response_to_dict(
            ""
        )
        assert response["type"] == "SingleElement"
        assert response["request_id"] is None
    
        # Empty String
        response = NotifySNS.aws_response_to_dict("")
        assert response["type"] is None
        assert response["request_id"] is None
    
        response = NotifySNS.aws_response_to_dict("""
            
                
                    5e16935a-d1fb-5a31-a716-c7805e5c1d2e
                
                
                    dc258024-d0e6-56bb-af1b-d4fe5f4181a4
                
            
            """)
        assert response["type"] == "PublishResponse"
        assert response["request_id"] == "dc258024-d0e6-56bb-af1b-d4fe5f4181a4"
        assert response["message_id"] == "5e16935a-d1fb-5a31-a716-c7805e5c1d2e"
    
        response = NotifySNS.aws_response_to_dict("""
             
               
                 arn:aws:sns:us-east-1:000000000000:abcd
                    
                
                    604bef0f-369c-50c5-a7a4-bbd474c83d6a
                
            
            """)
        assert response["type"] == "CreateTopicResponse"
        assert response["request_id"] == "604bef0f-369c-50c5-a7a4-bbd474c83d6a"
        assert response["topic_arn"] == "arn:aws:sns:us-east-1:000000000000:abcd"
    
        response = NotifySNS.aws_response_to_dict("""
            
                
                    Sender
                    InvalidParameter
                    Invalid parameter: TopicArn or TargetArn Reason:
                    no value for required parameter
                
                b5614883-babe-56ca-93b2-1c592ba6191e
            
            """)
        assert response["type"] == "ErrorResponse"
        assert response["request_id"] == "b5614883-babe-56ca-93b2-1c592ba6191e"
        assert response["error_type"] == "Sender"
        assert response["error_code"] == "InvalidParameter"
        assert response["error_message"].startswith("Invalid parameter:")
        assert response["error_message"].endswith("required parameter")
    
    
    @mock.patch("requests.post")
    def test_plugin_sns_aws_topic_handling(mock_post):
        """NotifySNS() AWS Topic Handling."""
    
        arn_response = """
             
               
                 arn:aws:sns:us-east-1:000000000000:abcd
                    
                
                    604bef0f-369c-50c5-a7a4-bbd474c83d6a
                
            
            """
    
        def post(url, data, **kwargs):
            """Since Publishing a token requires 2 posts, we need to return our
            response depending on what step we're on."""
    
            # A request
            robj = mock.Mock()
            robj.text = ""
            robj.status_code = requests.codes.ok
    
            if data.find("=CreateTopic") >= 0:
                # Topic Post Failure
                robj.status_code = requests.codes.bad_request
    
            return robj
    
        # Assign ourselves a new function
        mock_post.side_effect = post
    
        # Create our object
        a = Apprise()
    
        a.add(
            [
                # Single Topic
                "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnl/us-west-2/TopicA",
                # Multi-Topic
                "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnl/us-east-1/TopicA/TopicB/"
                # Topic-Mix
                "sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkce/us-west-2/"
                "12223334444/TopicA",
            ]
        )
    
        # CreateTopic fails
        assert a.notify(title="", body="test") is False
    
        def post(url, data, **kwargs):
            """Since Publishing a token requires 2 posts, we need to return our
            response depending on what step we're on."""
    
            # A request
            robj = mock.Mock()
            robj.text = ""
            robj.status_code = requests.codes.ok
    
            if data.find("=CreateTopic") >= 0:
                robj.text = arn_response
    
            # Manipulate Topic Publishing only (not phone)
            elif data.find("=Publish") >= 0 and data.find("TopicArn=") >= 0:
                # Topic Post Failure
                robj.status_code = requests.codes.bad_request
    
            return robj
    
        # Assign ourselves a new function
        mock_post.side_effect = post
    
        # Publish fails
        assert a.notify(title="", body="test") is False
    
        # Disable our side effect
        mock_post.side_effect = None
    
        # Handle case where TopicArn is missing:
        robj = mock.Mock()
        robj.text = ""
        robj.status_code = requests.codes.ok
    
        # Assign ourselves a new function
        mock_post.return_value = robj
        assert a.notify(title="", body="test") is False
    
        # Handle case where we fails get a bad response
        robj = mock.Mock()
        robj.text = ""
        robj.status_code = requests.codes.bad_request
        mock_post.return_value = robj
        assert a.notify(title="", body="test") is False
    
        # Handle case where we get a valid response and TopicARN
        robj = mock.Mock()
        robj.text = arn_response
        robj.status_code = requests.codes.ok
        mock_post.return_value = robj
        # We would have failed to make Post
        assert a.notify(title="", body="test") is True
    
    
    @mock.patch("requests.post")
    def test_plugin_sns_session_token(mock_post):
        """NotifySNS() session token for temporary/IAM credentials."""
    
        response = mock.MagicMock()
        response.status_code = requests.codes.ok
        response.text = ""
        mock_post.return_value = response
    
        # Init with session token
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets="+18001234567",
            session_token=TEST_SESSION_TOKEN,
        )
        assert obj.aws_session_token == TEST_SESSION_TOKEN
    
        # send() must include X-Amz-Security-Token in request headers
        assert obj.notify(body="test") is True
        call_kwargs = mock_post.call_args[1]
        assert "X-Amz-Security-Token" in call_kwargs["headers"]
        assert call_kwargs["headers"]["X-Amz-Security-Token"] == TEST_SESSION_TOKEN
    
        # x-amz-security-token must also appear in the Authorization header
        # (it is listed in SignedHeaders)
        auth = call_kwargs["headers"]["Authorization"]
        assert "x-amz-security-token" in auth
    
        # URL round-trip via userinfo position; + and = stay unencoded
        # (safe in userinfo); / is encoded since it terminates the netloc
        url = obj.url()
        assert "@" in url
        assert NotifySNS.quote(TEST_SESSION_TOKEN, safe="+=") in url
    
        results = NotifySNS.parse_url(url)
        assert results["session_token"] == TEST_SESSION_TOKEN
        assert results["access_key_id"] == TEST_ACCESS_KEY_ID
    
        obj2 = NotifySNS(**results)
        assert obj2.aws_session_token == TEST_SESSION_TOKEN
        assert obj2.url_identifier == obj.url_identifier
    
        # Privacy URL must mask the token but keep the @ separator
        priv_url = obj.url(privacy=True)
        assert TEST_SESSION_TOKEN not in priv_url
        assert "@" in priv_url
    
        # Without a session token no X-Amz-Security-Token header is sent
        mock_post.reset_mock()
        obj_plain = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets="+18001234567",
        )
        assert obj_plain.aws_session_token is None
        assert obj_plain.notify(body="test") is True
        call_kwargs = mock_post.call_args[1]
        assert "X-Amz-Security-Token" not in call_kwargs["headers"]
    
        # url() without token has no @ separator
        plain_url = obj_plain.url()
        assert "@" not in plain_url
    
    
    @mock.patch("requests.post")
    def test_plugin_sns_session_token_via_kwarg(mock_post):
        """NotifySNS() session token parsed from ?token= kwarg."""
    
        response = mock.MagicMock()
        response.status_code = requests.codes.ok
        response.text = ""
        mock_post.return_value = response
    
        url = (
            f"sns://{TEST_ACCESS_KEY_ID}/{TEST_ACCESS_KEY_SECRET}"
            f"/{TEST_REGION}/+18001234567?token={TEST_SESSION_TOKEN}"
        )
        results = NotifySNS.parse_url(url)
        assert results["session_token"] == TEST_SESSION_TOKEN
    
        obj = NotifySNS(**results)
        assert obj.aws_session_token == TEST_SESSION_TOKEN
        assert obj.notify(body="test") is True
    
        call_kwargs = mock_post.call_args[1]
        assert call_kwargs["headers"]["X-Amz-Security-Token"] == TEST_SESSION_TOKEN
    
        # ?token= overrides userinfo when both are present
        url_both = (
            f"sns://OTHERTOKEN@{TEST_ACCESS_KEY_ID}/{TEST_ACCESS_KEY_SECRET}"
            f"/{TEST_REGION}/+18001234567?token={TEST_SESSION_TOKEN}"
        )
        results2 = NotifySNS.parse_url(url_both)
        assert results2["session_token"] == TEST_SESSION_TOKEN
    
    
    def test_plugin_sns_key_alias():
        """NotifySNS() ?key= alias for access_key_id."""
    
        # ?key= resolves to access_key_id
        url = (
            f"sns://?key={TEST_ACCESS_KEY_ID}"
            f"&secret={TEST_ACCESS_KEY_SECRET}"
            f"®ion={TEST_REGION}&to=+18001234567"
        )
        results = NotifySNS.parse_url(url)
        assert results["access_key_id"] == TEST_ACCESS_KEY_ID
    
        obj = NotifySNS(**results)
        assert obj.aws_access_key_id == TEST_ACCESS_KEY_ID
    
        # ?key= takes priority over ?access= when both appear
        url_both = (
            f"sns://?key={TEST_ACCESS_KEY_ID}&access=OTHERID"
            f"&secret={TEST_ACCESS_KEY_SECRET}"
            f"®ion={TEST_REGION}&to=+18001234567"
        )
        results2 = NotifySNS.parse_url(url_both)
        assert results2["access_key_id"] == TEST_ACCESS_KEY_ID
    
    
    def test_plugin_sns_detailed_failures(mocker):
        """
        Test specific failure modes (HTTP 400) for SMS, Topic Creation,
        and Topic Publishing to ensure error counters are incremented.
        """
        from apprise.plugins.sns import NotifySNS
    
        # Mock requests.post
        mock_post = mocker.patch("requests.post")
    
        # --- Scenario 1: SMS (Phone) Failure ---
        obj_sms = NotifySNS(
            access_key_id="key",
            secret_access_key="secret",
            region_name="us-east-1",
            targets=["+15555555555"],
        )
    
        # Force a 400 Bad Request
        mock_response_bad = mocker.Mock()
        mock_response_bad.status_code = 400
        mock_response_bad.text = (
            "Fail"
        )
    
        mock_response_bad.content = mock_response_bad.text.encode("utf-8")
        mock_post.return_value = mock_response_bad
    
        # Should return False because the SMS failed
        assert obj_sms.notify(body="test") is False
    
        # --- Scenario 2: Topic Creation Failure ---
        obj_topic = NotifySNS(
            access_key_id="key",
            secret_access_key="secret",
            region_name="us-east-1",
            targets=["#MyTopic"],
        )
    
        # Force 400 on ANY request (which includes the first one: CreateTopic)
        mock_post.return_value = mock_response_bad
    
        # Should return False because CreateTopic failed
        assert obj_topic.notify(body="test") is False
    
        # --- Scenario 3: CreateTopic Success, but Publish Failure ---
        # We need a side_effect to return 200 for the first call (CreateTopic)
        # and 400 for the second (Publish)
    
        mock_response_ok = mocker.Mock()
        mock_response_ok.status_code = 200
        mock_response_ok.text = """
        
            
                arn:aws:sns:us-east-1:123456789012:MyTopic
            
        
        """
        mock_response_ok.content = mock_response_ok.text.encode("utf-8")
    
        def side_effect(*args, **kwargs):
            data = kwargs.get("data", "")
            if "Action=CreateTopic" in data:
                return mock_response_ok
            if "Action=Publish" in data:
                return mock_response_bad
            return mock_response_ok
    
        mock_post.side_effect = side_effect
    
        # Should return False because Publish failed
        assert obj_topic.notify(body="test") is False
    
    
    def test_plugin_sns_mode_detection():
        """NotifySNS() auto-detects and validates operating mode."""
    
        # Topic-only targets -> TOPIC mode auto-detected
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["#MyTopic"],
        )
        assert obj.mode == SNSMode.TOPIC
        assert obj.title_maxlen == 100
        assert obj.body_maxlen == 256000
    
        # Phone targets -> SMS mode auto-detected
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["+18001234567"],
        )
        assert obj.mode == SNSMode.SMS
        assert obj.title_maxlen == 0
        assert obj.body_maxlen == 160
    
        # Mixed targets -> SMS mode (safe default)
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["+18001234567", "#MyTopic"],
        )
        assert obj.mode == SNSMode.SMS
    
        # No targets -> SMS mode (safe default)
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=[],
        )
        assert obj.mode == SNSMode.SMS
    
        # Explicit mode=topic overrides auto-detection for phone-only URL
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["+18001234567"],
            mode=SNSMode.TOPIC,
        )
        assert obj.mode == SNSMode.TOPIC
    
        # Explicit mode=sms overrides auto-detection for topic-only URL
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["#MyTopic"],
            mode=SNSMode.SMS,
        )
        assert obj.mode == SNSMode.SMS
    
        # Prefix matching: "to" -> "topic"
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["#MyTopic"],
            mode="to",
        )
        assert obj.mode == SNSMode.TOPIC
    
        # Invalid mode raises TypeError
        with pytest.raises(TypeError):
            NotifySNS(
                access_key_id=TEST_ACCESS_KEY_ID,
                secret_access_key=TEST_ACCESS_KEY_SECRET,
                region_name=TEST_REGION,
                targets=["#MyTopic"],
                mode="invalid",
            )
    
    
    @mock.patch("requests.post")
    def test_plugin_sns_topic_mode_send(mock_post):
        """NotifySNS() TOPIC mode sets Subject field on topic payloads."""
    
        arn_response = (
            ""
            ""
            ""
            "arn:aws:sns:us-east-2:000000000000:MyTopic"
            ""
            ""
            ""
        )
    
        # Capture the most recent Publish payload for inspection
        publish_data = {}
    
        def side_effect(url, data, **kwargs):
            """Return ARN on CreateTopic; record payload on Publish."""
            robj = mock.Mock()
            robj.status_code = requests.codes.ok
            if "Action=CreateTopic" in data:
                robj.text = arn_response
            elif "Action=Publish" in data:
                publish_data["data"] = data
                robj.text = ""
            else:
                robj.text = ""
            return robj
    
        mock_post.side_effect = side_effect
    
        # Topic-only -> TOPIC mode auto-detected; title goes to Subject
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["#MyTopic"],
        )
        assert obj.mode == SNSMode.TOPIC
    
        # With title: Subject must appear in the Publish payload
        assert obj.notify(title="My Title", body="My Body") is True
        # urlencode() uses %20 for spaces
        assert "Subject=My%20Title" in publish_data["data"]
        assert "Message=My%20Body" in publish_data["data"]
    
        # With no title: Subject must not appear in the Publish payload
        publish_data.clear()
        assert obj.notify(title="", body="My Body") is True
        assert "Subject=" not in publish_data.get("data", "")
    
    
    @mock.patch("requests.post")
    def test_plugin_sns_topic_mode_phone_forced(mock_post):
        """NotifySNS() TOPIC mode forced on phone target prepends title."""
    
        response = mock.MagicMock()
        response.status_code = requests.codes.ok
        response.text = ""
        mock_post.return_value = response
    
        # Force TOPIC mode on a phone target
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["+18001234567"],
            mode=SNSMode.TOPIC,
        )
        assert obj.mode == SNSMode.TOPIC
    
        # With a title: it must be prepended to the body in the SMS payload
        assert obj.notify(title="My Title", body="My Body") is True
        data = mock_post.call_args[1]["data"]
        # urlencode() uses %20 for spaces; \r\n becomes %0D%0A
        assert "My%20Title" in data
        assert "My%20Body" in data
    
        # With no title: body is sent as-is without modification
        mock_post.reset_mock()
        assert obj.notify(title="", body="My Body") is True
        data = mock_post.call_args[1]["data"]
        assert "My%20Body" in data
    
    
    def test_plugin_sns_mode_url_round_trip():
        """NotifySNS() mode is preserved through url() and parse_url()."""
    
        # TOPIC mode round-trip
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["#MyTopic"],
            mode=SNSMode.TOPIC,
        )
        url = obj.url()
        assert "mode=topic" in url
    
        results = NotifySNS.parse_url(url)
        assert results["mode"] == SNSMode.TOPIC
    
        obj2 = NotifySNS(**results)
        assert obj2.mode == SNSMode.TOPIC
        assert obj2.url_identifier == obj.url_identifier
    
        # SMS mode round-trip
        obj = NotifySNS(
            access_key_id=TEST_ACCESS_KEY_ID,
            secret_access_key=TEST_ACCESS_KEY_SECRET,
            region_name=TEST_REGION,
            targets=["+18001234567"],
            mode=SNSMode.SMS,
        )
        url = obj.url()
        assert "mode=sms" in url
    
        results = NotifySNS.parse_url(url)
        assert results["mode"] == SNSMode.SMS
    
        obj2 = NotifySNS(**results)
        assert obj2.mode == SNSMode.SMS
    
        # No mode in URL -> auto-detection runs on re-instantiation
        url_no_mode = (
            f"sns://{TEST_ACCESS_KEY_ID}/{TEST_ACCESS_KEY_SECRET}"
            f"/{TEST_REGION}/+18001234567"
        )
        results = NotifySNS.parse_url(url_no_mode)
        assert results["mode"] is None
        obj3 = NotifySNS(**results)
        # Auto-detected from phone target
        assert obj3.mode == SNSMode.SMS
    caronc-apprise-182f859/tests/test_plugin_sogs.py000066400000000000000000000565141524161175200220330ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    import sys
    from unittest import mock
    
    from helpers import AppriseURLTester
    import pytest
    import requests
    
    from apprise import Apprise
    from apprise.plugins.sogs import (
        NotifySessionOGS,
        _build_session_message,
        _encode_varint,
        _ld_field,
    )
    
    logging.disable(logging.CRITICAL)
    
    # A valid 64-hex-char Ed25519 seed used as the bot seed.
    SEED = "a" * 64
    
    # A valid 64-hex-char server Curve25519 public key (from Session join URL).
    PUBLIC_KEY = "b" * 64
    
    # An invalid key: 63 chars (too short).
    BAD_KEY = "c" * 63
    
    # A valid room token.
    ROOM = "test-room"
    
    # Second room token for multi-room tests.
    ROOM2 = "another-room"
    
    # Canonical base URL used throughout the tests.
    BASE_URL = f"sessions://{PUBLIC_KEY}:{SEED}@open.getsession.org/{ROOM}"
    
    # Apprise URL tester entries; requires cryptography to run functionally.
    apprise_url_tests = (
        # Missing public_key and seed (only hostname + room, no credentials)
        (
            f"sessions://host/{ROOM}",
            {
                "instance": TypeError,
            },
        ),
        # Missing seed (user field present, no password)
        (
            f"sessions://{PUBLIC_KEY}@host/{ROOM}",
            {
                "instance": TypeError,
            },
        ),
        # Missing public_key (bad key in user field, too short)
        (
            f"sessions://{BAD_KEY}:{SEED}@host/{ROOM}",
            {
                "instance": TypeError,
            },
        ),
        # Missing room token (no path segments)
        (
            f"sessions://{PUBLIC_KEY}:{SEED}@host",
            {
                "instance": TypeError,
            },
        ),
        # Invalid seed (too short, in password field)
        (
            f"sessions://{PUBLIC_KEY}:{BAD_KEY}@host/{ROOM}",
            {
                "instance": TypeError,
            },
        ),
        # Valid HTTPS URL - single room
        (
            BASE_URL,
            {
                "instance": NotifySessionOGS,
                "privacy_url": (
                    f"sessions://{PUBLIC_KEY}:a...a@open.getsession.org/{ROOM}"
                ),
                "requests_response_code": requests.codes.created,
            },
        ),
        # Valid HTTPS URL via ?to= alias (public_key:seed in authority field)
        (
            f"sessions://{PUBLIC_KEY}:{SEED}@open.getsession.org?to={ROOM}",
            {
                "instance": NotifySessionOGS,
                "requests_response_code": requests.codes.created,
            },
        ),
        # Valid HTTPS URL with custom port
        (
            f"sessions://{PUBLIC_KEY}:{SEED}@open.getsession.org:8443/{ROOM}",
            {
                "instance": NotifySessionOGS,
                "requests_response_code": requests.codes.created,
            },
        ),
        # Valid HTTP (insecure) URL
        (
            f"session://{PUBLIC_KEY}:{SEED}@open.getsession.org/{ROOM}",
            {
                "instance": NotifySessionOGS,
                "requests_response_code": requests.codes.created,
            },
        ),
        # sogs:// alias for sessions:// (HTTPS)
        (
            f"sogs://{PUBLIC_KEY}:{SEED}@open.getsession.org/{ROOM}",
            {
                "instance": NotifySessionOGS,
                "requests_response_code": requests.codes.created,
            },
        ),
        # Multiple rooms as path segments
        (
            f"sessions://{PUBLIC_KEY}:{SEED}@open.getsession.org/{ROOM}/{ROOM2}",
            {
                "instance": NotifySessionOGS,
                "requests_response_code": requests.codes.created,
            },
        ),
        # Query-string form: ?key= and ?seed= instead of authority field
        (
            f"sessions://open.getsession.org/{ROOM}?key={PUBLIC_KEY}&seed={SEED}",
            {
                "instance": NotifySessionOGS,
                "requests_response_code": requests.codes.created,
            },
        ),
        # Query-string form using Session-native ?public_key= alias
        (
            f"sessions://open.getsession.org/{ROOM}"
            f"?public_key={PUBLIC_KEY}&seed={SEED}",
            {
                "instance": NotifySessionOGS,
                "requests_response_code": requests.codes.created,
            },
        ),
        # HTTP 403 Forbidden
        (
            BASE_URL,
            {
                "instance": NotifySessionOGS,
                "response": False,
                "requests_response_code": requests.codes.forbidden,
            },
        ),
        # HTTP 500 Internal Server Error
        (
            BASE_URL,
            {
                "instance": NotifySessionOGS,
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        # Unknown HTTP status code
        (
            BASE_URL,
            {
                "instance": NotifySessionOGS,
                "response": False,
                "requests_response_code": 999,
            },
        ),
        # RequestException
        (
            BASE_URL,
            {
                "instance": NotifySessionOGS,
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    def test_plugin_sogs_urls():
        """Run the Apprise URL test suite against SOGS URLs."""
        helper = AppriseURLTester(tests=apprise_url_tests)
        helper.run_all()
    
    
    @pytest.mark.skipif(
        "cryptography" in sys.modules,
        reason="Requires that cryptography NOT be installed",
    )
    def test_plugin_sogs_no_cryptography():
        """Plugin is disabled when cryptography is not installed."""
        # Apprise.instantiate() returns None for disabled plugins.
        obj = Apprise.instantiate(BASE_URL)
        assert obj is None
    
        # Direct instantiation must raise ImportError with a helpful message,
        # not the confusing AttributeError from None.from_private_bytes().
        with pytest.raises(ImportError, match="cryptography"):
            NotifySessionOGS(public_key=PUBLIC_KEY, seed=SEED, targets=[ROOM])
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_init(mock_post):
        """Test basic initialization and URL round-trip."""
    
        def _mk_resp(code=requests.codes.created):
            r = mock.Mock()
            r.status_code = code
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        # Basic construction.
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
        )
        assert obj.rooms == [ROOM]
        assert obj.public_key == PUBLIC_KEY
        assert obj.seed == SEED
    
        # Round-trip invariant.
        url = obj.url()
        parsed = NotifySessionOGS.parse_url(url)
        obj2 = NotifySessionOGS(**parsed)
        assert obj.url_identifier == obj2.url_identifier
        assert len(obj.rooms) == len(obj2.rooms)
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_missing_public_key(mock_post):
        """TypeError is raised when public_key is absent or invalid."""
        with pytest.raises(TypeError):
            NotifySessionOGS(
                public_key=None,
                seed=SEED,
                targets=[ROOM],
                host="host",
            )
        with pytest.raises(TypeError):
            NotifySessionOGS(
                public_key=BAD_KEY,
                seed=SEED,
                targets=[ROOM],
                host="host",
            )
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_missing_seed(mock_post):
        """TypeError is raised when seed is absent or invalid."""
        with pytest.raises(TypeError):
            NotifySessionOGS(
                public_key=PUBLIC_KEY,
                seed=None,
                targets=[ROOM],
                host="host",
            )
        with pytest.raises(TypeError):
            NotifySessionOGS(
                public_key=PUBLIC_KEY,
                seed=BAD_KEY,
                targets=[ROOM],
                host="host",
            )
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_missing_rooms(mock_post):
        """TypeError is raised when no valid room token is provided."""
        with pytest.raises(TypeError):
            NotifySessionOGS(
                public_key=PUBLIC_KEY,
                seed=SEED,
                targets=None,
                host="host",
            )
        with pytest.raises(TypeError):
            NotifySessionOGS(
                public_key=PUBLIC_KEY,
                seed=SEED,
                targets=["!!invalid!!"],
                host="host",
            )
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_send_success(mock_post):
        """A 201 Created response is treated as success."""
    
        def _mk_resp(code=requests.codes.created):
            r = mock.Mock()
            r.status_code = code
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
        )
        assert obj.notify(body="Hello SOGS") is True
        assert mock_post.call_count == 1
    
        # HTTP 200 (OK) is also treated as success.
        mock_post.return_value = _mk_resp(requests.codes.ok)
        assert obj.notify(body="Hello SOGS") is True
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_send_multi_room(mock_post):
        """Sending to two rooms makes two POST requests."""
    
        def _mk_resp(code=requests.codes.created):
            r = mock.Mock()
            r.status_code = code
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM, ROOM2],
            host="open.getsession.org",
        )
        assert obj.notify(body="Multi") is True
        assert mock_post.call_count == 2
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_send_partial_failure(mock_post):
        """Failure on any room causes the overall notify() to return False."""
    
        def _mk_resp(code):
            r = mock.Mock()
            r.status_code = code
            r.content = b""
            return r
    
        mock_post.side_effect = [
            _mk_resp(requests.codes.created),
            _mk_resp(requests.codes.forbidden),
        ]
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM, ROOM2],
            host="open.getsession.org",
        )
        assert obj.notify(body="Partial") is False
        assert mock_post.call_count == 2
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_http_error(mock_post):
        """Non-2xx responses cause send() to return False."""
    
        def _mk_resp(code):
            r = mock.Mock()
            r.status_code = code
            r.content = b""
            return r
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
        )
    
        for code in (
            requests.codes.bad_request,
            requests.codes.unauthorized,
            requests.codes.forbidden,
            requests.codes.not_found,
            requests.codes.internal_server_error,
            999,
        ):
            mock_post.reset_mock()
            mock_post.return_value = _mk_resp(code)
            assert obj.notify(body="Error") is False
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_request_exception(mock_post):
        """requests.RequestException causes send() to return False."""
        mock_post.side_effect = requests.RequestException("network error")
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
        )
        assert obj.notify(body="Error") is False
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_http_insecure(mock_post):
        """session:// sends over plain HTTP."""
    
        def _mk_resp():
            r = mock.Mock()
            r.status_code = requests.codes.created
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        obj = Apprise()
        assert obj.add(f"session://{PUBLIC_KEY}:{SEED}@open.getsession.org/{ROOM}")
        assert obj.notify(body="Plain HTTP") is True
    
        # Confirm the request was sent to http:// not https://.
        url_called = mock_post.call_args[0][0]
        assert url_called.startswith("http://")
        assert "https://" not in url_called
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_url_and_privacy(mock_post):
        """url() round-trips and masks seed in privacy mode."""
    
        def _mk_resp():
            r = mock.Mock()
            r.status_code = requests.codes.created
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
        )
    
        full_url = obj.url(privacy=False)
        assert SEED in full_url
        assert PUBLIC_KEY in full_url
    
        priv_url = obj.url(privacy=True)
        assert SEED not in priv_url
        assert PUBLIC_KEY in priv_url
    
        # Round-trip.
        parsed = NotifySessionOGS.parse_url(full_url)
        obj2 = NotifySessionOGS(**parsed)
        assert obj.url_identifier == obj2.url_identifier
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_custom_port(mock_post):
        """Custom port is encoded in the URL and used in requests."""
    
        def _mk_resp():
            r = mock.Mock()
            r.status_code = requests.codes.created
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
            port=8443,
        )
        assert ":8443" in obj.url()
        assert obj.notify(body="custom port") is True
    
        url_called = mock_post.call_args[0][0]
        assert ":8443" in url_called
    
    
    def test_plugin_sogs_sogs_alias():
        """sogs:// is accepted as an alias for sessions:// (HTTPS)."""
    
        # parse_url must recognise the sogs:// schema.
        url = f"sogs://{PUBLIC_KEY}:{SEED}@open.getsession.org/{ROOM}"
        results = NotifySessionOGS.parse_url(url)
        assert results is not None
        assert results["public_key"] == PUBLIC_KEY
        assert results["seed"] == SEED
        assert ROOM in results["targets"]
    
        # The parsed schema must be "sogs" (the alias used in the input URL).
        assert results["schema"] == "sogs"
    
    
    def test_plugin_sogs_parse_url():
        """parse_url extracts all expected fields."""
    
        # Canonical format: public_key:seed in authority, rooms in path.
        url = f"sessions://{PUBLIC_KEY}:{SEED}@open.getsession.org/{ROOM}/{ROOM2}"
        results = NotifySessionOGS.parse_url(url)
        assert results["public_key"] == PUBLIC_KEY
        assert results["seed"] == SEED
        assert ROOM in results["targets"]
        assert ROOM2 in results["targets"]
    
        # ?key= query param provides the public key (no user in URL).
        url2 = (
            f"sessions://open.getsession.org/{ROOM}?key={PUBLIC_KEY}&seed={SEED}"
        )
        results2 = NotifySessionOGS.parse_url(url2)
        assert results2["public_key"] == PUBLIC_KEY
        assert results2["seed"] == SEED
    
        # ?public_key= is the Session-native alias and overrides ?key= when both
        # are present.
        url2b = (
            f"sessions://open.getsession.org/{ROOM}"
            f"?public_key={PUBLIC_KEY}&seed={SEED}"
        )
        results2b = NotifySessionOGS.parse_url(url2b)
        assert results2b["public_key"] == PUBLIC_KEY
        assert results2b["seed"] == SEED
    
        # ?to= alias populates targets (no room in path).
        url3 = f"sessions://{PUBLIC_KEY}:{SEED}@open.getsession.org?to={ROOM}"
        results3 = NotifySessionOGS.parse_url(url3)
        assert ROOM in results3["targets"]
    
        # No password field -> seed is None.
        url4 = f"sessions://{PUBLIC_KEY}@open.getsession.org/{ROOM}"
        results4 = NotifySessionOGS.parse_url(url4)
        assert results4["seed"] is None
    
        # Empty URL -> None (host is required; verify_host=True is the default).
        assert NotifySessionOGS.parse_url("sessions://") is None
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_invalid_room_token(mock_post):
        """Invalid room tokens are stored in invalid_rooms and survive url()."""
    
        def _mk_resp():
            r = mock.Mock()
            r.status_code = requests.codes.created
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        # One valid + one invalid: must succeed with at least one valid room.
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM, "!!!bad!!!"],
            host="open.getsession.org",
        )
        assert obj.rooms == [ROOM]
        assert len(obj.invalid_rooms) == 1
    
        # Invalid rooms survive url() round-trip (no silent data loss).
        full_url = obj.url()
        parsed = NotifySessionOGS.parse_url(full_url)
        obj2 = NotifySessionOGS(**parsed)
        assert obj.url_identifier == obj2.url_identifier
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_url_identifier(mock_post):
        """url_identifier distinguishes connections by public_key and host."""
    
        def _mk_obj(host, public_key=PUBLIC_KEY):
            return NotifySessionOGS(
                public_key=public_key,
                seed=SEED,
                targets=[ROOM],
                host=host,
            )
    
        obj_a = _mk_obj("server-a.example.com")
        obj_b = _mk_obj("server-b.example.com")
        obj_c = _mk_obj("server-a.example.com", public_key="d" * 64)
    
        # Same host + same public_key -> same identifier.
        obj_a2 = _mk_obj("server-a.example.com")
        assert obj_a.url_identifier == obj_a2.url_identifier
    
        # Different host -> different identifier.
        assert obj_a.url_identifier != obj_b.url_identifier
    
        # Same host, different public_key -> different identifier.
        assert obj_a.url_identifier != obj_c.url_identifier
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_auth_headers(mock_post):
        """X-SOGS-* headers are present and well-formed on every request."""
    
        def _mk_resp():
            r = mock.Mock()
            r.status_code = requests.codes.created
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
        )
        assert obj.notify(body="Auth test") is True
    
        call_kwargs = mock_post.call_args[1]
        headers = call_kwargs.get("headers", {})
    
        # All four X-SOGS-* headers must be present.
        assert "X-SOGS-Pubkey" in headers
        assert "X-SOGS-Nonce" in headers
        assert "X-SOGS-Timestamp" in headers
        assert "X-SOGS-Signature" in headers
    
        # Pubkey starts with "00" (unblinded) then 64 hex chars.
        pk = headers["X-SOGS-Pubkey"]
        assert pk.startswith("00")
        assert len(pk) == 66  # "00" + 32-byte pubkey hex = 2 + 64
    
        # Timestamp is a digit string.
        assert headers["X-SOGS-Timestamp"].isdigit()
    
        # Verify the no-body branch of _sogs_auth_headers (body_bytes=None
        # skips the blake2b step; the returned dict must still be well-formed).
        obj2 = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
        )
        no_body_headers = obj2._sogs_auth_headers("GET", "/room/test")
        assert "X-SOGS-Pubkey" in no_body_headers
        assert "X-SOGS-Nonce" in no_body_headers
        assert "X-SOGS-Timestamp" in no_body_headers
        assert "X-SOGS-Signature" in no_body_headers
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_response_unparseable_json(mock_post):
        """Unparseable response body is handled gracefully."""
        r = mock.Mock()
        r.status_code = requests.codes.created
        r.content = b"not-json"
        mock_post.return_value = r
    
        obj = NotifySessionOGS(
            public_key=PUBLIC_KEY,
            seed=SEED,
            targets=[ROOM],
            host="open.getsession.org",
        )
        assert obj.notify(body="Hello") is True
    
    
    def test_plugin_sogs_build_session_message():
        """_build_session_message produces valid padded protobuf bytes."""
        msg = _build_session_message("hello")
    
        # Must be bytes and non-empty.
        assert isinstance(msg, bytes)
        assert len(msg) > 0
    
        # Ends with the Session padding marker 0x80.
        assert msg[-1] == 0x80
    
        # Starts with protobuf tag 0x0A (field 1, wire type 2).
        assert msg[0] == 0x0A
    
    
    def test_plugin_sogs_encode_varint():
        """_encode_varint encodes small and multi-byte integers correctly."""
        assert _encode_varint(0) == b"\x00"
        assert _encode_varint(1) == b"\x01"
        assert _encode_varint(127) == b"\x7f"
        assert _encode_varint(128) == b"\x80\x01"
        assert _encode_varint(300) == b"\xac\x02"
    
    
    def test_plugin_sogs_ld_field():
        """_ld_field encodes a length-delimited protobuf field."""
        encoded = _ld_field(1, b"hi")
        assert encoded == b"\x0a\x02hi"
    
    
    def test_plugin_sogs_runtime_deps():
        """runtime_deps() returns the cryptography package name."""
        deps = NotifySessionOGS.runtime_deps()
        assert "cryptography" in deps
    
    
    @pytest.mark.skipif(
        "cryptography" not in sys.modules,
        reason="Requires cryptography",
    )
    @mock.patch("requests.post")
    def test_plugin_sogs_apprise_integration(mock_post):
        """Apprise.add() and Apprise.notify() work end-to-end."""
    
        def _mk_resp():
            r = mock.Mock()
            r.status_code = requests.codes.created
            r.content = b'{"id": 1}'
            return r
    
        mock_post.return_value = _mk_resp()
    
        app = Apprise()
        assert app.add(BASE_URL)
        assert app.notify(title="Title", body="Body") is True
        assert mock_post.call_count == 1
    caronc-apprise-182f859/tests/test_plugin_sparkpost.py000066400000000000000000000401501524161175200230730ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    from json import dumps
    
    # Disable logging for a cleaner testing output
    import logging
    import os
    from unittest import mock
    
    from helpers import AppriseURLTester
    import pytest
    import requests
    
    from apprise import Apprise, AppriseAttachment, NotifyType
    from apprise.plugins.sparkpost import NotifySparkPost
    
    logging.disable(logging.CRITICAL)
    
    # Attachment Directory
    TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var")
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "sparkpost://",
            {
                "instance": TypeError,
            },
        ),
        (
            "sparkpost://:@/",
            {
                "instance": TypeError,
            },
        ),
        # No Token specified
        (
            "sparkpost://user@localhost.localdomain",
            {
                "instance": TypeError,
            },
        ),
        # Token is valid, but no user name specified
        (
            "sparkpost://localhost.localdomain/{}".format("a" * 32),
            {
                "instance": TypeError,
            },
        ),
        # Invalid from email address
        (
            'sparkpost://"@localhost.localdomain/{}'.format("b" * 32),
            {
                "instance": TypeError,
            },
        ),
        # No To email address, but everything else is valid
        (
            "sparkpost://user@localhost.localdomain/{}".format("c" * 32),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        (
            "sparkpost://user@localhost.localdomain/{}?format=markdown".format(
                "d" * 32
            ),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        (
            "sparkpost://user@localhost.localdomain/{}?format=html".format(
                "d" * 32
            ),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
                "force_debug": True,
            },
        ),
        (
            "sparkpost://user@localhost.localdomain/{}?format=text".format(
                "d" * 32
            ),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # valid url with region specified (case insensitve)
        (
            "sparkpost://user@localhost.localdomain/{}?region=uS".format("d" * 32),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # valid url with region specified (case insensitve)
        (
            "sparkpost://user@localhost.localdomain/{}?region=EU".format("e" * 32),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # headers
        (
            "sparkpost://user@localhost.localdomain/{}"
            "?+X-Customer-Campaign-ID=Apprise".format("f" * 32),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # template tokens
        (
            "sparkpost://user@localhost.localdomain/{}"
            "?:name=Chris&:status=admin".format("g" * 32),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # bcc and cc
        (
            "sparkpost://user@localhost.localdomain/{}"
            "?bcc=user@example.com&cc=user2@example.com".format("h" * 32),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # invalid url with region specified (case insensitve)
        (
            "sparkpost://user@localhost.localdomain/{}?region=invalid".format(
                "a" * 32
            ),
            {
                "instance": TypeError,
            },
        ),
        # One 'To' Email address
        (
            "sparkpost://user@localhost.localdomain/{}/test@example.com".format(
                "a" * 32
            ),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # Invalid 'To' Email address
        (
            "sparkpost://user@localhost.localdomain/{}/invalid".format("i" * 32),
            {
                "instance": NotifySparkPost,
                # Expected notify() response
                "notify_response": False,
            },
        ),
        # Multiple 'To', 'Cc', and 'Bcc' addresses (with invalid ones)
        (
            "sparkpost://user@example.com/{}/{}?bcc={}&cc={}".format(
                "j" * 32,
                "/".join(
                    ("user1@example.com", "invalid", "User2:user2@example.com")
                ),
                ",".join(("user3@example.com", "i@v", "User1:user1@example.com")),
                ",".join(("user4@example.com", "g@r@b", "Da:user5@example.com")),
            ),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        (
            "sparkpost://user@localhost.localdomain/{}?to=test@example.com".format(
                "k" * 32
            ),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # One To Email address, a from name specified too
        (
            "sparkpost://user@localhost.localdomain/{}/"
            'test@example.com?name="Frodo"'.format("l" * 32),
            {
                "instance": NotifySparkPost,
                "requests_response_text": {
                    "results": {
                        "total_rejected_recipients": 0,
                        "total_accepted_recipients": 1,
                        "id": "11668787484950529",
                    }
                },
            },
        ),
        # Test invalid JSON response
        (
            "sparkpost://user@localhost.localdomain/{}".format("m" * 32),
            {
                "instance": NotifySparkPost,
                "requests_response_text": "{",
            },
        ),
        (
            "sparkpost://user@localhost.localdomain/{}".format("n" * 32),
            {
                "instance": NotifySparkPost,
                # force a failure
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "sparkpost://user@localhost.localdomain/{}".format("o" * 32),
            {
                "instance": NotifySparkPost,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "sparkpost://user@localhost.localdomain/{}".format("p" * 32),
            {
                "instance": NotifySparkPost,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_sparkpost_urls():
        """NotifySparkPost() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    @mock.patch("requests.post")
    def test_plugin_sparkpost_throttling(mock_post):
        """NotifySparkPost() Throttling."""
    
        NotifySparkPost.sparkpost_retry_wait_sec = 0.1
        NotifySparkPost.sparkpost_retry_attempts = 3
    
        # API Key
        apikey = "abc123"
        user = "user"
        host = "example.com"
        targets = f"{user}@{host}"
    
        # Exception should be thrown about the fact no user was specified
        with pytest.raises(TypeError):
            NotifySparkPost(apikey=apikey, targets=targets, host=host)
    
        # Exception should be thrown about the fact no private key was specified
        with pytest.raises(TypeError):
            NotifySparkPost(apikey=None, targets=targets, user=user, host=host)
    
        okay_response = requests.Request()
        okay_response.status_code = requests.codes.ok
        okay_response.content = dumps(
            {
                "results": {
                    "total_rejected_recipients": 0,
                    "total_accepted_recipients": 1,
                    "id": "11668787484950529",
                }
            }
        )
    
        retry_response = requests.Request()
        retry_response.status_code = requests.codes.too_many_requests
        retry_response.content = dumps(
            {
                "errors": [
                    {
                        "description": (
                            "Unconfigured or unverified sending domain."
                        ),
                        "code": "7001",
                        "message": "Invalid domain",
                    }
                ]
            }
        )
    
        # Prepare Mock (force 2 retry responses and then one okay)
        mock_post.side_effect = (retry_response, retry_response, okay_response)
    
        obj = Apprise.instantiate(
            f"sparkpost://user@localhost.localdomain/{apikey}"
        )
        assert isinstance(obj, NotifySparkPost)
    
        # We'll successfully perform the notification as we're within
        # our retry limit
        assert obj.notify("test") is True
    
        mock_post.reset_mock()
        mock_post.side_effect = (retry_response, retry_response, retry_response)
    
        # Now we are less than our expected limit check so we will fail
        assert obj.notify("test") is False
    
    
    @mock.patch("requests.post")
    def test_plugin_sparkpost_attachments(mock_post):
        """NotifySparkPost() Attachments."""
        NotifySparkPost.sparkpost_retry_wait_sec = 0.1
        NotifySparkPost.sparkpost_retry_attempts = 3
    
        okay_response = requests.Request()
        okay_response.status_code = requests.codes.ok
        okay_response.content = dumps(
            {
                "results": {
                    "total_rejected_recipients": 0,
                    "total_accepted_recipients": 1,
                    "id": "11668787484950529",
                }
            }
        )
    
        # Assign our mock object our return value
        mock_post.return_value = okay_response
    
        # API Key
        apikey = "abc123"
    
        obj = Apprise.instantiate(
            f"sparkpost://user@localhost.localdomain/{apikey}"
        )
        assert isinstance(obj, NotifySparkPost)
    
        # Test Valid Attachment
        path = os.path.join(TEST_VAR_DIR, "apprise-test.gif")
        attach = AppriseAttachment(path)
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
    
        # Test invalid attachment
        path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg")
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=path,
            )
            is False
        )
    
        with mock.patch("base64.b64encode", side_effect=OSError()):
            # We can't send the message if we fail to parse the data
            assert (
                obj.notify(
                    body="body",
                    title="title",
                    notify_type=NotifyType.INFO,
                    attach=attach,
                )
                is False
            )
    
        obj = Apprise.instantiate(
            f"sparkpost://no-reply@example.com/{apikey}/"
            "user1@example.com/user2@example.com?batch=yes"
        )
        assert isinstance(obj, NotifySparkPost)
    
        # As a batch mode, both emails can be lumped into 1
        assert len(obj) == 1
    
        # Force our batch to break into separate messages
        obj.default_batch_size = 1
    
        # We'll send 2 messages no
        assert len(obj) == 2
        mock_post.reset_mock()
    
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
        assert mock_post.call_count == 2
    
        # single batch
        mock_post.reset_mock()
        # We'll send 1 message
        obj.default_batch_size = 2
    
        assert (
            obj.notify(
                body="body",
                title="title",
                notify_type=NotifyType.INFO,
                attach=attach,
            )
            is True
        )
        assert mock_post.call_count == 1
    
    
    def test_plugin_sparkpost_cc_bcc_invalid_branch():
        """NotifySparkPost() CC/BCC validation: invalid entries are dropped."""
    
        # Build a NotifySparkPost with:
        #   cc  = one valid address + one that parse_emails passes through but
        #         is_email rejects (hits the FALSE branch in the CC loop).
        #         A list must be used -- parse_emails filters invalid tokens out
        #         of comma-separated strings before is_email is ever called.
        #   bcc = same, for the FALSE branch in the BCC loop
        obj = NotifySparkPost(
            apikey="abc123",
            targets=["user@example.com"],
            cc=["good@example.com", "notanemail"],
            bcc=["bcc@example.com", "alsonotanemail"],
            user="user",
            host="example.com",
        )
    
        # Valid CC entry was added; invalid one was silently dropped
        assert "good@example.com" in obj.cc
        assert len(obj.cc) == 1
    
        # Valid BCC entry was added; invalid one was silently dropped
        assert "bcc@example.com" in obj.bcc
        assert len(obj.bcc) == 1
    caronc-apprise-182f859/tests/test_plugin_spike.py000066400000000000000000000056001524161175200221610ustar00rootroot00000000000000#
    # BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    import logging
    
    from helpers import AppriseURLTester
    import requests
    
    from apprise.plugins.spike import NotifySpike
    
    logging.disable(logging.CRITICAL)
    
    apprise_url_tests = (
        (
            "spike://",
            {
                "instance": TypeError,
            },
        ),
        (
            "spike://invalid-key",
            {
                "instance": TypeError,
            },
        ),
        (
            "spike://1234567890abcdef1234567890abcdef",
            {
                "instance": NotifySpike,
                "privacy_url": "spike://****/",
            },
        ),
        (
            "spike://?token=1234567890abcdef1234567890abcdef",
            {
                "instance": NotifySpike,
                "privacy_url": "spike://****/",
            },
        ),
        (
            "https://api.spike.sh/v1/alerts/1234567890abcdef1234567890abcdef",
            {
                "instance": NotifySpike,
            },
        ),
        (
            "spike://1234567890abcdef1234567890abcdef",
            {
                "instance": NotifySpike,
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "spike://1234567890abcdef1234567890abcdef",
            {
                "instance": NotifySpike,
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "spike://ffffffffffffffffffffffffffffffff",
            {
                "instance": NotifySpike,
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_spike_urls():
        AppriseURLTester(tests=apprise_url_tests).run_all()
    caronc-apprise-182f859/tests/test_plugin_splunk.py000066400000000000000000000161251524161175200223660ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    
    from helpers import AppriseURLTester
    import requests
    
    from apprise.plugins.splunk import NotifySplunk
    
    logging.disable(logging.CRITICAL)
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "splunk://",
            {
                "instance": TypeError,
            },
        ),
        (
            "splunk://:@/",
            {
                "instance": TypeError,
            },
        ),
        (
            "splunk://routekey@%badapi%",
            {
                "instance": TypeError,
            },
        ),
        (
            "splunk://abc123",
            {
                # No route key provided
                "instance": TypeError,
            },
        ),
        (
            "splunk://%badroute%@apikey",
            {
                "instance": TypeError,
            },
        ),
        (
            "splunk://?apikey=abc123&routing_key=db",
            {
                # We're good
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://route@abc123/entity_id",
            {
                # We're good
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://route@abc123/?entity_id=my_entity",
            {
                # We're good
                "instance": NotifySplunk,
            },
        ),
        (
            # An entity_id containing characters that are reserved in a URL
            # (?, #, %, /) must survive a url() round-trip without being
            # truncated or corrupted.
            "splunk://route@abc123/?entity_id=a%3Fb%23c%25d%2Fe",
            {
                # We're good
                "instance": NotifySplunk,
                # Our privacy url confirms the entity_id was quoted (not
                # dropped) when the url() was re-assembled
                "privacy_url": "splunk://route@a...3/a%3Fb%23c%25d/e",
            },
        ),
        # Support legacy URL
        (
            (
                "https://alert.victorops.com/integrations/generic/20131114/"
                "alert/apikey/routing_key"
            ),
            {
                # We're good
                "instance": NotifySplunk,
            },
        ),
        # Support legacy URL (with entity id provided)
        (
            (
                "https://alert.victorops.com/integrations/generic/20131114/"
                "alert/apikey/routing_key/entity_id"
            ),
            {
                # We're good
                "instance": NotifySplunk,
            },
        ),
        # support victorops:// too!
        (
            "victorops://?apikey=abc123&route=db",
            {
                # We're good
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://?apikey=abc123&route=db",
            {
                # We're good
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=recovery",
            {
                # Always Recovery Alias
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=resolve",
            {
                # Always Recovery Alias
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=r",
            {
                # Always Recovery (short form)
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=acknowledgement",
            {
                # Always Acknowledgement
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=ack",
            {
                # Always Acknowledgement (short form)
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=critical",
            {
                # Always Critical
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=crit",
            {
                # Always Critical (short form)
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=warning",
            {
                # Always Warning
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=warn",
            {
                # Always Warning (short form)
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=info",
            {
                # Always INFO
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=i",
            {
                # Always INFO (short form)
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?action=invalid",
            {
                # Invalid Action
                "instance": TypeError,
            },
        ),
        (
            "splunk://db@apikey?:warning=critical",
            {
                # Map warnings to CRITICAL
                "instance": NotifySplunk,
            },
        ),
        (
            "splunk://db@apikey?:invalid=critical",
            {
                # A bad Apprise Notification Type was provided
                "instance": TypeError,
            },
        ),
        (
            "splunk://db@apikey?:warning=invalid",
            {
                # A bad Splunk Notification Type was provided
                "instance": TypeError,
            },
        ),
        (
            "splunk://db@apikey",
            {
                "instance": NotifySplunk,
                # force a failure
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "splunk://db@apikey",
            {
                "instance": NotifySplunk,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "splunk://db@token",
            {
                "instance": NotifySplunk,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_splunk_urls():
        """NotifySplunk() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    caronc-apprise-182f859/tests/test_plugin_spugpush.py000066400000000000000000000056601524161175200227320ustar00rootroot00000000000000#
    # BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    import logging
    
    from helpers import AppriseURLTester
    import requests
    
    from apprise.plugins.spugpush import NotifySpugpush
    
    logging.disable(logging.CRITICAL)
    
    apprise_url_tests = (
        (
            "spugpush://",
            {
                "instance": TypeError,
            },
        ),
        (
            "spugpush://invalid!",
            {
                "instance": TypeError,
            },
        ),
        (
            "spugpush://abc123def456ghi789jkl012mno345pq",
            {
                "instance": NotifySpugpush,
                "privacy_url": "spugpush://****/",
            },
        ),
        (
            "spugpush://?token=abc123def456ghi789jkl012mno345pq",
            {
                "instance": NotifySpugpush,
                "privacy_url": "spugpush://****/",
            },
        ),
        (
            "https://push.spug.dev/send/abc123def456ghi789jkl012mno345pq",
            {
                "instance": NotifySpugpush,
            },
        ),
        (
            "spugpush://abc123def456ghi789jkl012mno345pq",
            {
                "instance": NotifySpugpush,
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "spugpush://abc123def456ghi789jkl012mno345pq",
            {
                "instance": NotifySpugpush,
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "spugpush://ffffffffffffffffffffffffffffffff",
            {
                "instance": NotifySpugpush,
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_spugpush_urls():
        AppriseURLTester(tests=apprise_url_tests).run_all()
    caronc-apprise-182f859/tests/test_plugin_stackfield.py000066400000000000000000000206401524161175200231600ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    from unittest import mock
    from urllib.parse import urlparse
    
    from helpers import AppriseURLTester
    import pytest
    import requests
    
    from apprise import Apprise
    from apprise.plugins.stackfield import NotifyStackfield
    
    logging.disable(logging.CRITICAL)
    
    # A valid Stackfield webhook token (UUID format)
    TEST_TOKEN = "11111111-2222-3333-4444-555555555555"
    
    apprise_url_tests = (
        # Missing token
        (
            "stackfield://",
            {
                "instance": TypeError,
            },
        ),
        # Invalid token -- not a UUID
        (
            "stackfield://not-a-valid-uuid",
            {
                "instance": TypeError,
            },
        ),
        # Invalid token -- wrong UUID length
        (
            "stackfield://11111111-2222-3333-4444-55555555555",
            {
                "instance": TypeError,
            },
        ),
        # Valid token in URL path
        (
            f"stackfield://{TEST_TOKEN}",
            {
                "instance": NotifyStackfield,
                "privacy_url": "stackfield://****/",
            },
        ),
        # Valid token via ?token= query parameter
        (
            f"stackfield://?token={TEST_TOKEN}",
            {
                "instance": NotifyStackfield,
                "privacy_url": "stackfield://****/",
            },
        ),
        # Native Stackfield webhook URL
        (
            f"https://www.stackfield.com/apiwh/{TEST_TOKEN}",
            {
                "instance": NotifyStackfield,
            },
        ),
        # Native URL without www prefix
        (
            f"https://stackfield.com/apiwh/{TEST_TOKEN}",
            {
                "instance": NotifyStackfield,
            },
        ),
        # HTTP 500 response
        (
            f"stackfield://{TEST_TOKEN}",
            {
                "instance": NotifyStackfield,
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        # Unknown HTTP error code
        (
            f"stackfield://{TEST_TOKEN}",
            {
                "instance": NotifyStackfield,
                "response": False,
                "requests_response_code": 999,
            },
        ),
        # RequestException
        (
            "stackfield://abcdef01-2345-6789-abcd-ef0123456789",
            {
                "instance": NotifyStackfield,
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_stackfield_urls():
        """Run the standard AppriseURLTester against all URL test cases."""
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    def test_plugin_stackfield_init():
        """Test NotifyStackfield init, url(), and url_identifier."""
    
        # Valid instantiation
        obj = NotifyStackfield(token=TEST_TOKEN)
        assert isinstance(obj, NotifyStackfield)
    
        # url_identifier returns (schema, token)
        assert obj.url_identifier == ("stackfield", TEST_TOKEN)
    
        # url() round-trips correctly
        url = obj.url()
        assert url.startswith("stackfield://")
        assert TEST_TOKEN in url
    
        # privacy URL masks the token
        privacy = obj.url(privacy=True)
        assert TEST_TOKEN not in privacy
        assert "stackfield://" in privacy
    
        # Round-trip: parse_url(url()) re-creates the same object
        result = NotifyStackfield.parse_url(obj.url())
        assert result is not None
        obj2 = NotifyStackfield(**result)
        assert obj2.url_identifier == obj.url_identifier
    
        # Missing token
        with pytest.raises(TypeError):
            NotifyStackfield(token=None)
    
        # Empty token
        with pytest.raises(TypeError):
            NotifyStackfield(token="")
    
        # Invalid token format (not a UUID)
        with pytest.raises(TypeError):
            NotifyStackfield(token="not-a-valid-uuid-at-all")
    
        # Wrong UUID length (too short)
        with pytest.raises(TypeError):
            NotifyStackfield(token="11111111-2222-3333-4444-55555555555")
    
    
    @mock.patch("requests.post")
    def test_plugin_stackfield_send(mock_post):
        """Test send() HTTP success, HTTP error, and RequestException paths."""
    
        def _mk_resp(code=requests.codes.ok):
            r = mock.Mock()
            r.status_code = code
            r.content = b""
            return r
    
        obj = NotifyStackfield(token=TEST_TOKEN)
    
        # Successful notification
        mock_post.return_value = _mk_resp(requests.codes.ok)
        assert obj.send(body="Hello, Stackfield!") is True
        assert mock_post.call_count == 1
    
        # Verify POST target hostname
        call_url = mock_post.call_args[0][0]
        assert urlparse(call_url).hostname == "www.stackfield.com"
    
        mock_post.reset_mock()
    
        # HTTP 500 -> send() returns False
        mock_post.return_value = _mk_resp(requests.codes.internal_server_error)
        assert obj.send(body="test") is False
    
        mock_post.reset_mock()
    
        # Unknown HTTP code 999 -> send() returns False
        mock_post.return_value = _mk_resp(999)
        assert obj.send(body="test") is False
    
        mock_post.reset_mock()
    
        # RequestException -> send() returns False
        mock_post.side_effect = requests.RequestException("connection error")
        assert obj.send(body="test") is False
    
    
    def test_plugin_stackfield_url_parsing():
        """Test parse_url() and parse_native_url() edge cases."""
    
        # Token from URL path (host position)
        result = NotifyStackfield.parse_url(f"stackfield://{TEST_TOKEN}")
        assert result is not None
        assert result["token"] == TEST_TOKEN
    
        # Token from ?token= query string
        result = NotifyStackfield.parse_url(f"stackfield://?token={TEST_TOKEN}")
        assert result is not None
        assert result["token"] == TEST_TOKEN
    
        # ?token= wins over host position when both are present
        other = "abcdef01-2345-6789-abcd-ef0123456789"
        result = NotifyStackfield.parse_url(
            f"stackfield://{TEST_TOKEN}?token={other}"
        )
        assert result is not None
        assert result["token"] == other
    
        # parse_native_url: full https://www.stackfield.com/apiwh/TOKEN
        result = NotifyStackfield.parse_native_url(
            f"https://www.stackfield.com/apiwh/{TEST_TOKEN}"
        )
        assert result is not None
        assert result["token"] == TEST_TOKEN
    
        # parse_native_url: without www
        result = NotifyStackfield.parse_native_url(
            f"https://stackfield.com/apiwh/{TEST_TOKEN}"
        )
        assert result is not None
        assert result["token"] == TEST_TOKEN
    
        # parse_native_url: http variant
        result = NotifyStackfield.parse_native_url(
            f"http://www.stackfield.com/apiwh/{TEST_TOKEN}"
        )
        assert result is not None
        assert result["token"] == TEST_TOKEN
    
        # parse_native_url: invalid token format -> returns None
        result = NotifyStackfield.parse_native_url(
            "https://www.stackfield.com/apiwh/not-a-valid-uuid"
        )
        assert result is None
    
        # parse_native_url: unrelated URL -> returns None
        result = NotifyStackfield.parse_native_url(
            "https://example.com/webhook/12345"
        )
        assert result is None
    
    
    @mock.patch("requests.post")
    def test_plugin_stackfield_apprise_integration(mock_post):
        """Test that Apprise correctly loads and fires a Stackfield URL."""
    
        r = mock.Mock()
        r.status_code = requests.codes.ok
        r.content = b""
        mock_post.return_value = r
    
        a = Apprise()
        assert a.add(f"stackfield://{TEST_TOKEN}") is True
        assert a.notify(title="Test", body="Integration test") is True
        assert mock_post.call_count == 1
    caronc-apprise-182f859/tests/test_plugin_streamlabs.py000066400000000000000000000131141524161175200232020ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    
    from helpers import AppriseURLTester
    
    from apprise.plugins.streamlabs import NotifyStreamlabs
    
    logging.disable(logging.CRITICAL)
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "strmlabs://",
            {
                # No Access Token specified
                "instance": TypeError,
            },
        ),
        (
            "strmlabs://a_bd_/",
            {
                # invalid Access Token
                "instance": TypeError,
            },
        ),
        (
            "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso",
            {
                # access token
                "instance": NotifyStreamlabs,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "strmlabs://I...o",
            },
        ),
        # Test incorrect currency
        (
            "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?currency=ABCD",
            {
                "instance": TypeError,
            },
        ),
        # Test complete params - donations
        (
            (
                "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/"
                "?name=tt&identifier=pyt&amount=20¤cy=USD&call=donations"
            ),
            {
                "instance": NotifyStreamlabs,
            },
        ),
        # Test complete params - donations
        (
            (
                "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/"
                "?image_href=https://example.org/rms.jpg"
                "&sound_href=https://example.org/rms.mp3"
            ),
            {
                "instance": NotifyStreamlabs,
            },
        ),
        # Test complete params - alerts
        (
            (
                "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/"
                "?duration=1000&image_href=&"
                "sound_href=&alert_type=donation&special_text_color=crimson"
            ),
            {
                "instance": NotifyStreamlabs,
            },
        ),
        # Test incorrect call
        (
            (
                "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/"
                "?name=tt&identifier=pyt&amount=20¤cy=USD&call=rms"
            ),
            {
                "instance": TypeError,
            },
        ),
        # Test incorrect alert_type
        (
            (
                "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/"
                "?name=tt&identifier=pyt&amount=20¤cy=USD&alert_type=rms"
            ),
            {
                "instance": TypeError,
            },
        ),
        # Test incorrect name
        (
            "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?name=t",
            {
                "instance": TypeError,
            },
        ),
        (
            "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?call=donations",
            {
                "instance": NotifyStreamlabs,
                # A failure has status set to zero
                # Test without an 'error' flag
                "requests_response_text": {
                    "status": 0,
                },
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?call=alerts",
            {
                "instance": NotifyStreamlabs,
                # A failure has status set to zero
                # Test without an 'error' flag
                "requests_response_text": {
                    "status": 0,
                },
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?call=alerts",
            {
                "instance": NotifyStreamlabs,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
        (
            "strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?call=donations",
            {
                "instance": NotifyStreamlabs,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_streamlabs_urls():
        """NotifyStreamlabs() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    caronc-apprise-182f859/tests/test_plugin_synology.py000066400000000000000000000144121524161175200227320ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    from unittest import mock
    
    from helpers import AppriseURLTester
    import requests
    
    from apprise.plugins.synology import NotifySynology
    
    logging.disable(logging.CRITICAL)
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "synology://:@/",
            {
                "instance": None,
            },
        ),
        (
            "synology://",
            {
                "instance": None,
            },
        ),
        (
            "synologys://",
            {
                "instance": None,
            },
        ),
        (
            "synology://localhost/token",
            {
                "instance": NotifySynology,
            },
        ),
        (
            "synology://localhost/token?file_url=http://reddit.com/test.jpg",
            {
                "instance": NotifySynology,
            },
        ),
        (
            "synology://user:pass@localhost/token",
            {
                "instance": NotifySynology,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "synology://user:****@localhost/t...n",
            },
        ),
        # No token was specified
        (
            "synology://user@localhost",
            {
                "instance": TypeError,
            },
        ),
        (
            "synology://user@localhost/token",
            {
                "instance": NotifySynology,
            },
        ),
        # Continue testing other cases
        (
            "synology://localhost:8080/token",
            {
                "instance": NotifySynology,
            },
        ),
        (
            "synology://user:pass@localhost:8080/token",
            {
                "instance": NotifySynology,
            },
        ),
        (
            "synologys://localhost/token",
            {
                "instance": NotifySynology,
            },
        ),
        (
            "synologys://localhost/?token=mytoken",
            {
                "instance": NotifySynology,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "synologys://localhost/m...n",
            },
        ),
        (
            "synologys://user:pass@localhost/token",
            {
                "instance": NotifySynology,
            },
        ),
        (
            "synologys://localhost:8080/token/path/",
            {
                "instance": NotifySynology,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "synologys://localhost:8080/t...n/path/",
            },
        ),
        (
            "synologys://user:password@localhost:8080/token",
            {
                "instance": NotifySynology,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "synologys://user:****@localhost:8080/t...n",
            },
        ),
        # Test our Headers
        (
            "synology://localhost:8080/path?+HeaderKey=HeaderValue",
            {
                "instance": NotifySynology,
            },
        ),
        (
            "synology://user:pass@localhost:8081/token",
            {
                "instance": NotifySynology,
                # force a failure
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "synology://user:pass@localhost:8082/token",
            {
                "instance": NotifySynology,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "synology://user:pass@localhost:8083/token",
            {
                "instance": NotifySynology,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_custom_synology_urls():
        """NotifySynology() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    @mock.patch("requests.post")
    def test_plugin_synology_edge_cases(mock_post):
        """NotifySynology() Edge Cases."""
    
        # Prepare our response
        response = requests.Request()
        response.status_code = requests.codes.ok
    
        # Prepare Mock
        mock_post.return_value = response
    
        # This string also tests that type is set to nothing
        results = NotifySynology.parse_url(
            "synology://user:pass@localhost:8080/token"
        )
    
        assert isinstance(results, dict)
        assert results["user"] == "user"
        assert results["password"] == "pass"
        assert results["port"] == 8080
        assert results["host"] == "localhost"
        assert results["fullpath"] == ""
        assert results["path"] == "/"
        assert results["query"] == "token"
        assert results["schema"] == "synology"
        assert results["url"] == "synology://user:pass@localhost:8080/token"
    
        instance = NotifySynology(**results)
        assert isinstance(instance, NotifySynology)
    
        response = instance.send(title="title", body="body")
        assert response is True
        assert mock_post.call_count == 1
    
        details = mock_post.call_args_list[0]
        assert details[0][0] == "http://localhost:8080/webapi/entry.cgi"
    
        assert details[1]["data"].startswith("payload=")
    caronc-apprise-182f859/tests/test_plugin_syslog.py000066400000000000000000000123661524161175200223750ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    import sys
    from unittest import mock
    
    import pytest
    
    import apprise
    
    try:
        import syslog
    
    except ImportError:
        # Shim so that test cases can run in environments that
        # do not have syslog
        import types
    
        syslog = types.SimpleNamespace(
            LOG_PID=0x01,
            LOG_PERROR=0x02,
            LOG_INFO=6,
            LOG_NOTICE=5,
            LOG_CRIT=2,
            LOG_WARNING=4,
            LOG_KERN=0,
            LOG_USER=1,
            LOG_MAIL=2,
            LOG_DAEMON=3,
            LOG_AUTH=4,
            LOG_SYSLOG=5,
            LOG_LPR=6,
            LOG_NEWS=7,
            LOG_UUCP=8,
            LOG_CRON=9,
            LOG_LOCAL0=16,
            LOG_LOCAL1=17,
            LOG_LOCAL2=18,
            LOG_LOCAL3=19,
            LOG_LOCAL4=20,
            LOG_LOCAL5=21,
            LOG_LOCAL6=22,
            LOG_LOCAL7=23,
            openlog=lambda *a, **kw: None,
            syslog=lambda *a, **kw: None,
        )
        sys.modules["syslog"] = syslog
    
    
    logging.disable(logging.CRITICAL)
    
    from apprise.plugins.syslog import NotifySyslog  # noqa E402
    
    
    @mock.patch("syslog.syslog")
    @mock.patch("syslog.openlog")
    def test_plugin_syslog_by_url(openlog, syslog):
        """NotifySyslog() Apprise URLs."""
        # an invalid URL
        assert NotifySyslog.parse_url(object) is None
        assert NotifySyslog.parse_url(42) is None
        assert NotifySyslog.parse_url(None) is None
    
        obj = apprise.Apprise.instantiate("syslog://")
        assert obj.url().startswith("syslog://user")
        assert r"logpid=yes" in obj.url()
        assert r"logperror=no" in obj.url()
    
        # We do not support generation of a URL ID
        assert obj.url_id() is None
    
        assert isinstance(
            apprise.Apprise.instantiate("syslog://:@/"), NotifySyslog
        )
    
        obj = apprise.Apprise.instantiate("syslog://?logpid=no&logperror=yes")
        assert isinstance(obj, NotifySyslog)
        assert obj.url().startswith("syslog://user")
        assert r"logpid=no" in obj.url()
        assert r"logperror=yes" in obj.url()
    
        # Test sending a notification
        assert obj.notify("body") is True
        assert obj.notify(title="title", body="body") is True
    
        # Invalid Notification Type
        assert obj.notify("body", notify_type="invalid") is False
    
        obj = apprise.Apprise.instantiate("syslog://_/?facility=local5")
        assert isinstance(obj, NotifySyslog)
        assert obj.url().startswith("syslog://local5")
        assert r"logpid=yes" in obj.url()
        assert r"logperror=no" in obj.url()
    
        # Invalid instantiation
        assert apprise.Apprise.instantiate("syslog://_/?facility=invalid") is None
    
        # j will cause a search to take place and match to daemon
        obj = apprise.Apprise.instantiate("syslog://_/?facility=d")
        assert isinstance(obj, NotifySyslog)
        assert obj.url().startswith("syslog://daemon")
        assert r"logpid=yes" in obj.url()
        assert r"logperror=no" in obj.url()
    
        # Facility can also be specified on the url as a hostname
        obj = apprise.Apprise.instantiate("syslog://kern?logpid=no&logperror=y")
        assert isinstance(obj, NotifySyslog)
        assert obj.url().startswith("syslog://kern")
        assert r"logpid=no" in obj.url()
        assert r"logperror=yes" in obj.url()
    
        # Facilities specified as an argument always over-ride host
        obj = apprise.Apprise.instantiate("syslog://kern?facility=d")
        assert isinstance(obj, NotifySyslog)
        assert obj.url().startswith("syslog://daemon")
    
    
    @mock.patch("syslog.syslog")
    @mock.patch("syslog.openlog")
    def test_plugin_syslog_edge_cases(openlog, syslog):
        """NotifySyslog() Edge Cases."""
    
        # Default
        obj = NotifySyslog(facility=None)
        assert isinstance(obj, NotifySyslog)
        assert obj.url().startswith("syslog://user")
        assert r"logpid=yes" in obj.url()
        assert r"logperror=no" in obj.url()
    
        # Exception should be thrown about the fact no bot token was specified
        with pytest.raises(TypeError):
            NotifySyslog(facility="invalid")
    
        with pytest.raises(TypeError):
            NotifySyslog(facility=object)
    caronc-apprise-182f859/tests/test_plugin_techululs_push.py000066400000000000000000000062731524161175200241240ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    # Disable logging for a cleaner testing output
    import logging
    
    from helpers import AppriseURLTester
    import requests
    
    from apprise.plugins.techuluspush import NotifyTechulusPush
    
    logging.disable(logging.CRITICAL)
    
    # a test UUID we can use
    UUID4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752"
    
    # Our Testing URLs
    apprise_url_tests = (
        (
            "push://",
            {
                # Missing API Key
                "instance": TypeError,
            },
        ),
        # Invalid API Key
        (
            "push://%s" % ("+" * 24),
            {
                "instance": TypeError,
            },
        ),
        # APIkey
        (
            f"push://{UUID4}",
            {
                "instance": NotifyTechulusPush,
                # Our expected url(privacy=True) startswith() response:
                "privacy_url": "push://8...2/",
            },
        ),
        # API Key + bad url
        (
            "push://:@/",
            {
                "instance": TypeError,
            },
        ),
        (
            f"push://{UUID4}",
            {
                "instance": NotifyTechulusPush,
                # force a failure
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            f"push://{UUID4}",
            {
                "instance": NotifyTechulusPush,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            f"push://{UUID4}",
            {
                "instance": NotifyTechulusPush,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_techulus_push_urls():
        """NotifyTechulusPush() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    caronc-apprise-182f859/tests/test_plugin_telegram.py000066400000000000000000002466741524161175200226700ustar00rootroot00000000000000# BSD 2-Clause License
    #
    # Apprise - Push Notification Library.
    # Copyright (c) 2026, Chris Caron 
    #
    # Redistribution and use in source and binary forms, with or without
    # modification, are permitted provided that the following conditions are met:
    #
    # 1. Redistributions of source code must retain the above copyright notice,
    #    this list of conditions and the following disclaimer.
    #
    # 2. Redistributions in binary form must reproduce the above copyright notice,
    #    this list of conditions and the following disclaimer in the documentation
    #    and/or other materials provided with the distribution.
    #
    # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    # POSSIBILITY OF SUCH DAMAGE.
    
    from inspect import cleandoc
    from json import dumps, loads
    
    # Disable logging for a cleaner testing output
    import logging
    import os
    import re
    from unittest import mock
    
    from helpers import AppriseURLTester
    import pytest
    import requests
    
    from apprise import (
        Apprise,
        AppriseAsset,
        AppriseAttachment,
        NotifyFormat,
        NotifyType,
    )
    from apprise.plugins.telegram import NotifyTelegram
    
    logging.disable(logging.CRITICAL)
    
    # Attachment Directory
    TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var")
    
    # Our Testing URLs
    apprise_url_tests = (
        ##################################
        # NotifyTelegram
        ##################################
        (
            "tgram://",
            {
                "instance": None,
            },
        ),
        # Simple Message
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Simple Message (no images)
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": NotifyTelegram,
                # don't include an image by default
                "include_image": False,
            },
        ),
        # Simple Message with multiple chat names
        (
            "tgram://123456789:abcdefg_hijklmnop/id1/id2/",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Simple Message with multiple chat names
        (
            "tgram://123456789:abcdefg_hijklmnop/?to=id1,id2",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Simple Message with an invalid chat ID
        (
            "tgram://123456789:abcdefg_hijklmnop/%$/",
            {
                "instance": NotifyTelegram,
                # Notify will fail
                "response": False,
            },
        ),
        # Simple Message with multiple chat ids
        (
            "tgram://123456789:abcdefg_hijklmnop/id1/id2/23423/-30/",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Simple Message with multiple chat ids (no images)
        (
            "tgram://123456789:abcdefg_hijklmnop/id1/id2/23423/-30/",
            {
                "instance": NotifyTelegram,
                # don't include an image by default
                "include_image": False,
            },
        ),
        # Support bot keyword prefix
        (
            "tgram://bottest@123456789:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Support Thread Topics
        (
            "tgram://bottest@123456789:abcdefg_hijklmnop/id1/?topic=12345",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Thread is just an alias of topic
        (
            "tgram://bottest@123456789:abcdefg_hijklmnop/id1/?thread=12345",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Threads must be numeric
        (
            "tgram://bottest@123456789:abcdefg_hijklmnop/id1/?topic=invalid",
            {
                "instance": TypeError,
            },
        ),
        # content must be 'before' or 'after'
        (
            "tgram://bottest@123456789:abcdefg_hijklmnop/id1/?content=invalid",
            {
                "instance": TypeError,
            },
        ),
        (
            "tgram://bottest@123456789:abcdefg_hijklmnop/id1:invalid/?thread=12345",
            {
                "instance": NotifyTelegram,
                # Notify will fail (bad target)
                "response": False,
            },
        ),
        # Testing image
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?image=Yes",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Testing invalid format (fall's back to html)
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=invalid",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Testing empty format (falls back to html)
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Testing valid formats
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=markdown",
            {
                "instance": NotifyTelegram,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=markdown&mdv=v1",
            {
                "instance": NotifyTelegram,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=markdown&mdv=v2",
            {
                "instance": NotifyTelegram,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/l2g/?format=markdown&mdv=bad",
            {
                # Defaults to v2
                "instance": NotifyTelegram,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=html",
            {
                "instance": NotifyTelegram,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=text",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Test Silent Settings
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?silent=yes",
            {
                "instance": NotifyTelegram,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?silent=no",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Test Web Page Preview Settings
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?preview=yes",
            {
                "instance": NotifyTelegram,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?preview=no",
            {
                "instance": NotifyTelegram,
            },
        ),
        # Simple Message without image
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": NotifyTelegram,
                # don't include an image by default
                "include_image": False,
            },
        ),
        # Invalid Bot Token
        (
            "tgram://alpha:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": None,
            },
        ),
        # AuthToken + bad url
        (
            "tgram://:@/",
            {
                "instance": None,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": NotifyTelegram,
                # force a failure
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?image=Yes",
            {
                "instance": NotifyTelegram,
                # force a failure without an image specified
                "include_image": False,
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/id1/id2/",
            {
                "instance": NotifyTelegram,
                # force a failure with multiple chat_ids
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/id1/id2/",
            {
                "instance": NotifyTelegram,
                # force a failure without an image specified
                "include_image": False,
                "response": False,
                "requests_response_code": requests.codes.internal_server_error,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": NotifyTelegram,
                # throw a bizarre code forcing us to fail to look it up
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": NotifyTelegram,
                # throw a bizarre code forcing us to fail to look it up without
                # having an image included
                "include_image": False,
                "response": False,
                "requests_response_code": 999,
            },
        ),
        # Test with image set
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?image=Yes",
            {
                "instance": NotifyTelegram,
                # throw a bizarre code forcing us to fail to look it up without
                # having an image included
                "include_image": True,
                "response": False,
                "requests_response_code": 999,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/",
            {
                "instance": NotifyTelegram,
                # Throws a series of i/o exceptions with this flag
                # is set and tests that we gracefully handle them
                "test_requests_exceptions": True,
            },
        ),
        (
            "tgram://123456789:abcdefg_hijklmnop/lead2gold/?image=Yes",
            {
                "instance": NotifyTelegram,
                # Throws a series of i/o exceptions with this flag is set and
                # tests that we gracefully handle them without images set
                "include_image": True,
                "test_requests_exceptions": True,
            },
        ),
    )
    
    
    def test_plugin_telegram_urls():
        """NotifyTelegram() Apprise URLs."""
    
        # Run our general tests
        AppriseURLTester(tests=apprise_url_tests).run_all()
    
    
    @mock.patch("requests.post")
    def test_plugin_telegram_general(mock_post):
        """NotifyTelegram() General Tests."""
    
        # Bot Token
        bot_token = "123456789:abcdefg_hijklmnop"
        invalid_bot_token = "abcd:123"
    
        # Chat ID
        chat_ids = "l2g:1234, lead2gold"
    
        # Prepare Mock
        mock_post.return_value = requests.Request()
        mock_post.return_value.status_code = requests.codes.ok
        mock_post.return_value.content = "{}"
    
        # Exception should be thrown about the fact no bot token was specified
        with pytest.raises(TypeError):
            NotifyTelegram(bot_token=None, targets=chat_ids)
    
        # Invalid JSON while trying to detect bot owner
        mock_post.return_value.content = "}"
        obj = NotifyTelegram(bot_token=bot_token, targets=None)
        obj.notify(title="hello", body="world")
    
        # Invalid JSON while trying to detect bot owner + 400 error
        mock_post.return_value.status_code = requests.codes.internal_server_error
        obj = NotifyTelegram(bot_token=bot_token, targets=None)
        obj.notify(title="hello", body="world")
    
        # Return status back to how they were
        mock_post.return_value.status_code = requests.codes.ok
    
        # Exception should be thrown about the fact an invalid bot token was
        # specifed
        with pytest.raises(TypeError):
            NotifyTelegram(bot_token=invalid_bot_token, targets=chat_ids)
    
        obj = NotifyTelegram(
            bot_token=bot_token, targets=chat_ids, include_image=True
        )
        assert isinstance(obj, NotifyTelegram) is True
        assert len(obj.targets) == 2
    
        # Test Image Sending Exceptions
        mock_post.side_effect = OSError()
        assert not obj.send_media(obj.targets[0], NotifyType.INFO)
    
        # Test our other objects
        mock_post.side_effect = requests.HTTPError
        assert not obj.send_media(obj.targets[0], NotifyType.INFO)
    
        # Restore their entries
        mock_post.side_effect = None
        mock_post.return_value.content = "{}"
    
        # test url call
        assert isinstance(obj.url(), str) is True
    
        # test privacy version of url
        assert isinstance(obj.url(privacy=True), str) is True
        assert obj.url(privacy=True).startswith("tgram://1...p/") is True
    
        # Test that we can load the string we generate back:
        obj = NotifyTelegram(**NotifyTelegram.parse_url(obj.url()))
        assert isinstance(obj, NotifyTelegram) is True
    
        # Prepare Mock to fail
        response = mock.Mock()
        response.status_code = requests.codes.internal_server_error
    
        # a error response
        response.content = dumps(
            {
                "description": "test",
            }
        )
        mock_post.return_value = response
    
        # No image asset
        nimg_obj = NotifyTelegram(bot_token=bot_token, targets=chat_ids)
        nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False)
    
        # Test that our default settings over-ride base settings since they are
        # not the same as the one specified in the base; this check merely
        # ensures our plugin inheritance is working properly
        assert obj.body_maxlen == NotifyTelegram.body_maxlen
    
        # This tests erroneous messages involving multiple chat ids
        assert (
            obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
            is False
        )
        assert (
            obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
            is False
        )
        assert (
            nimg_obj.notify(
                body="body", title="title", notify_type=NotifyType.INFO
            )
            is False
        )
    
        # This tests erroneous messages involving a single chat id
        obj = NotifyTelegram(bot_token=bot_token, targets="l2g")
        nimg_obj = NotifyTelegram(bot_token=bot_token, targets="l2g")
        nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False)
    
        assert (
            obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
            is False
        )
        assert (
            nimg_obj.notify(
                body="body", title="title", notify_type=NotifyType.INFO
            )
            is False
        )
    
        # Bot Token Detection
        # Just to make it clear to people reading this code and trying to learn
        # what is going on.  Apprise tries to detect the bot owner if you don't
        # specify a user to message.  The idea is to just default to messaging
        # the bot owner himself (it makes it easier for people).  So we're testing
        # the creating of a Telegram Notification without providing a chat ID.
        # We're testing the error handling of this bot detection section of the
        # code
        mock_post.return_value.content = dumps(
            {
                "ok": True,
                "result": [
                    {
                        "update_id": 645421319,
                        # Entry without `message` in it
                    },
                    {
                        # Entry without `from` in `message`
                        "update_id": 645421320,
                        "message": {
                            "message_id": 2,
                            "chat": {
                                "id": 532389719,
                                "first_name": "Chris",
                                "type": "private",
                            },
                            "date": 1519694394,
                            "text": "/start",
                            "entities": [
                                {
                                    "offset": 0,
                                    "length": 6,
                                    "type": "bot_command",
                                }
                            ],
                        },
                    },
                    {
                        "update_id": 645421321,
                        "message": {
                            "message_id": 2,
                            "from": {
                                "id": 532389719,
                                "is_bot": False,
                                "first_name": "Chris",
                                "language_code": "en-US",
                            },
                            "chat": {
                                "id": 532389719,
                                "first_name": "Chris",
                                "type": "private",
                            },
                            "date": 1519694394,
                            "text": "/start",
                            "entities": [
                                {
                                    "offset": 0,
                                    "length": 6,
                                    "type": "bot_command",
                                }
                            ],
                        },
                    },
                ],
            }
        )
        mock_post.return_value.status_code = requests.codes.ok
    
        obj = NotifyTelegram(bot_token=bot_token, targets="12345")
        assert len(obj.targets) == 1
        assert obj.targets[0] == (12345, None)
    
        # Test the escaping of characters since Telegram escapes stuff for us to
        # which we need to consider
        mock_post.reset_mock()
        body = "

    '\"This can't\t\r\nfail us\"'

    " assert ( obj.notify( body=body, title="special characters", notify_type=NotifyType.INFO ) is True ) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # Test our payload assert ( payload["text"] == "special characters\r\n'\"This can't\t\r\nfail us\"'\r\n" ) for content in ("before", "after"): # Test our content settings obj = NotifyTelegram( bot_token=bot_token, targets="12345", content=content ) # Reset our mock mock_post.reset_mock() # Test sending attachments attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "apprise-test.gif") ) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test large messages assert ( obj.notify( body="a" * (obj.telegram_caption_maxlen + 1), title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # An invalid attachment will cause a failure path = os.path.join( TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg" ) attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # Test large messages assert ( obj.notify( body="a" * (obj.telegram_caption_maxlen + 1), title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) obj = NotifyTelegram(bot_token=bot_token, targets=None) # No user detected; this happens after our firsst notification assert len(obj.targets) == 0 assert obj.notify(title="hello", body="world") is True assert len(obj.targets) == 1 assert obj.targets[0] == ("532389719", None) # Do the test again, but without the expected (parsed response) mock_post.return_value.content = dumps( { "ok": True, "result": [], } ) # No user will be detected now obj = NotifyTelegram(bot_token=bot_token, targets=None) # No user detected; this happens after our firsst notification assert len(obj.targets) == 0 assert obj.notify(title="hello", body="world") is False assert len(obj.targets) == 0 # Do the test again, but with ok not set to True mock_post.return_value.content = dumps( { "ok": False, "result": [ { "update_id": 645421321, "message": { "message_id": 2, "from": { "id": 532389719, "is_bot": False, "first_name": "Chris", "language_code": "en-US", }, "chat": { "id": 532389719, "first_name": "Chris", "type": "private", }, "date": 1519694394, "text": "/start", "entities": [ { "offset": 0, "length": 6, "type": "bot_command", } ], }, }, ], } ) # No user will be detected now obj = NotifyTelegram(bot_token=bot_token, targets=None) # No user detected; this happens after our firsst notification assert len(obj.targets) == 0 assert obj.notify(title="hello", body="world") is False assert len(obj.targets) == 0 # An edge case where no results were provided; this will probably never # happen, but it helps with test coverage completeness mock_post.return_value.content = dumps( { "ok": True, } ) # No user will be detected now obj = NotifyTelegram(bot_token=bot_token, targets=None) # No user detected; this happens after our firsst notification assert len(obj.targets) == 0 assert obj.notify(title="hello", body="world") is False assert len(obj.targets) == 0 # Detect the bot with a bad response mock_post.return_value.content = dumps({}) obj.detect_bot_owner() # Test our bot detection with a internal server error mock_post.return_value.status_code = requests.codes.internal_server_error # internal server error prevents notification from being sent obj = NotifyTelegram(bot_token=bot_token, targets=None) assert len(obj.targets) == 0 assert obj.notify(title="hello", body="world") is False assert len(obj.targets) == 0 # Test our bot detection with an unmappable html error mock_post.return_value.status_code = 999 NotifyTelegram(bot_token=bot_token, targets=None) assert len(obj.targets) == 0 assert obj.notify(title="hello", body="world") is False assert len(obj.targets) == 0 # Do it again but this time provide a failure message mock_post.return_value.content = dumps({"description": "Failure Message"}) NotifyTelegram(bot_token=bot_token, targets=None) assert len(obj.targets) == 0 assert obj.notify(title="hello", body="world") is False assert len(obj.targets) == 0 # Do it again but this time provide a failure message and perform a # notification without a bot detection by providing at least 1 chat id obj = NotifyTelegram(bot_token=bot_token, targets=["@abcd"]) assert ( nimg_obj.notify( body="body", title="title", notify_type=NotifyType.INFO ) is False ) # iterate over our exceptions and test them mock_post.side_effect = requests.HTTPError # No chat_ids specified obj = NotifyTelegram(bot_token=bot_token, targets=None) assert len(obj.targets) == 0 assert obj.notify(title="hello", body="world") is False assert len(obj.targets) == 0 # Test Telegram Group obj = Apprise.instantiate( "tgram://123456789:ABCdefghijkl123456789opqyz/-123456789525" ) assert isinstance(obj, NotifyTelegram) assert len(obj.targets) == 1 assert (-123456789525, None) in obj.targets @mock.patch("requests.post") def test_plugin_telegram_formatting(mock_post): """NotifyTelegram() formatting tests.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = "{}" # Simple success response mock_post.return_value.content = dumps( { "ok": True, "result": [ { "update_id": 645421321, "message": { "message_id": 2, "from": { "id": 532389719, "is_bot": False, "first_name": "Chris", "language_code": "en-US", }, "chat": { "id": 532389719, "first_name": "Chris", "type": "private", }, "date": 1519694394, "text": "/start", "entities": [ { "offset": 0, "length": 6, "type": "bot_command", } ], }, }, ], } ) mock_post.return_value.status_code = requests.codes.ok results = NotifyTelegram.parse_url("tgram://123456789:abcdefg_hijklmnop/") instance = NotifyTelegram(**results) assert isinstance(instance, NotifyTelegram) response = instance.send(title="title", body="body") assert response is True # 1 call to look up bot owner, and second for notification assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage" ) # Reset our values mock_post.reset_mock() # Now test our HTML Conversion as TEXT) aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/") assert len(aobj) == 1 title = "🚨 Change detected for Apprise Test Title" body = ( 'Apprise Body Title' ' had a change' ) assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a TEXT mode assert ( payload["text"] == "🚨 Change detected for <i>Apprise Test Title</i>" '\r\n<a href="http://localhost"><i>' "Apprise Body Title</i></a> had <" 'a href="http://127.0.0.1">a change</a>' ) # Reset our values mock_post.reset_mock() # Now test our HTML Conversion as TEXT) aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/?format=html") assert len(aobj) == 1 assert aobj.notify(title=title, body=body, body_format=NotifyFormat.HTML) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a HTML mode assert ( payload["text"] == "🚨 Change detected for Apprise Test Title\r\n" 'Apprise Body Title had ' 'a change' ) # Reset our values mock_post.reset_mock() # Now test our MARKDOWN Handling title = "# 🚨 Change detected for _Apprise Test Title_" body = ( "_[Apprise Body Title](http://localhost)_" " had [a change](http://127.0.0.1)" ) aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify( title=title, body=body, body_format=NotifyFormat.MARKDOWN ) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a HTML mode assert ( payload["text"] == "# 🚨 Change detected for _Apprise Test Title_\r\n" "_[Apprise Body Title](http://localhost)_ had " "[a change](http://127.0.0.1)" ) # Reset our values mock_post.reset_mock() # Now test our MARKDOWN Handling title = "# 🚨 Change detected for _Apprise Test Title_" body = ( "_[Apprise Body Title](http://localhost)_" " had [a change](http://127.0.0.1)" ) aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/?format=markdown&mdv=1") assert len(aobj) == 1 assert aobj.notify( title=title, body=body, body_format=NotifyFormat.MARKDOWN ) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a HTML mode assert ( payload["text"] == "# 🚨 Change detected for _Apprise Test Title_\r\n" "_[Apprise Body Title](http://localhost)_ had " "[a change](http://127.0.0.1)" ) # Reset our values mock_post.reset_mock() # Upstream to use HTML but input specified as Markdown aobj = Apprise() aobj.add("tgram://987654321:abcdefg_hijklmnop/?format=html") assert len(aobj) == 1 # Now test our MARKDOWN Handling title = "# 🚨 Another Change detected for _Apprise Test Title_" body = ( "_[Apprise Body Title](http://localhost)_" " had [a change](http://127.0.0.2)" ) # HTML forced by the command line, but MARKDOWN specified as # upstream mode assert aobj.notify( title=title, body=body, body_format=NotifyFormat.MARKDOWN ) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a HTML mode assert ( payload["text"] == "\r\n🚨 Another Change detected for " "Apprise Test Title\r\n\r\n" 'Apprise Body Title' ' had a change\r\n' ) # Now we'll test an edge case where a title was defined, but after # processing it, it was determiend there really wasn't anything there # at all at the end of the day. # Reset our values mock_post.reset_mock() # Upstream to use HTML but input specified as Markdown v1 aobj = Apprise() aobj.add("tgram://987654321:abcdefg_hijklmnop/?format=markdown&mdv=1") assert len(aobj) == 1 # Now test our MARKDOWN Handling (no title defined... not really anyway) title = "# " body = ( "_[Apprise Body Title](http://localhost)_" " had [a change](http://127.0.0.2)" ) # MARKDOWN forced by the command line, but TEXT specified as # upstream mode assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a MARKDOWN mode assert payload["text"] == body # Reset our values mock_post.reset_mock() # Upstream to use HTML but input specified as Markdown v2 aobj = Apprise() aobj.add("tgram://987654321:abcdefg_hijklmnop/?format=markdown&mdv=2") assert len(aobj) == 1 # MARKDOWN forced by the command line, but TEXT specified as # upstream mode assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a MARKDOWN mode assert ( payload["text"] == "\\_\\[Apprise Body Title\\]\\(http://localhost\\)\\_ had \\" "[a change\\]\\(http://127\\.0\\.0\\.2\\)" ) # Reset our values mock_post.reset_mock() # Upstream to use HTML but input specified as Markdown v1 aobj = Apprise() aobj.add("tgram://987654321:abcdefg_hijklmnop/?format=markdown&mdv=1") assert len(aobj) == 1 # Set an actual title this time title = "# A Great Title" body = ( "_[Apprise Body Title](http://localhost)_" " had [a change](http://127.0.0.2)" ) # TEXT forced by the command line, but MARKDOWN specified as # upstream mode assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a MARKDOWN mode assert ( payload["text"] == "# A Great Title\n" "_[Apprise Body Title](http://localhost)_ had " "[a change](http://127.0.0.2)" ) # Reset our values mock_post.reset_mock() # Upstream to use HTML but input specified as Markdown v2 aobj = Apprise() aobj.add("tgram://987654321:abcdefg_hijklmnop/?format=markdown&mdv=2") assert len(aobj) == 1 # TEXT forced by the command line, but MARKDOWN specified as # upstream mode assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT) # Test our calls assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a MARKDOWN mode assert ( payload["text"] == "\\# A Great Title\n" "\\_\\[Apprise Body Title\\]\\(http://localhost\\)\\_ had " "\\[a change\\]\\(http://127\\.0\\.0\\.2\\)" ) # Reset our values mock_post.reset_mock() # If input is markdown and output is v2, it is expected the user knows # what he is doing... no esaping takes place assert aobj.notify( title=title, body=body, body_format=NotifyFormat.MARKDOWN ) # Test our calls assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[0][1]["data"]) # No escaping in this circumstance assert ( payload["text"] == "# A Great Title\r\n" "_[Apprise Body Title](http://localhost)_ had " "[a change](http://127.0.0.2)" ) # Reset our values mock_post.reset_mock() # No body format specified at all... user definitely must know what # they are doing... still no escaping in this circumstance assert aobj.notify(title=title, body=body) # Test our calls assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage" ) payload = loads(mock_post.call_args_list[0][1]["data"]) # No escaping in this circumstance assert ( payload["text"] == "# A Great Title\r\n" "_[Apprise Body Title](http://localhost)_ had " "[a change](http://127.0.0.2)" ) # Reset our values mock_post.reset_mock() # # Now test that
    is correctly escaped # title = "Test Message Title" body = "Test Message Body
    ok
    " aobj = Apprise() aobj.add("tgram://1234:aaaaaaaaa/-1123456245134") assert len(aobj) == 1 assert aobj.notify( title=title, body=body, body_format=NotifyFormat.MARKDOWN ) # Test our calls assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot1234:aaaaaaaaa/sendMessage" ) payload = loads(mock_post.call_args_list[0][1]["data"]) # Test that everything is escaped properly in a HTML mode assert ( payload["text"] == "Test Message Title\r\n\r\nTest Message Body\r\nok\r\n" ) # Reset our values mock_post.reset_mock() # # Now test that
    is correctly escaped as it would have been via the # CLI mode where the body_format is TEXT # aobj = Apprise() aobj.add("tgram://1234:aaaaaaaaa/-1123456245134") assert len(aobj) == 1 assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT) # Test our calls assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot1234:aaaaaaaaa/sendMessage" ) payload = loads(mock_post.call_args_list[0][1]["data"]) # Test that everything is escaped properly in a HTML mode assert ( payload["text"] == "Test Message Title\r\n" "Test Message Body <br/> ok</br>" ) # Reset our values mock_post.reset_mock() # # Now test that
    is correctly escaped if fed as HTML # aobj = Apprise() aobj.add("tgram://1234:aaaaaaaaa/-1123456245134") assert len(aobj) == 1 assert aobj.notify(title=title, body=body, body_format=NotifyFormat.HTML) # Test our calls assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.telegram.org/bot1234:aaaaaaaaa/sendMessage" ) payload = loads(mock_post.call_args_list[0][1]["data"]) # Test that everything is escaped properly in a HTML mode assert ( payload["text"] == "Test Message Title\r\nTest Message Body\r\nok\r\n" ) @mock.patch("requests.post") def test_plugin_telegram_html_formatting(mock_post): """NotifyTelegram() HTML Formatting.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Simple success response mock_post.return_value.content = dumps( { "ok": True, "result": [ { "update_id": 645421321, "message": { "message_id": 2, "from": { "id": 532389719, "is_bot": False, "first_name": "Chris", "language_code": "en-US", }, "chat": { "id": 532389719, "first_name": "Chris", "type": "private", }, "date": 1519694394, "text": "/start", "entities": [ { "offset": 0, "length": 6, "type": "bot_command", } ], }, }, ], } ) aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/") assert len(aobj) == 1 assert isinstance(aobj[0], NotifyTelegram) # Test our HTML Conversion title = "'information&apos" body = ( ""This is in Italic"
    " "
     &emspHeadings are dropped and" " converted to bold
    " ) assert aobj.notify(title=title, body=body, body_format=NotifyFormat.HTML) # 1 call to look up bot owner, and second for notification assert mock_post.call_count == 2 payload = loads(mock_post.call_args_list[1][1]["data"]) # Test that everything is escaped properly in a HTML mode assert ( payload["text"] == "\r\n'information'\r\n\r\n\"This is in Italic\"" "\r\n Headings are dropped and converted to bold\r\n" ) mock_post.reset_mock() assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT) # owner has already been looked up, so only one call is made assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert ( payload["text"] == "<title>&apos;information&apos</title>" "\r\n<em>&quot;This is in Italic&quot</em><" "br/><h5>&emsp;&emspHeadings&nbsp;are " "dropped and&nbspconverted to bold</h5>" ) # Lest test more complex HTML examples now mock_post.reset_mock() test_file_01 = os.path.join(TEST_VAR_DIR, "01_test_example.html") with open(test_file_01) as html_file: assert aobj.notify( body=html_file.read(), body_format=NotifyFormat.HTML ) # owner has already been looked up, so only one call is made assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert ( payload["text"] == "\r\nBootstrap 101 Template\r\nMy Title\r\n" "Heading 1\r\n-Bullet 1\r\n-Bullet 2\r\n-Bullet 3\r\n" "-Bullet 1\r\n-Bullet 2\r\n-Bullet 3\r\nHeading 2\r\n" "A div entry\r\nA div entry\r\n" "
    print('hello')
    \r\n" "Heading 3\r\nHeading 4\r\nHeading 5\r\n" "Heading 6\r\nA set of text\r\n" "Another line after the set of text\r\nMore text\r\nlabel" ) @mock.patch("requests.post") def test_plugin_telegram_html_to_markdown_format(mock_post): """Test HTML delivery to Telegram Markdown targets.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = dumps({"ok": True, "result": True}) # Simple HTML that our html_to_markdown converter handles body = "hello world" # Markdown v1 gets the converted body in Telegram's own Markdown dialect # (single-asterisk bold, single-underscore italic). aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=1") assert len(aobj) == 1 assert aobj.notify(body=body, body_format=NotifyFormat.HTML) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MARKDOWN" assert payload["text"] == "*hello* _world_" mock_post.reset_mock() # Markdown v2 gets the same dialect-correct delimiters, left unescaped so # Telegram still parses them as real formatting. aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify(body=body, body_format=NotifyFormat.HTML) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == "*hello* _world_" mock_post.reset_mock() # Telegram escapes v2-only punctuation not covered by generic Markdown. aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify( body="

    3:00 p.m. (sharp)! Don't be late - see you there.

    ", body_format=NotifyFormat.HTML, ) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == ( r"3:00 p\.m\. \(sharp\)\! Don't be late \- see you there\." ) mock_post.reset_mock() # Markdown v2 target, plain TEXT body aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify( body="Tag #1 and *not* bold", body_format=NotifyFormat.TEXT ) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == r"Tag \#1 and \*not\* bold" mock_post.reset_mock() # Markdown v2 target, no body_format specified aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify(body="**already** markdown #tag") assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == "**already** markdown #tag" mock_post.reset_mock() # A heading has no MarkdownV2 entity -- its '#' must be escaped (not left # bare), or Telegram rejects the entire message outright rather than just. aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify( body="

    Title

    body text

    ", body_format=NotifyFormat.HTML ) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == "\\# Title\n\nbody text" mock_post.reset_mock() # Convert CommonMark links to Telegram's bare-destination syntax. aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify( body='click', body_format=NotifyFormat.HTML, ) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == "[click](https://example.com/x)" mock_post.reset_mock() # Lists and tables have no MarkdownV2 entity either -- their markers # need the same escaping as a heading's, for the same reason. aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify( body="
    • one
    • two
    ", body_format=NotifyFormat.HTML, ) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == "\\- one\n\\- two" mock_post.reset_mock() aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify( body="
    First HeaderSecond HeaderContent Cell1Content Cell2Content Cell3Content Cell4
    AB
    ", body_format=NotifyFormat.HTML, ) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == ( "\\| A \\| B \\|\n\\| \\-\\-\\- \\| \\-\\-\\- \\|" ) mock_post.reset_mock() # A code span's content is just as literal to Telegram as it is to # CommonMark -- the strict escape pass must not touch it. aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=2") assert len(aobj) == 1 assert aobj.notify( body="a.b-c|d", body_format=NotifyFormat.HTML ) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["parse_mode"] == "MarkdownV2" assert payload["text"] == "`a.b-c|d`" @mock.patch("requests.post") def test_plugin_telegram_html_to_markdown_hardening(mock_post): """Test edge cases in the CommonMark-to-Telegram dialect adaptation.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = dumps({"ok": True, "result": True}) def notify(body, mdv="2"): aobj = Apprise() aobj.add( "tgram://123456789:abcdefg_hijklmnop/12345" f"?format=markdown&mdv={mdv}" ) assert len(aobj) == 1 assert aobj.notify(body=body, body_format=NotifyFormat.HTML) payload = loads(mock_post.call_args_list[-1][1]["data"]) mock_post.reset_mock() return payload["text"] # A link destination containing a literal ')' or '\' must have those # escaped. assert notify('x') == ( "[x](https://example.com/a(b\\)c)" ) # A code span's content needs every '`'/'\' inside it escaped. assert notify("a\\b") == "`a\\\\b`" assert notify("a`b") == "`a\\`b`" # Immediately-adjacent nested emphasis (no text between the outer and inner # tag's open) must stay correctly nested ("*_x_*"), not cross ("*_x*_"). assert notify("x") == "*_x_*" assert notify("x") == "*_x_*" # Legacy Markdown (v1) doesn't support nested entities at all. assert notify("a b c", mdv="1") == "*a b c*" assert notify("x", mdv="1") == "*x*" # x and x both flatten to the identical # CommonMark "***x***" (html_to_markdown has no separating text to anchor assert notify("x", mdv="1") == "*x*" # Legacy Markdown only recognizes a backslash escape in front of # '`'/'*'/'_'/'['. assert ( notify("

    #tag (test)! <x> ~wave~

    ", mdv="1") == "#tag (test)! ~wave~" ) assert ( notify("

    a[b]c *lit* _lit_ `lit`

    ", mdv="1") == "a\\[b\\]c \\*lit\\* \\_lit\\_ \\`lit\\`" ) # Non-adjacent nesting and sibling spans are unaffected. assert notify("bold italic still bold") == ( "*bold _italic_ still bold*" ) assert notify("AB") == "*A**B*" # A nested bold opening *while italic is already open, with real text in # between* (so the two opening delimiters aren't touching) is a completely. assert notify("a b c") == "_a *b* c_" assert notify("a b c", mdv="1") == "_a b c_" # The reverse nesting (bold containing italic, separated by text) was # already correct, and must stay that way. assert notify("a b c") == "*a _b_ c*" # A literal "\x01\x01"-shaped sequence in ordinary text must pass # through completely unaltered. assert notify("literal \x010\x01 text, no code or links at all") == ( "literal \x010\x01 text, no code or links at all" ) # overflow=split can hand this method just one chunk of a longer body, with # a span that doesn't open or close until a different chunk entirely. aobj = Apprise() aobj.add( "tgram://123456789:abcdefg_hijklmnop/12345" "?format=markdown&mdv=2&overflow=split" ) assert len(aobj) == 1 assert aobj.notify( body="" + ("x" * 4990) + "", body_format=NotifyFormat.HTML ) assert mock_post.call_count == 2 texts = [loads(c[1]["data"])["text"] for c in mock_post.call_args_list] # Each half is independently balanced -- an odd number of un-escaped # '*'/'_' in either one would mean Telegram still rejects it. for text in texts: assert text.count("*") % 2 == 0 assert text.count("_") % 2 == 0 # A split at a bold close must not leave an empty entity. assert texts[1] == "x" * (len(texts[1])) # The same overflow split can also land mid-code-span or mid-link. assert ( NotifyTelegram._commonmark_to_telegram( "text ``unterminated", strict=True ) == "text \\`\\`unterminated" ) assert ( NotifyTelegram._commonmark_to_telegram( "a](' in V1 mode: # the scan skips escaped characters and still finds the '>)' terminator. assert ( notify('click', mdv="1") == r"[click](https://example.com/x\\>y)" ) # HTML body with a title in V1 mode: _build_send_calls merges the title # as a heading before dialect conversion (covers the title-merge branch). aobj_v1 = Apprise() aobj_v1.add( "tgram://123456789:abcdefg_hijklmnop/12345?format=markdown&mdv=1" ) assert aobj_v1.notify( body="hello", title="My Title", body_format=NotifyFormat.HTML ) payload = loads(mock_post.call_args_list[-1][1]["data"]) assert payload["text"] == "# My Title\n*hello*" mock_post.reset_mock() # Title that reduces to an empty string after stripping leading heading and # list characters (html_to_markdown converts " - " to "-"). assert aobj_v1.notify( body="hello", title=" - ", body_format=NotifyFormat.HTML ) payload = loads(mock_post.call_args_list[-1][1]["data"]) assert payload["text"] == "*hello*" mock_post.reset_mock() @mock.patch("requests.post") def test_plugin_telegram_overflow_split_repair(mock_post): """Test that overflow=split can't break entity boundaries across messages: _build_send_calls() adapts the whole body to Telegram's dialect before splitting (not per-chunk after), and _repair_split_chunk() patches up whatever the split itself still cuts in half across two messages.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = dumps({"ok": True, "result": True}) def notify_split(body): aobj = Apprise() aobj.add( "tgram://123456789:abcdefg_hijklmnop/12345" "?format=markdown&mdv=2&overflow=split" ) assert len(aobj) == 1 assert aobj.notify(body=body, body_format=NotifyFormat.HTML) texts = [loads(c[1]["data"])["text"] for c in mock_post.call_args_list] mock_post.reset_mock() return texts # A bold span long enough to force a split, immediately followed by plain # text that was never part of it. texts = notify_split( "" + ("x" * 4990) + "" + "TAIL_SHOULD_NOT_BE_BOLD" ) assert len(texts) == 2 # The part that fit keeps its formatting... assert texts[0].startswith("*x") assert texts[0].endswith("x*") # ...but the unrelated trailing text does not become bold. assert "TAIL" in texts[1] assert not texts[1].startswith("*") for text in texts: assert text.count("*") % 2 == 0 assert text.count("_") % 2 == 0 # A link long enough that its URL alone forces a split. url = "https://example.com/" + ("a" * 4990) texts = notify_split(f'click here') assert len(texts) >= 2 for text in texts: # Every chunk must be valid MarkdownV2: no unescaped reserved chars. assert not re.search(r"(?#+=|{}.!<-]", text) # A
     block long enough to force a split.
        content = "line.with.dots-and-dashes_under " * 200
        texts = notify_split(f"
    {content}
    ") assert len(texts) >= 2 for text in texts: assert not re.search(r"(?#+=|{}.!<-]", text) # A short message that never triggers a split at all is unaffected. texts = notify_split("short text") assert texts == ["*short* _text_"] # A continuation chunk where the carried-over destination *does* close # within this same chunk (not needing yet another one), even with an. assert NotifyTelegram._repair_split_chunk( "a\\)b)more text\\.", True, {"in_link_dest": True} ) == ("a\\)b\\)more text\\.", {}) # A carried-over code span closing within this chunk, same idea. assert NotifyTelegram._repair_split_chunk( "code```more text\\.", True, {"in_code": 3} ) == ("codemore text\\.", {}) # A carried-over destination that doesn't end in *this* chunk either. assert NotifyTelegram._repair_split_chunk( "still no close here", True, {"in_link_dest": True} ) == ("still no close here", {"in_link_dest": True}) repair = NotifyTelegram._repair_split_chunk # V1 (non-strict) with an unmatched backtick: the backtick run has no # closing partner, so the `if strict:` branch is NOT taken. assert repair("`code no close", False, {}) == ("`code no close", {}) # A pending close count for '*' in strict mode: the matching close # delimiter in this chunk is discarded (the open was already dropped). assert repair("*text", True, {"*": 1}) == ("text", {"*": 0}) # Same for '_'. assert repair("_text", True, {"_": 1}) == ("text", {"_": 0}) # Strict mode: '](url)' with NO matching '[' on the link stack escapes # the construct rather than emitting it verbatim. assert repair("](https://e.com)", True, {}) == ( "\\]\\(https://e\\.com\\)", {}, ) # Strict mode: '](url' with no closing ')' sets in_link_dest pending. assert repair("](https://e.com no-close", True, {}) == ( "\\]\\(https://e\\.com no\\-close", {"in_link_dest": True}, ) # Strict mode: opening and immediately closing the same delimiter with no # content in between -- the empty span is dropped from the output. assert repair("**", True, {}) == ("", {}) # Strict mode: a stray ']', '(', or ')' outside any complete link entity # is backslash-escaped rather than passed through verbatim. assert repair("]text", True, {}) == ("\\]text", {}) assert repair("(text)", True, {}) == ("\\(text\\)", {}) # Strict mode: a dangling open at the end of the chunk with no content # after it is removed as an empty span (not propagated as pending). assert repair("text*", True, {}) == ("text", {}) @mock.patch("requests.post") def test_plugin_telegram_threads(mock_post): """NotifyTelegram() Threads/Topics.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Simple success response mock_post.return_value.content = dumps( { "ok": True, "result": [ { "update_id": 645421321, "message": { "message_id": 2, "from": { "id": 532389719, "is_bot": False, "first_name": "Chris", "language_code": "en-US", }, "chat": { "id": 532389719, "first_name": "Chris", "type": "private", }, "date": 1519694394, "text": "/start", "entities": [ { "offset": 0, "length": 6, "type": "bot_command", } ], }, }, ], } ) aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/?thread=1234") assert len(aobj) == 1 assert isinstance(aobj[0], NotifyTelegram) body = "my threaded message" assert aobj.notify(body=body) # 1 call to look up bot owner, and second for notification assert mock_post.call_count == 2 payload = loads(mock_post.call_args_list[1][1]["data"]) assert "message_thread_id" in payload assert payload["message_thread_id"] == 1234 mock_post.reset_mock() aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/?topic=1234") assert len(aobj) == 1 assert isinstance(aobj[0], NotifyTelegram) body = "my message" assert aobj.notify(body=body) # 1 call to look up bot owner, and second for notification assert mock_post.call_count == 2 payload = loads(mock_post.call_args_list[1][1]["data"]) assert "message_thread_id" in payload assert payload["message_thread_id"] == 1234 mock_post.reset_mock() aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/9876:1234/9876:1111") assert len(aobj) == 1 assert isinstance(aobj[0], NotifyTelegram) body = "my message" assert aobj.notify(body=body) # 1 call to look up bot owner, and second for notification assert mock_post.call_count == 2 payload = loads(mock_post.call_args_list[0][1]["data"]) assert "message_thread_id" in payload assert payload["message_thread_id"] == 1111 payload = loads(mock_post.call_args_list[1][1]["data"]) assert "message_thread_id" in payload assert payload["message_thread_id"] == 1234 mock_post.reset_mock() @mock.patch("requests.post") def test_plugin_telegram_markdown_v2(mock_post): """NotifyTelegram() MarkdownV2.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Simple success response mock_post.return_value.content = dumps( { "ok": True, "result": [ { "update_id": 645421321, "message": { "message_id": 2, "from": { "id": 532389719, "is_bot": False, "first_name": "Chris", "language_code": "en-US", }, "chat": { "id": 532389719, "first_name": "Chris", "type": "private", }, "date": 1519694394, "text": "/start", "entities": [ { "offset": 0, "length": 6, "type": "bot_command", } ], }, }, ], } ) aobj = Apprise() aobj.add("tgram://123456789:abcdefg_hijklmnop/?mdv=2&format=markdown") assert len(aobj) == 1 assert isinstance(aobj[0], NotifyTelegram) body = "# my message\r\n## more content\r\n\\# already escaped hashtag" # Test with body format set to markdown assert aobj.notify(body=body, body_format=NotifyFormat.TEXT) # 1 call to look up bot owner, and second for notification assert mock_post.call_count == 2 payload = loads(mock_post.call_args_list[1][1]["data"]) # Our content is escapped properly assert ( payload["text"] == "\\# my message\r\n" "\\#\\# more content\r\n\\# already escaped hashtag" ) mock_post.reset_mock() # We'll iterate over all of the bad unsupported characters mdv2_unsupported = ( "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "=", "|", "{", "}", ".", "!", "-", ) for c in mdv2_unsupported: body = f"bad character: {c}, and already escapped \\{c}" # Test with body format set to markdown assert aobj.notify(body=body, body_format=NotifyFormat.TEXT) assert mock_post.call_count == 1 payload = loads(mock_post.call_args_list[0][1]["data"]) # Our content is escapped properly assert ( payload["text"] == f"bad character: \\{c}, and already escapped \\{c}" ) mock_post.reset_mock() @mock.patch("requests.post") def test_plugin_telegram_attach_memory(mock_post): """Regression: AttachMemory must be sendable without OSError.""" from apprise.attachment.memory import AttachMemory response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True, "result": True}) mock_post.return_value = response obj = NotifyTelegram( bot_token="123456789:abcdefg_hijklmnop", targets="12345" ) mem = AttachMemory( content=b"

    Test

    ", name="test.html", mimetype="text/html", ) assert obj.notify(body="Test", attach=mem) is True assert mock_post.call_count >= 1 @mock.patch("requests.post") def test_plugin_telegram_template_blocks(mock_post, tmpdir): """NotifyTelegram() - Rich Message template mode.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response # Write a minimal Rich Message JSON template to disk template = tmpdir.join("blocks.json") template.write( cleandoc(""" { "blocks": [ { "type": "section_heading", "text": "{{app_title}}" }, { "type": "paragraph", "text": "{{app_body}}" } ] } """) ) obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/" "?template={}&:mykey=myval".format(str(template)) ) assert isinstance(obj, NotifyTelegram) # Verify tokens and template were parsed correctly assert "mykey" in obj.tokens assert obj.tokens["mykey"] == "myval" assert obj.template assert ( obj.notify(body="hello", title="world", notify_type=NotifyType.INFO) is True ) assert mock_post.called is True # Inspect the posted URL and payload posted_url = mock_post.call_args_list[0][0][0] assert posted_url.endswith("/sendRichMessage") posted = loads(mock_post.call_args_list[0][1]["data"]) assert posted["chat_id"] == "@lead2gold" assert "rich_message" in posted blocks = posted["rich_message"]["blocks"] assert any(b.get("type") == "section_heading" for b in blocks) assert any(b.get("type") == "paragraph" for b in blocks) heading = next(b for b in blocks if b.get("type") == "section_heading") assert heading["text"] == "world" paragraph = next(b for b in blocks if b.get("type") == "paragraph") assert paragraph["text"] == "hello" @mock.patch("requests.post") def test_plugin_telegram_template_invalid_json(mock_post, tmpdir): """NotifyTelegram() - Rich Message template with invalid JSON fails.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("bad.json") template.write("{ not valid json }") obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_telegram_template_blocks_not_list(mock_post, tmpdir): """NotifyTelegram() - 'blocks' missing/not-a-list/empty is rejected.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response # 'blocks' key entirely missing template = tmpdir.join("missing_blocks.json") template.write('{"text": "no blocks here"}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False # 'blocks' present but not a list template.write('{"blocks": "not-a-list"}') obj2 = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj2, NotifyTelegram) assert ( obj2.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False # Empty list is also rejected template.write('{"blocks": []}') obj3 = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj3, NotifyTelegram) assert ( obj3.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_telegram_template_block_missing_type(mock_post, tmpdir): """NotifyTelegram() - a block dict without 'type' is rejected.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("no_type.json") template.write('{"blocks": [{"text": "hi"}]}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_telegram_template_content_not_dict(mock_post, tmpdir): """NotifyTelegram() - template that parses to a JSON array is rejected.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("array.json") template.write('[{"type": "paragraph"}]') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_telegram_template_block_not_dict(mock_post, tmpdir): """NotifyTelegram() - non-dict entry in blocks list is rejected.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("bad_block.json") template.write('{"blocks": ["not-a-dict"]}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_telegram_template_load_error(mock_post, tmpdir): """NotifyTelegram() - template OSError during read fails gracefully.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response # Write an empty file so the attachment resolves but open() can be # mocked to fail template = tmpdir.join("empty.json") template.write("") obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) with mock.patch("builtins.open", side_effect=OSError): assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False def test_plugin_telegram_template_bad_tokens(): """NotifyTelegram() - invalid tokens type raises TypeError.""" with pytest.raises(TypeError): NotifyTelegram( bot_token="123456789:abcdefg_hijklmnop", targets="lead2gold", tokens="not-a-dict", ) def test_plugin_telegram_template_add_failure(): """NotifyTelegram() - TypeError when AppriseAttachment.add() drops entry.""" with mock.patch("apprise.plugins.telegram.AppriseAttachment") as mock_cls: inst = mock.MagicMock() inst.__len__ = mock.Mock(return_value=0) mock_cls.return_value = inst with pytest.raises(TypeError): NotifyTelegram( bot_token="123456789:abcdefg_hijklmnop", targets="lead2gold", template="file:///some/template.json", ) @mock.patch("requests.post") def test_plugin_telegram_template_inaccessible(mock_post, tmpdir): """NotifyTelegram() - template attachment that cannot be accessed fails.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response # Point to a template file that does not exist missing = str(tmpdir.join("missing.json")) obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( missing ) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) assert mock_post.called is False @mock.patch("requests.post") def test_plugin_telegram_template_none_token_value(mock_post, tmpdir): """NotifyTelegram() - a None token value (e.g. app_image_url) is coerced to an empty string before JSON-escaping.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response # Template references app_image_url which will be None when # include_image=False template = tmpdir.join("img.json") template.write( '{"blocks": [{"type": "paragraph",' ' "text": "{{app_body}} img={{app_image_url}}"}]}' ) obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/" "?image=no&template={}".format(str(template)) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="hi", title="y", notify_type=NotifyType.INFO) is True ) posted = loads(mock_post.call_args_list[0][1]["data"]) paragraph = posted["rich_message"]["blocks"][0] assert paragraph["text"] == "hi img=" @mock.patch("requests.post") def test_plugin_telegram_template_url_roundtrip(mock_post, tmpdir): """NotifyTelegram() - template + tokens survive url()/parse_url() round-trip.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("rt.json") template.write( cleandoc(""" { "blocks": [ {"type": "paragraph", "text": "{{app_body}}"} ] } """) ) obj1 = NotifyTelegram( bot_token="123456789:abcdefg_hijklmnop", targets="lead2gold", template=str(template), tokens={"key1": "val1", "key2": "val2"}, ) url = obj1.url() result = NotifyTelegram.parse_url(url) assert result is not None obj2 = NotifyTelegram(**result) assert isinstance(obj2, NotifyTelegram) # Connection identity must be preserved assert obj1.url_identifier == obj2.url_identifier # Tokens must survive the round-trip assert obj2.tokens.get("key1") == "val1" assert obj2.tokens.get("key2") == "val2" # Template must be present after round-trip assert obj2.template @mock.patch("requests.post") def test_plugin_telegram_template_multi_target(mock_post, tmpdir): """NotifyTelegram() - Rich Message is POSTed once per target.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("multi.json") template.write('{"blocks": [{"type": "paragraph", "text": "hi"}]}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/12345/67890:55/" "?template={}".format(str(template)) ) assert isinstance(obj, NotifyTelegram) assert len(obj.targets) == 2 assert obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is True assert mock_post.call_count == 2 posted_1 = loads(mock_post.call_args_list[0][1]["data"]) posted_2 = loads(mock_post.call_args_list[1][1]["data"]) assert posted_1["chat_id"] == 12345 assert "message_thread_id" not in posted_1 assert posted_2["chat_id"] == 67890 assert posted_2["message_thread_id"] == 55 @mock.patch("requests.post") def test_plugin_telegram_template_with_attachment(mock_post, tmpdir): """NotifyTelegram() - attachments remain untouched in template mode, sent separately after the Rich Message.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("attach.json") template.write('{"blocks": [{"type": "paragraph", "text": "hi"}]}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) # Pass a raw (not pre-wrapped) attachment path; template mode must # still normalize it into an AppriseAttachment internally. path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") assert obj.notify(body="hi", title="y", attach=path) is True # First call is the Rich Message itself first_url = mock_post.call_args_list[0][0][0] assert first_url.endswith("/sendRichMessage") # Second call is the attachment, sent via the normal send_media() path second_url = mock_post.call_args_list[1][0][0] assert not second_url.endswith("/sendRichMessage") @mock.patch("requests.post") def test_plugin_telegram_template_attachment_failure(mock_post, tmpdir): """NotifyTelegram() - a failed attachment send flags an overall failure in Rich Message mode.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("attach_fail.json") template.write('{"blocks": [{"type": "paragraph", "text": "hi"}]}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) # Point to an attachment that cannot be accessed attach = AppriseAttachment("file:///path/does/not/exist.gif") assert obj.notify(body="hi", title="y", attach=attach) is False @mock.patch("requests.post") def test_plugin_telegram_template_preview_enabled(mock_post, tmpdir): """NotifyTelegram() - preview=yes omits link_preview_options.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = dumps({"ok": True}) mock_post.return_value = response template = tmpdir.join("preview.json") template.write('{"blocks": [{"type": "paragraph", "text": "hi"}]}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/" "?preview=yes&template={}".format(str(template)) ) assert isinstance(obj, NotifyTelegram) assert obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is True posted = loads(mock_post.call_args_list[0][1]["data"]) assert "link_preview_options" not in posted @mock.patch("requests.post") def test_plugin_telegram_template_http_error(mock_post, tmpdir): """NotifyTelegram() - Rich Message HTTP error is handled gracefully.""" response = mock.Mock() response.status_code = requests.codes.internal_server_error response.content = dumps({"description": "failure"}) mock_post.return_value = response template = tmpdir.join("err.json") template.write('{"blocks": [{"type": "paragraph", "text": "hi"}]}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) # Also cover the case where the error response body itself is not # parsable JSON (falls back to the generic HTTP status string) response.content = b"not-json" assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) @mock.patch("requests.post") def test_plugin_telegram_template_request_exception(mock_post, tmpdir): """NotifyTelegram() - Rich Message RequestException is handled gracefully.""" mock_post.side_effect = requests.RequestException() template = tmpdir.join("exc.json") template.write('{"blocks": [{"type": "paragraph", "text": "hi"}]}') obj = Apprise.instantiate( "tgram://123456789:abcdefg_hijklmnop/lead2gold/?template={}".format( str(template) ) ) assert isinstance(obj, NotifyTelegram) assert ( obj.notify(body="x", title="y", notify_type=NotifyType.INFO) is False ) caronc-apprise-182f859/tests/test_plugin_threema.py000066400000000000000000000143701524161175200224770ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.threema import NotifyThreema logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "threema://", { # No user/secret specified "instance": TypeError, }, ), ( "threema://@:", { # Invalid url "instance": TypeError, }, ), ( "threema://user@secret", { # gateway id must be 8 characters in len "instance": TypeError, }, ), ( "threema://*THEGWID@secret/{targets}/".format( targets="/".join(["2222"]) ), { # Invalid target phone number "instance": NotifyThreema, "notify_response": False, "privacy_url": "threema://%2ATHEGWID@****/2222", }, ), ( "threema://*THEGWID@secret/{targets}/".format( targets="/".join(["16134442222"]) ), { # Valid "instance": NotifyThreema, "privacy_url": "threema://%2ATHEGWID@****/16134442222", }, ), ( "threema://*THEGWID@secret/{targets}/".format( targets="/".join(["16134442222", "16134443333"]) ), { # Valid multiple targets "instance": NotifyThreema, "privacy_url": "threema://%2ATHEGWID@****/16134442222/16134443333", }, ), ( "threema:///?secret=secret&from=*THEGWID&to={targets}".format( targets=",".join(["16134448888", "user1@gmail.com", "abcd1234"]) ), { # Valid "instance": NotifyThreema, }, ), ( "threema:///?secret=secret&gwid=*THEGWID&to={targets}".format( targets=",".join(["16134448888", "user2@gmail.com", "abcd1234"]) ), { # Valid "instance": NotifyThreema, }, ), ( "threema://*THEGWID@secret", { "instance": NotifyThreema, # No targets specified "notify_response": False, }, ), ( "threema://*THEGWID@secret/16134443333", { "instance": NotifyThreema, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "threema://*THEGWID@secret/16134443333", { "instance": NotifyThreema, # Throws a series of errors "test_requests_exceptions": True, }, ), ) def test_plugin_threema(): """NotifyThreema() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_threema_edge_cases(mock_post): """NotifyThreema() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens gwid = "*THEGWID" secret = "mysecret" targets = "+1 (555) 123-9876" # No email specified with pytest.raises(TypeError): NotifyThreema(user=gwid, secret=None, targets=targets) results = NotifyThreema.parse_url( f"threema://?gwid={gwid}&secret={secret}&to={targets}" ) assert isinstance(results, dict) assert results["user"] == gwid assert results["secret"] == secret assert results["password"] is None assert results["port"] is None assert results["host"] == "" assert results["fullpath"] == "/" assert results["path"] == "/" assert results["query"] is None assert results["schema"] == "threema" assert results["url"] == "threema:///" assert isinstance(results["targets"], list) assert len(results["targets"]) == 1 assert results["targets"][0] == targets instance = NotifyThreema(**results) assert len(instance.targets) == 1 assert instance.targets[0] == ("phone", "15551239876") assert isinstance(instance, NotifyThreema) response = instance.send(title="title", body="body 😊") assert response is True assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert details[0][0] == "https://msgapi.threema.ch/send_simple" assert details[1]["headers"]["User-Agent"] == "Apprise" assert details[1]["headers"]["Accept"] == "*/*" assert ( details[1]["headers"]["Content-Type"] == "application/x-www-form-urlencoded; charset=utf-8" ) assert details[1]["params"]["secret"] == secret assert details[1]["params"]["from"] == gwid assert details[1]["params"]["phone"] == "15551239876" assert details[1]["params"]["text"] == "body 😊".encode() caronc-apprise-182f859/tests/test_plugin_title_maxlen.py000066400000000000000000000123031524161175200235310ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc from json import loads # Disable logging for a cleaner testing output import logging import os import pytest import requests from apprise import Apprise from apprise.config import ConfigBase logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") @pytest.fixture def request_mock(mocker): """Prepare requests mock.""" resp = requests.Request() resp.status_code = requests.codes.ok resp.content = "" # json/form/xml plugins now use requests.request(); # telegram still uses requests.post() mock_request = mocker.patch("requests.request") mock_request.return_value = resp mock_post = mocker.patch("requests.post") mock_post.return_value = resp return mock_request, mock_post def test_plugin_title_maxlen(request_mock): """Plugin title maxlen blending support.""" mock_request, mock_post = request_mock # Load our configuration result, _ = ConfigBase.config_parse_yaml( cleandoc(""" urls: # Our JSON plugin allows for a title definition; we enforce a html format - json://user:pass@example.ca?format=html # Telegram has a title_maxlen of 0 - tgram://123456789:AABCeFGhIJKLmnOPqrStUvWxYZ12345678U/987654321 """) ) # Verify we loaded correctly assert isinstance(result, list) assert len(result) == 2 assert len(result[0].tags) == 0 aobj = Apprise() aobj.add(result) assert len(aobj) == 2 title = "Hello World" body = "Foo Bar" assert aobj.notify(title=title, body=body) # JSON uses requests.request, Telegram uses requests.post assert mock_request.call_count == 1 assert mock_post.call_count == 1 details = mock_request.call_args_list[0] assert details[0][0] == "POST" assert details[0][1] == "http://example.ca" payload = loads(details[1]["data"]) assert payload["message"] == body assert payload["title"] == "Hello World" # Telegram plugin: requests.post(url, data=...) -> url is [0][0] details = mock_post.call_args_list[0] assert ( details[0][0] == "https://api.telegram.org/bot123456789:" "AABCeFGhIJKLmnOPqrStUvWxYZ12345678U/sendMessage" ) payload = loads(details[1]["data"]) # HTML in Title is escaped assert payload["text"] == "Hello World\r\nFoo Bar" # Reset our mock objects mock_request.reset_mock() mock_post.reset_mock() # # Reverse the configuration file and expect the same results # result, _config = ConfigBase.config_parse_yaml( cleandoc(""" urls: # Telegram has a title_maxlen of 0 - tgram://123456789:AABCeFGhIJKLmnOPqrStUvWxYZ12345678U/987654321 # Our JSON plugin allows for a title definition; we enforce a html format - json://user:pass@example.ca?format=html """) ) # Verify we loaded correctly assert isinstance(result, list) assert len(result) == 2 assert len(result[0].tags) == 0 aobj = Apprise() aobj.add(result) assert len(aobj) == 2 title = "Hello World" body = "Foo Bar" assert aobj.notify(title=title, body=body) # JSON uses requests.request, Telegram uses requests.post assert mock_request.call_count == 1 assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert ( details[0][0] == "https://api.telegram.org/bot123456789:" "AABCeFGhIJKLmnOPqrStUvWxYZ12345678U/sendMessage" ) payload = loads(details[1]["data"]) # HTML in Title is escaped assert payload["text"] == "Hello World\r\nFoo Bar" details = mock_request.call_args_list[0] assert details[0][0] == "POST" assert details[0][1] == "http://example.ca" payload = loads(details[1]["data"]) assert payload["message"] == body assert payload["title"] == "Hello World" caronc-apprise-182f859/tests/test_plugin_trigv.py000066400000000000000000000271021524161175200222020ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests import apprise from apprise.plugins.trigv import NotifyTrigv logging.disable(logging.CRITICAL) VALID_API_KEY = "trgv_a1b2c3d4_0123456789abcdef0123456789abcdef" apprise_url_tests = ( ( "trigvs://", { "instance": TypeError, }, ), ( "trigv://", { "instance": TypeError, }, ), ( f"trigvs://{VALID_API_KEY}", { "instance": NotifyTrigv, }, ), ( f"trigvs://{VALID_API_KEY}/general", { "instance": NotifyTrigv, }, ), ( f"trigvs://{VALID_API_KEY}/deploys", { "instance": NotifyTrigv, }, ), ( f"trigv://{VALID_API_KEY}@trigv-platform.test/general", { "instance": NotifyTrigv, }, ), ( f"trigv://{VALID_API_KEY}@trigv-platform.test:8080/general", { "instance": NotifyTrigv, }, ), ( f"trigvs://{VALID_API_KEY}/alerts/ops", { "instance": NotifyTrigv, }, ), ( f"trigvs://{VALID_API_KEY}/?to=alerts,ops", { "instance": NotifyTrigv, }, ), ( f"trigvs://{VALID_API_KEY}/?url=https://example.com", { "instance": NotifyTrigv, }, ), ( f"trigvs://{VALID_API_KEY}/?urgency=time_sensitive", { "instance": NotifyTrigv, }, ), ( "trigvs://not-a-valid-key", { "instance": TypeError, }, ), ( f"trigvs://{VALID_API_KEY}/bad.channel/", { "instance": TypeError, }, ), ( f"trigvs://{VALID_API_KEY}", { "instance": NotifyTrigv, "response": False, "requests_response_code": requests.codes.unauthorized, }, ), ( f"trigvs://{VALID_API_KEY}", { "instance": NotifyTrigv, "response": False, "requests_response_code": requests.codes.unprocessable_entity, }, ), ( f"trigvs://{VALID_API_KEY}", { "instance": NotifyTrigv, "test_requests_exceptions": True, }, ), ) def test_plugin_trigv_urls(): """NotifyTrigv() Apprise URLs.""" AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_trigv_general(mock_post): """NotifyTrigv() General Checks.""" response = mock.Mock() response.content = b'{"event":{"public_id":"evt_test","status":"queued"}}' response.status_code = requests.codes.accepted mock_post.return_value = response obj = apprise.Apprise.instantiate(f"trigvs://{VALID_API_KEY}/alerts") assert isinstance(obj, NotifyTrigv) assert obj.targets == ["alerts"] assert len(obj) == 1 assert ( obj.notify( body="Backup failed", title="Cron", notify_type=apprise.NotifyType.FAILURE, ) is True ) assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == ( "https://api.trigv.com/api/v1/events" ) headers = mock_post.call_args_list[0][1]["headers"] assert headers["Authorization"] == f"Bearer {VALID_API_KEY}" assert headers["Content-Type"] == "application/json" import json payload = json.loads(mock_post.call_args_list[0][1]["data"]) assert payload["channel"] == "alerts" assert payload["title"] == "Cron" assert payload["description"] == "Backup failed" assert payload["level"] == "error" assert payload["delivery_urgency"] == "time_sensitive" @mock.patch("requests.post") def test_plugin_trigv_local_host(mock_post): """NotifyTrigv() custom hostname for local development.""" response = mock.Mock() response.status_code = requests.codes.accepted mock_post.return_value = response obj = apprise.Apprise.instantiate( f"trigv://{VALID_API_KEY}@trigv-platform.test/general" ) assert isinstance(obj, NotifyTrigv) assert obj.notify(body="Hello", title="Test") is True assert ( mock_post.call_args_list[0][0][0] == "http://trigv-platform.test/api/v1/events" ) @mock.patch("requests.post") def test_plugin_trigv_custom_port(mock_post): """NotifyTrigv() custom hostname with an explicit port.""" response = mock.Mock() response.status_code = requests.codes.accepted mock_post.return_value = response obj = apprise.Apprise.instantiate( f"trigvs://{VALID_API_KEY}@trigv-platform.test:8443/general" ) assert isinstance(obj, NotifyTrigv) assert obj.port == 8443 assert obj.notify(body="Hello", title="Test") is True assert ( mock_post.call_args_list[0][0][0] == "https://trigv-platform.test:8443/api/v1/events" ) @mock.patch("requests.post") def test_plugin_trigv_multiple_channels(mock_post): """NotifyTrigv() notifies every channel specified.""" response = mock.Mock() response.status_code = requests.codes.accepted mock_post.return_value = response obj = NotifyTrigv(api_key=VALID_API_KEY, targets=["alerts", "ops"]) assert obj.targets == ["alerts", "ops"] assert len(obj) == 2 assert obj.send(body="test", title="title") is True assert mock_post.call_count == 2 import json channels_notified = { json.loads(call[1]["data"])["channel"] for call in mock_post.call_args_list } assert channels_notified == {"alerts", "ops"} @mock.patch("requests.post") def test_plugin_trigv_multiple_channels_partial_failure(mock_post): """NotifyTrigv() still tries every channel if one of them fails.""" ok_response = mock.Mock() ok_response.status_code = requests.codes.accepted error_response = mock.Mock() error_response.status_code = requests.codes.internal_server_error error_response.content = b"" mock_post.side_effect = [error_response, ok_response] obj = NotifyTrigv(api_key=VALID_API_KEY, targets=["alerts", "ops"]) assert obj.send(body="test", title="title") is False assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_trigv_multiple_channels_request_exception(mock_post): """NotifyTrigv() marks a failure when one channel raises.""" ok_response = mock.Mock() ok_response.status_code = requests.codes.accepted mock_post.side_effect = [ requests.RequestException(), ok_response, ] obj = NotifyTrigv(api_key=VALID_API_KEY, targets=["alerts", "ops"]) assert obj.send(body="test", title="title") is False assert mock_post.call_count == 2 @mock.patch("requests.post") def test_plugin_trigv_duplicate_success(mock_post): """NotifyTrigv() treats HTTP 200 idempotent duplicate as success.""" response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response obj = NotifyTrigv(api_key=VALID_API_KEY) assert obj.send(body="test", title="title") is True @mock.patch("requests.post") def test_plugin_trigv_optional_payload_and_priority(mock_post): """NotifyTrigv() includes optional fields and maps numeric priority.""" response = mock.Mock() response.status_code = requests.codes.accepted mock_post.return_value = response obj = NotifyTrigv( api_key=VALID_API_KEY, image_url="https://example.com/image.png", event_type="backup.failed", priority=1, ) assert obj.send(body="", title="") is True import json payload = json.loads(mock_post.call_args_list[0][1]["data"]) assert payload["title"] == obj.app_desc assert payload["description"] == obj.app_desc assert payload["image_url"] == "https://example.com/image.png" assert payload["event_type"] == "backup.failed" assert payload["delivery_urgency"] == "time_sensitive" @mock.patch("requests.post") def test_plugin_trigv_empty_body_and_invalid_priority(mock_post): """NotifyTrigv() omits an empty body and ignores invalid priority.""" response = mock.Mock() response.status_code = requests.codes.accepted mock_post.return_value = response obj = NotifyTrigv(api_key=VALID_API_KEY, priority="invalid") assert obj.send(body="", title="Title", notify_type="unknown") is True import json payload = json.loads(mock_post.call_args_list[0][1]["data"]) assert "description" not in payload assert payload["level"] == "info" assert payload["delivery_urgency"] == "standard" def test_plugin_trigv_invalid_urgency(): """NotifyTrigv() rejects unknown urgency.""" with pytest.raises(TypeError): NotifyTrigv(api_key=VALID_API_KEY, urgency="critical") def test_plugin_trigv_to_alias(): """NotifyTrigv() parses ?to= into additional targets.""" parsed = NotifyTrigv.parse_url( f"trigvs://{VALID_API_KEY}/alerts/?to=ops,escalation" ) obj = NotifyTrigv(**parsed) # parse_list() de-duplicates and sorts every target it is given assert obj.targets == ["alerts", "escalation", "ops"] @mock.patch("requests.post") def test_plugin_trigv_url_roundtrip(mock_post): """NotifyTrigv() url() round-trips optional query parameters.""" mock_post.return_value = mock.Mock() mock_post.return_value.status_code = requests.codes.accepted obj = NotifyTrigv( api_key=VALID_API_KEY, targets="deploys", supplemental_url="https://example.com/run/1", image_url="https://example.com/image.png", urgency="time_sensitive", event_type="deploy.completed", priority=2, ) generated = obj.url() assert "deploys" in generated assert "url=" in generated assert "image_url=" in generated assert "urgency=time_sensitive" in generated assert "event_type=deploy.completed" in generated assert "priority=2" in generated parsed = NotifyTrigv.parse_url(generated) obj2 = NotifyTrigv(**parsed) assert obj2.targets == ["deploys"] assert obj2.supplemental_url == "https://example.com/run/1" assert obj2.image_url == "https://example.com/image.png" assert obj2.urgency == "time_sensitive" assert obj2.event_type == "deploy.completed" assert obj2.priority == 2 caronc-apprise-182f859/tests/test_plugin_twilio.py000066400000000000000000000270341524161175200223620ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.plugins.twilio import NotifyTwilio, TwilioNotificationMethod logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "twilio://", { # No Account SID specified "instance": TypeError, }, ), ( "twilio://:@/", { # invalid Auth token "instance": TypeError, }, ), ( "twilio://AC{}@12345678".format("a" * 32), { # Just sid provided "instance": TypeError, }, ), ( "twilio://AC{}:{}@_".format("a" * 32, "b" * 32), { # sid and token provided but invalid from "instance": TypeError, }, ), ( "twilio://AC{}:{}@{}".format("a" * 32, "b" * 32, "3" * 5), { # using short-code (5 characters) without a target # We can still instantiate ourselves with a valid short code "instance": NotifyTwilio, # Since there are no targets specified we expect a False return on # send() "notify_response": False, }, ), ( "twilio://AC{}:{}@{}".format("a" * 32, "b" * 32, "3" * 9), { # sid and token provided and from but invalid from no "instance": TypeError, }, ), ( "twilio://AC{}:{}@{}/123/{}/abcd/w:{}".format( "a" * 32, "b" * 32, "3" * 11, "9" * 15, 8 * 11 ), { # valid everything but target numbers "instance": NotifyTwilio, }, ), ( "twilio://AC{}:{}@12345/{}".format("a" * 32, "b" * 32, "4" * 11), { # using short-code (5 characters) "instance": NotifyTwilio, # Our expected url(privacy=True) startswith() response: "privacy_url": "twilio://...aaaa:b...b@12345", }, ), ( "twilio://AC{}:{}@98765/{}/w:{}/".format( "a" * 32, "b" * 32, "4" * 11, "5" * 11 ), { # using short-code (5 characters) and 1 twillio address ignored # because source phone number can not be a short code "instance": NotifyTwilio, # Our expected url(privacy=True) startswith() response: "privacy_url": "twilio://...aaaa:b...b@98765", }, ), ( "twilio://AC{}:{}@w:12345/{}/{}".format( "a" * 32, "b" * 32, "4" * 11, "5" * 11 ), { # Invalid short-code "instance": TypeError, }, ), ( "twilio://AC{}:{}@123456/{}".format("a" * 32, "b" * 32, "4" * 11), { # using short-code (6 characters) "instance": NotifyTwilio, }, ), ( "twilio://AC{}:{}@{}".format("a" * 32, "b" * 32, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifyTwilio, }, ), ( "twilio://AC{}:{}@{}?method=sms".format("a" * 32, "b" * 32, "5" * 11), { # Specify explicitly notification method sms "instance": NotifyTwilio, }, ), ( "twilio://AC{}:{}@{}?method=mms".format("a" * 32, "b" * 32, "5" * 11), { # Invalid notification method "instance": TypeError, }, ), ( "twilio://AC{}:{}@{}?method=call".format( "a" * 32, "b" * 32, "w:" + "5" * 11 ), { # Incompatibility between Whatsapp mode and CALL method "instance": TypeError, }, ), ( "twilio://_?sid=AC{}&token={}&from={}".format( "a" * 32, "b" * 32, "5" * 11 ), { # use get args to acomplish the same thing "instance": NotifyTwilio, }, ), ( "twilio://_?sid=AC{}&token={}&from={}&to=w:{}".format( "a" * 32, "b" * 32, "5" * 11, "6" * 11 ), { # Support whatsapp (w: before number) "instance": NotifyTwilio, }, ), ( "twilio://_?sid=AC{}&token={}&source={}".format( "a" * 32, "b" * 32, "5" * 11 ), { # use get args to acomplish the same thing (use source instead of # from) "instance": NotifyTwilio, }, ), ( "twilio://_?sid=AC{}&token={}&from={}&to={}".format( "a" * 32, "b" * 32, "5" * 11, "7" * 13 ), { # use to= "instance": NotifyTwilio, }, ), ( "twilio://_?sid=AC{}&token={}&from={}&to={}method=call".format( "a" * 32, "b" * 32, "5" * 11, "7" * 13 ), { # Specify notification method call "instance": NotifyTwilio, }, ), ( "twilio://AC{}:{}@{}".format("a" * 32, "b" * 32, "6" * 11), { "instance": NotifyTwilio, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "twilio://AC{}:{}@{}".format("a" * 32, "b" * 32, "6" * 11), { "instance": NotifyTwilio, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_twilio_urls(): """NotifyTwilio() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_twilio_auth(mock_post): """NotifyTwilio() Auth. - account-wide auth token - API key and its own auth token """ response = mock.Mock() response.content = "" response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens account_sid = "AC{}".format("b" * 32) apikey = "SK{}".format("b" * 32) auth_token = "{}".format("b" * 32) source = "+1 (555) 123-3456" dest = "+1 (555) 987-6543" message_contents = "test" # Variation of initialization without API key obj = Apprise.instantiate( f"twilio://{account_sid}:{auth_token}@{source}/{dest}" ) assert isinstance(obj, NotifyTwilio) assert isinstance(obj.url(), str) # Send Notification assert obj.send(body=message_contents) is True # Variation of initialization with API key obj = Apprise.instantiate( f"twilio://{account_sid}:{auth_token}@{source}/{dest}?apikey={apikey}" ) assert isinstance(obj, NotifyTwilio) assert isinstance(obj.url(), str) # Send Notification assert obj.send(body=message_contents) is True # Variation of initialization with method call obj = Apprise.instantiate( f"twilio://{account_sid}:{auth_token}@{source}/{dest}?method=call" ) assert isinstance(obj, NotifyTwilio) assert isinstance(obj.url(), str) # Send Notification assert obj.send(body=message_contents) is True # Validate expected call parameters assert mock_post.call_count == 3 first_call = mock_post.call_args_list[0] second_call = mock_post.call_args_list[1] third_call = mock_post.call_args_list[2] # URL and message parameters are the same for both calls assert ( first_call[0][0] == second_call[0][0] == ( "https://api.twilio.com/2010-04-01/Accounts" f"/{account_sid}/Messages.json" ) ) assert ( first_call[1]["data"]["Body"] == second_call[1]["data"]["Body"] == message_contents ) assert third_call[0][0] == ( f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Calls.json" ) assert third_call[1]["data"]["Twiml"] == message_contents assert ( first_call[1]["data"]["From"] == second_call[1]["data"]["From"] == third_call[1]["data"]["From"] == "+15551233456" ) assert ( first_call[1]["data"]["To"] == second_call[1]["data"]["To"] == third_call[1]["data"]["To"] == "+15559876543" ) # Auth differs depending on if API Key is used assert first_call[1]["auth"] == (account_sid, auth_token) assert second_call[1]["auth"] == (apikey, auth_token) assert third_call[1]["auth"] == (account_sid, auth_token) @mock.patch("requests.post") def test_plugin_twilio_edge_cases(mock_post): """NotifyTwilio() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens account_sid = "AC{}".format("b" * 32) auth_token = "{}".format("b" * 32) source = "+1 (555) 123-3456" whatsapp_source = "w:" + "+1 (555) 123-3456" # No account_sid specified with pytest.raises(TypeError): NotifyTwilio(account_sid=None, auth_token=auth_token, source=source) # No auth_token specified with pytest.raises(TypeError): NotifyTwilio(account_sid=account_sid, auth_token=None, source=source) # Source is bad with pytest.raises(TypeError): NotifyTwilio(account_sid=account_sid, auth_token=auth_token, source="") # Incompatibility between mode and method with pytest.raises(TypeError): NotifyTwilio( account_sid=account_sid, auth_token=auth_token, source=whatsapp_source, method=TwilioNotificationMethod.CALL, ) # a error response response.status_code = 400 response.content = dumps( { "code": 21211, "message": "The 'To' number +1234567 is not a valid phone number.", } ) mock_post.return_value = response # Initialize our object obj = NotifyTwilio( account_sid=account_sid, auth_token=auth_token, source=source ) # We will fail with the above error code assert obj.notify("title", "body", "info") is False caronc-apprise-182f859/tests/test_plugin_twist.py000066400000000000000000000450701524161175200222250ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyFormat from apprise.plugins.twist import NotifyTwist logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "twist://", { # Missing Email and Login "instance": None, }, ), ( "twist://:@/", { "instance": None, }, ), ( "twist://user@example.com/", { # No password "instance": None, }, ), ( "twist://user@example.com/password", { # Password acceptable as first entry in path "instance": NotifyTwist, # Expected notify() response is False because internally we would # have failed to login "notify_response": False, }, ), ( "twist://password:user1@example.com", { # password:login acceptable "instance": NotifyTwist, # Expected notify() response is False because internally we would # have failed to login "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "twist://****:user1@example.com", }, ), ( "twist://password:user2@example.com", { # password:login acceptable "instance": NotifyTwist, # Expected notify() response is False because internally we would # have logged in, but we would have failed to look up the #General # channel and workspace. "requests_response_text": { # Login expected response "id": 1234, "default_workspace": 9876, }, "notify_response": False, }, ), ( "twist://password:user2@example.com", { "instance": NotifyTwist, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "twist://password:user2@example.com", { "instance": NotifyTwist, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_twist_urls(): """NotifyTwist() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_twist_init(): """NotifyTwist() init()""" with pytest.raises(TypeError): NotifyTwist(email="invalid", targets=None) with pytest.raises(TypeError): NotifyTwist(email="user@domain", targets=None) # Simple object initialization result = NotifyTwist( password="abc123", email="user@domain.com", targets=None ) assert result.user == "user" assert result.host == "domain.com" assert result.password == "abc123" # Channel Instantiation by name obj = Apprise.instantiate("twist://password:user@example.com/#Channel") assert isinstance(obj, NotifyTwist) # Channel Instantiation by id (faster if you know the translation) obj = Apprise.instantiate("twist://password:user@example.com/12345") assert isinstance(obj, NotifyTwist) # Invalid Channel - (max characters is 64), the below drops it obj = Apprise.instantiate( "twist://password:user@example.com/{}".format("a" * 65) ) assert isinstance(obj, NotifyTwist) # No User detect result = NotifyTwist.parse_url("twist://example.com") assert result is None # test usage of to= result = NotifyTwist.parse_url( "twist://password:user@example.com?to=#channel" ) assert isinstance(result, dict) assert "user" in result assert result["user"] == "user" assert "host" in result assert result["host"] == "example.com" assert "password" in result assert result["password"] == "password" assert "targets" in result assert isinstance(result["targets"], list) is True assert len(result["targets"]) == 1 assert "#channel" in result["targets"] @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_twist_auth(mock_post, mock_get): """NotifyTwist() login/logout()""" # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok mock_post.return_value.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 12345, } ) mock_get.return_value.content = mock_post.return_value.content # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel") assert isinstance(obj, NotifyTwist) # not logged in yet obj.logout() assert obj.login() is True # Clear our channel listing obj.channels.clear() # No channels mean there is no internal migration/lookups required assert obj._channel_migration() is True # Workspace Success mock_post.return_value.content = dumps( [ { "name": "TesT", "id": 1, }, { "name": "tESt2", "id": 2, }, ] ) mock_get.return_value.content = mock_post.return_value.content results = obj.get_workspaces() assert len(results) == 2 assert "test" in results assert results["test"] == 1 assert "test2" in results assert results["test2"] == 2 mock_post.return_value.content = dumps( [ { "name": "ChaNNEL1", "id": 1, }, { "name": "chaNNel2", "id": 2, }, ] ) mock_get.return_value.content = mock_post.return_value.content results = obj.get_channels(wid=1) assert len(results) == 2 assert "channel1" in results assert results["channel1"] == 1 assert "channel2" in results assert results["channel2"] == 2 # Test result failure response mock_post.return_value.status_code = 403 mock_get.return_value.status_code = 403 assert obj.get_workspaces() == {} # Return things how they were mock_post.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok # Forces call to logout: del obj # # Authentication failures # mock_post.return_value.status_code = 403 mock_get.return_value.status_code = 403 # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel") assert isinstance(obj, NotifyTwist) # Authentication failed assert obj.get_workspaces() == {} assert obj.get_channels(wid=1) == {} assert obj._channel_migration() is False assert obj.send("body", "title") is False obj = Apprise.instantiate("twist://password:user@example.com/#Channel") assert isinstance(obj, NotifyTwist) # Calling logout on an object already logged out obj.logout() @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_twist_cache(mock_post, mock_get): """NotifyTwist() Cache Handling.""" def _response(url, *args, **kwargs): # Default configuration request = mock.Mock() request.status_code = requests.codes.ok request.content = "{}" if url.endswith("/login"): # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 1, } ) elif url.endswith("workspaces/get"): request.content = dumps( [ { "name": "TeamA", "id": 1, }, { "name": "TeamB", "id": 2, }, ] ) elif url.endswith("channels/get"): request.content = dumps( [ { "name": "ChanA", "id": 1, }, { "name": "ChanB", "id": 2, }, ] ) return request mock_get.side_effect = _response mock_post.side_effect = _response # Instantiate an object obj = Apprise.instantiate( "twist://password:user@example.com/" "#ChanB/1:1/TeamA:ChanA/Ignore:Chan/3:1" ) assert isinstance(obj, NotifyTwist) # Will detect channels except Ignore:Chan assert obj._channel_migration() is False # Add another channel obj.channels.add("ChanB") assert obj._channel_migration() is True # Nothing more to detect the second time around assert obj._channel_migration() is True # Send a notification assert obj.send("body", "title") is True def _can_not_send_response(url, *args, **kwargs): """Simulate a case where we can't send a notification.""" # Force a failure request = mock.Mock() request.status_code = 403 request.content = "{}" return request mock_get.side_effect = _can_not_send_response mock_post.side_effect = _can_not_send_response # Send a notification and fail at it assert obj.send("body", "title") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_twist_fetch(mock_post, mock_get): """NotifyTwist() fetch() fetch() is a wrapper that handles all kinds of edge cases and even attempts to re-authenticate to the Twist server if our token happens to expire. This tests these edge cases """ # Track our iteration; by tracing within an object, we can re-reference # it within a function scope. cache = { "first_time": True, } def _reauth_response(url, *args, **kwargs): """Tests re-authentication process and then a successful retry.""" # Default configuration request = mock.Mock() request.status_code = requests.codes.ok # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 12345, } ) if url.endswith("threads/add") and cache["first_time"] is True: # First time iteration; act as if we failed; our second iteration # will not enter this and be successful. This is done by simply # toggling the first_time flag: cache["first_time"] = False # otherwise, we set our first-time failure settings request.status_code = 403 request.content = dumps( { "error_code": 200, "error_string": "Invalid token", } ) return request mock_get.side_effect = _reauth_response mock_post.side_effect = _reauth_response # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel/34") assert isinstance(obj, NotifyTwist) # Simulate a re-authentication postokay, response = obj._fetch("threads/add") ########################################################################## cache = { "first_time": True, } def _reauth_exception_response(url, *args, **kwargs): """Tests exception thrown after re-authentication process.""" # Default configuration request = mock.Mock() request.status_code = requests.codes.ok # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 12345, } ) if url.endswith("threads/add") and cache["first_time"] is True: # First time iteration; act as if we failed; our second iteration # will not enter this and be successful. This is done by simply # toggling the first_time flag: cache["first_time"] = False # otherwise, we set our first-time failure settings request.status_code = 403 request.content = dumps( { "error_code": 200, "error_string": "Invalid token", } ) elif url.endswith("threads/add") and cache["first_time"] is False: # unparseable response throws the exception request.status_code = 200 request.content = "{" return request mock_get.side_effect = _reauth_exception_response mock_post.side_effect = _reauth_exception_response # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel/34") assert isinstance(obj, NotifyTwist) # Simulate a re-authentication postokay, response = obj._fetch("threads/add") ########################################################################## cache = { "first_time": True, } def _reauth_failed_response(url, *args, **kwargs): """Tests re-authentication process and have it not succeed.""" # Default configuration request = mock.Mock() request.status_code = requests.codes.ok # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 12345, } ) if url.endswith("threads/add") and cache["first_time"] is True: # First time iteration; act as if we failed; our second iteration # will not enter this and be successful. This is done by simply # toggling the first_time flag: cache["first_time"] = False # otherwise, we set our first-time failure settings request.status_code = 403 request.content = dumps( { "error_code": 200, "error_string": "Invalid token", } ) elif url.endswith("/login") and cache["first_time"] is False: # Fail to login request.status_code = 403 request.content = "{}" return request mock_get.side_effect = _reauth_failed_response mock_post.side_effect = _reauth_failed_response # Instantiate an object obj = Apprise.instantiate("twist://password:user@example.com/#Channel/34") assert isinstance(obj, NotifyTwist) # Simulate a re-authentication postokay, response = obj._fetch("threads/add") def _unparseable_json_response(url, *args, **kwargs): # Default configuration request = mock.Mock() request.status_code = requests.codes.ok request.content = "{" return request mock_get.side_effect = _unparseable_json_response mock_post.side_effect = _unparseable_json_response # Instantiate our object obj = Apprise.instantiate("twist://password:user@example.com/#Channel/34") assert isinstance(obj, NotifyTwist) # Simulate a re-authentication postokay, response = obj._fetch("threads/add") assert postokay is True # When we can't parse the content, we still default to an empty # dictionary assert response == {} @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_twist_html_to_markdown_format(mock_post, mock_get): """NotifyTwist(): HTML body is converted to Markdown.""" def _response(url, *args, **kwargs): # Default configuration request = mock.Mock() request.status_code = requests.codes.ok if url.endswith("/login"): # Simulate a successful login request.content = dumps( { "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", "default_workspace": 1, } ) else: # All other endpoints return an empty JSON object request.content = "{}" return request mock_get.side_effect = _response mock_post.side_effect = _response # Twist has class-level notify_format = MARKDOWN, so no ?format= # is needed; incoming HTML bodies are converted before dispatch aobj = Apprise() assert aobj.add("twist://password:user@example.com/1:1") # Notify with an HTML body; the framework converts it to Markdown # before dispatching to the Twist plugin assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) # Two POST calls are expected: login then threads/add assert mock_post.call_count == 2 # The body must arrive as Markdown in the threads/add payload data = mock_post.call_args_list[1][1]["data"] assert data["content"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_twitter.py000066400000000000000000001231501524161175200225510ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timezone import json import logging import os from unittest.mock import Mock, patch from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyType from apprise.plugins.twitter import NotifyTwitter # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") TWITTER_SCREEN_NAME = "apprise" # Our Testing URLs apprise_url_tests = ( ################################## # NotifyTwitter ################################## ( "twitter://", { # Missing Consumer API Key "instance": TypeError, }, ), ( "twitter://:@/", { "instance": TypeError, }, ), ( "twitter://consumer_key", { # Missing Keys "instance": TypeError, }, ), ( "twitter://consumer_key/consumer_secret/", { # Missing Keys "instance": TypeError, }, ), ( "twitter://consumer_key/consumer_secret/atoken1/", { # Missing Access Secret "instance": TypeError, }, ), ( "twitter://consumer_key/consumer_secret/atoken2/access_secret", { # No user mean's we message ourselves "instance": NotifyTwitter, # Expected notify() response False (because we won't be able # to detect our user); default mock has no v2 data key "notify_response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "x://c...y/****/a...2/****", }, ), ( ( "twitter://consumer_key/consumer_secret/atoken3/access_secret" "?cache=no" ), { # No user mean's we message ourselves "instance": NotifyTwitter, # v2 whoami format: {"data": {"id": "...", "username": "..."}} "requests_response_text": { "data": {"id": "12345", "username": "test"}, "media_id": 123, }, }, ), ( "twitter://consumer_key/consumer_secret/atoken4/access_secret", { # No user mean's we message ourselves "instance": NotifyTwitter, # v2 whoami format "requests_response_text": { "data": {"id": "12345", "username": "test"}, "media_id": 123, }, }, ), # A duplicate of the entry above, this will cause cache to be referenced ( "twitter://consumer_key/consumer_secret/atoken5/access_secret", { # No user mean's we message ourselves "instance": NotifyTwitter, # v2 whoami format "requests_response_text": { "data": {"id": "12345", "username": "test"}, "media_id": 123, }, }, ), # handle cases where the screen_name is missing from the response causing # an exception during parsing ( "twitter://consumer_key/consumer_secret2/atoken6/access_secret", { # No user mean's we message ourselves "instance": NotifyTwitter, # v1.1-format response (missing "data" key) causes _whoami to fail "requests_response_text": { "id": 12345, "media_id": 123, }, # due to a mangled response_text we'll fail "notify_response": False, }, ), ( "twitter://user@consumer_key/csecret2/atoken7/access_secret/-/%/", { # One Invalid User "instance": NotifyTwitter, # Expected notify() response False (because we won't be able # to detect our user) "notify_response": False, }, ), ( ( "twitter://user@consumer_key/csecret/atoken8/access_secret" "?cache=No&batch=No" ), { # No Cache & No Batch "instance": NotifyTwitter, # v2 user lookup format "requests_response_text": { "data": [{"id": "12345", "username": "user"}] }, }, ), ( "twitter://user@consumer_key/csecret/atoken9/access_secret", { # We're good! "instance": NotifyTwitter, # v2 user lookup format "requests_response_text": { "data": [{"id": "12345", "username": "user"}] }, }, ), ( "twitter://user@consumer_key/csecret/atoken11/access_secret", { # We're identifying the same user we already sent to "instance": NotifyTwitter, "notify_response": False, }, ), ( "tweet://ckey/csecret/atoken12/access_secret", { # A Public Tweet "instance": NotifyTwitter, }, ), ( "twitter://user@ckey/csecret/atoken13/access_secret?mode=invalid", { # An invalid mode "instance": TypeError, }, ), ( ( "twitter://usera@consumer_key/consumer_secret/atoken14/" "access_secret/user/?to=userb" ), { # We're good! "instance": NotifyTwitter, # v2 user lookup format with multiple entries and a garbage entry # to test exception handling "requests_response_text": { "data": [ {"id": "12345", "username": "usera"}, {"id": "12346", "username": "userb"}, { # A garbage entry we can test exception handling on }, ] }, }, ), ( "twitter://ckey/csecret/atoken15/access_secret", { "instance": NotifyTwitter, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "twitter://ckey/csecret/atoken16/access_secret", { "instance": NotifyTwitter, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ( "twitter://ckey/csecret/atoken17/access_secret?mode=tweet", { "instance": NotifyTwitter, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def good_response(data): """Prepare a good response.""" response = Mock() response.content = json.dumps(data) response.status_code = requests.codes.ok return response def bad_response(data): """Prepare a bad response.""" response = Mock() response.content = json.dumps(data) response.status_code = requests.codes.internal_server_error return response @pytest.fixture def twitter_url(): ckey = "ckey" csecret = "csecret" akey = "akey" asecret = "asecret" url = f"twitter://{ckey}/{csecret}/{akey}/{asecret}" return url @pytest.fixture def good_message_response(): """Prepare a good v2 whoami response.""" # v2 /users/me format: {"data": {"id": "...", "username": "..."}} response = good_response( { "data": { "id": "9876", "username": TWITTER_SCREEN_NAME, } } ) return response @pytest.fixture def bad_message_response(): """Prepare a bad message response.""" response = bad_response( { "errors": [ { "code": 999, "message": "Something failed", } ] } ) return response @pytest.fixture def good_media_response(): """Prepare a good v2 media upload response.""" # v2 /2/media/upload format: {"data": {"id": "...", ...}} response = Mock() response.content = json.dumps( { "data": { "id": "710511363345354753", "media_key": "3_710511363345354753", "expires_after_secs": 86400, "size": 11065, } } ) response.status_code = requests.codes.ok return response @pytest.fixture def bad_media_response(): """Prepare a bad media response.""" response = bad_response( { "errors": [ { "code": 93, "message": ( "This application is not allowed to access or " "delete your direct messages." ), } ] } ) return response @pytest.fixture(autouse=True) def ensure_whoami_is_mocked(mocker, good_message_response): """ Make sure requests to https://api.twitter.com/2/users/me do not escape the test harness, for all test case functions. """ mock_get = mocker.patch("requests.get") mock_get.return_value = good_message_response def test_plugin_twitter_urls(): """NotifyTwitter() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_twitter_general(mocker): """NotifyTwitter() General Tests.""" mock_get = mocker.patch("requests.get") mock_post = mocker.patch("requests.post") ckey = "ckey" csecret = "csecret" akey = "akey" asecret = "asecret" # v2 user lookup response: {"data": [{"id": "...", "username": "..."}]} user_lookup_obj = { "data": [ { "username": TWITTER_SCREEN_NAME, "id": "9876", } ] } # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) request = Mock() request.content = json.dumps(user_lookup_obj) request.status_code = requests.codes.ok request.headers = { "x-rate-limit-reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "x-rate-limit-remaining": 1, } # Prepare Mock mock_get.return_value = request mock_post.return_value = request # Variation Initializations obj = NotifyTwitter( ckey=ckey, csecret=csecret, akey=akey, asecret=asecret, targets=TWITTER_SCREEN_NAME, ) assert isinstance(obj, NotifyTwitter) is True assert isinstance(obj.url(), str) is True # apprise room was found assert obj.send(body="test") is True # Change our status code and try again request.status_code = 403 assert obj.send(body="test") is False assert obj.ratelimit_remaining == 1 # Return the status request.status_code = requests.codes.ok # Force a reset request.headers["x-rate-limit-remaining"] = 0 # behind the scenes, it should cause us to update our rate limit assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 # This should cause us to block request.headers["x-rate-limit-remaining"] = 10 assert obj.send(body="test") is True assert obj.ratelimit_remaining == 10 # Handle cases where we simply couldn't get this field del request.headers["x-rate-limit-remaining"] assert obj.send(body="test") is True # It remains set to the last value assert obj.ratelimit_remaining == 10 # Reset our variable back to 1 request.headers["x-rate-limit-remaining"] = 1 # Handle cases where our epoch time is wrong del request.headers["x-rate-limit-reset"] assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block request.headers["x-rate-limit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() + 1 request.headers["x-rate-limit-remaining"] = 0 obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block request.headers["x-rate-limit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() - 1 request.headers["x-rate-limit-remaining"] = 0 obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Return our limits to always work request.headers["x-rate-limit-reset"] = ( datetime.now(timezone.utc) - epoch ).total_seconds() request.headers["x-rate-limit-remaining"] = 1 obj.ratelimit_remaining = 1 # Alter pending targets obj.targets.append("usera") # Response still in v2 user_lookup format from above request.content = json.dumps(user_lookup_obj) assert obj.send(body="test") is True # Flush our cache forcing it is re-creating obj._user_cache = {} assert obj.send(body="test") is True # Cause content response to be None request.content = None assert obj.send(body="test") is True # Invalid JSON request.content = "{" assert obj.send(body="test") is True # Return it to a parseable string request.content = "{}" results = NotifyTwitter.parse_url( f"twitter://{ckey}/{csecret}/{akey}/{asecret}?to={TWITTER_SCREEN_NAME}" ) assert isinstance(results, dict) is True assert TWITTER_SCREEN_NAME in results["targets"] # Valid JSON response; send succeeds assert obj.send(body="test") is True # Set ourselves up to handle whoami calls # Flush out our cache obj._user_cache = {} # v2 whoami response: {"data": {"id": "...", "username": "..."}} whoami_obj = { "data": { "username": TWITTER_SCREEN_NAME, "id": "9876", } } request.content = json.dumps(whoami_obj) obj = NotifyTwitter(ckey=ckey, csecret=csecret, akey=akey, asecret=asecret) assert obj.send(body="test") is True # Alter the key forcing us to look up a new value of ourselves again obj._user_cache = {} obj._whoami_cache = None obj.ckey = "different.then.it.was" assert obj.send(body="test") is True obj._whoami_cache = None obj.ckey = "different.again" assert obj.send(body="test") is True def test_plugin_twitter_garbage_responses(mocker): """Verify garbage/invalid server responses are handled gracefully.""" mock_get = mocker.patch("requests.get") mock_post = mocker.patch("requests.post") ckey = "ckey" csecret = "csecret" akey = "akey" asecret = "asecret" # Base request mock returning HTTP 200 request = Mock() request.status_code = requests.codes.ok request.headers = {} mock_get.return_value = request mock_post.return_value = request # No targets -> _send_dm calls _whoami obj = NotifyTwitter(ckey=ckey, csecret=csecret, akey=akey, asecret=asecret) # _whoami: None content -> TypeError in loads() -> content = {} # -> no "data" key in {} -> KeyError caught -> _whoami returns {} request.content = None assert obj.send(body="test") is False # _whoami: malformed JSON -> ValueError in loads() -> content = {} # -> same path as above obj._whoami_cache = None request.content = "{" assert obj.send(body="test") is False # _whoami: valid JSON but v1.1-style response (missing "data" key) # -> KeyError in _whoami -> caught -> returns {} obj._whoami_cache = None request.content = json.dumps({"screen_name": "apprise", "id": 9876}) assert obj.send(body="test") is False # Has targets -> _send_dm calls _user_lookup obj2 = NotifyTwitter( ckey=ckey, csecret=csecret, akey=akey, asecret=asecret, targets=TWITTER_SCREEN_NAME, ) # _user_lookup: response is a list, not a dict # -> isinstance(response, dict) is False -> continue -> no results request.content = json.dumps( [{"id": "9876", "username": TWITTER_SCREEN_NAME}] ) assert obj2.send(body="test") is False # _fetch: json=False with a non-None payload -> raw body path request.content = json.dumps({}) postokay, _ = obj._fetch( "https://api.twitter.com/2/tweets", payload="raw=data", json=False, ) assert postokay is True # _fetch: HTTP 201 Created is treated as success (v2 POST endpoints) request.status_code = requests.codes.created request.content = json.dumps({"data": {"id": "1"}}) postokay, _response = obj._fetch( "https://api.twitter.com/2/tweets", payload={"text": "hi"}, ) assert postokay is True def test_plugin_twitter_edge_cases(): """NotifyTwitter() Edge Cases.""" with pytest.raises(TypeError): NotifyTwitter(ckey=None, csecret=None, akey=None, asecret=None) with pytest.raises(TypeError): NotifyTwitter(ckey="value", csecret=None, akey=None, asecret=None) with pytest.raises(TypeError): NotifyTwitter(ckey="value", csecret="value", akey=None, asecret=None) with pytest.raises(TypeError): NotifyTwitter( ckey="value", csecret="value", akey="value", asecret=None ) assert isinstance( NotifyTwitter( ckey="value", csecret="value", akey="value", asecret="value" ), NotifyTwitter, ) assert isinstance( NotifyTwitter( ckey="value", csecret="value", akey="value", asecret="value", user="l2gnux", ), NotifyTwitter, ) # Invalid Target User obj = NotifyTwitter( ckey="value", csecret="value", akey="value", asecret="value", targets="%G@rB@g3", ) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) def test_plugin_twitter_dm_caching( mocker, twitter_url, good_message_response, good_media_response ): """Verify that the `NotifyTwitter.{_user_cache,_whoami_cache}` caches work as intended.""" # This is the request to `https://api.twitter.com/2/users/me`. # Explicitly mock it here so the calls to it can be evaluated. mock_get = mocker.patch("requests.get") mock_get.return_value = good_message_response # This test case submits two notifications, so make sure to provide two # mocked responses. mock_post = mocker.patch("requests.post") mock_post.side_effect = [good_message_response, good_message_response] # Make sure to start with empty caches. if hasattr(NotifyTwitter, "_user_cache"): NotifyTwitter._user_cache = {} if hasattr(NotifyTwitter, "_whoami_cache"): NotifyTwitter._whoami_cache = {} # Create application objects. obj = Apprise.instantiate(twitter_url) # Send the first notification. assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Test call counts. assert mock_get.call_count == 1 assert ( # v2 current-user endpoint replaced v1.1 verify_credentials mock_get.call_args_list[0][0][0] == "https://api.twitter.com/2/users/me" ) assert mock_post.call_count == 1 # v2 DM endpoint; user id "9876" comes from good_message_response assert ( mock_post.call_args_list[0][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) # Reset the mocks to start counting calls from scratch. mock_get.reset_mock() mock_post.reset_mock() # Send another notification. assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Calls to `users/me` will get cached by `NotifyTwitter`. # So, the `GET` request to `users/me` should only have been # issued once. assert mock_get.call_count == 0 assert mock_post.call_count == 1 def test_plugin_twitter_dm_attachments_basic( mocker, twitter_url, good_message_response, good_media_response ): """ NotifyTwitter() DM Attachment Checks - Basic """ mock_get = mocker.patch("requests.get") mock_post = mocker.patch("requests.post") # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) mock_get.return_value = good_message_response mock_post.return_value.headers = { "x-rate-limit-reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "x-rate-limit-remaining": 1, } # The first response is for uploading the attachment, # the second one for posting the actual message. mock_post.side_effect = [good_media_response, good_message_response] # Create application objects. obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test call counts. assert mock_get.call_count == 1 assert ( # v2 whoami endpoint mock_get.call_args_list[0][0][0] == "https://api.twitter.com/2/users/me" ) assert mock_post.call_count == 2 assert ( # v2 media upload endpoint (available on all access tiers) mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( # v2 DM endpoint; user id "9876" comes from good_message_response mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) def test_plugin_twitter_dm_attachments_message_fails( mocker, twitter_url, good_media_response, bad_message_response ): """Test case with a bad media response.""" mock_post = mocker.patch("requests.post") mock_post.side_effect = [good_media_response, bad_message_response] # Create application objects. obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification; it will fail because of the message response. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 2 assert ( # v2 media upload endpoint (available on all access tiers) mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( # v2 DM endpoint; user id "9876" comes from good_message_response mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) def test_plugin_twitter_dm_attachments_upload_fails( mocker, twitter_url, good_message_response, bad_media_response ): """Test case where upload fails.""" mock_post = mocker.patch("requests.post") mock_post.side_effect = [bad_media_response, good_message_response] # Create application objects. obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification; it will fail because of the media response. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Test call counts. assert mock_post.call_count == 1 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) def test_plugin_twitter_dm_attachments_invalid_attachment( mocker, twitter_url, good_message_response ): """Test case with an invalid attachment.""" mock_post: Mock = mocker.patch("requests.post") mock_post.side_effect = [good_media_response, good_message_response] # Create application objects. # An invalid attachment will cause a failure. obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") ) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Verify no post requests have been issued, because attachment is not good. assert mock_post.mock_calls == [] def test_plugin_twitter_dm_attachments_multiple( mocker, twitter_url, good_message_response, good_media_response ): mock_post = mocker.patch("requests.post") mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_media_response, good_message_response, good_message_response, good_message_response, good_message_response, ] # 4 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] # Create application objects. obj = Apprise.instantiate(twitter_url) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 8 # First 4 calls are v2 media uploads assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[3][0][0] == "https://api.x.com/2/media/upload" ) # Last 4 calls are DM sends via v2 endpoint; user id "9876" from # good_message_response assert ( mock_post.call_args_list[4][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) assert ( mock_post.call_args_list[5][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) assert ( mock_post.call_args_list[6][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) assert ( mock_post.call_args_list[7][0][0] == "https://api.twitter.com/2/dm_conversations/with/9876/messages" ) def test_plugin_twitter_dm_attachments_multiple_oserror( mocker, twitter_url, good_message_response, good_media_response ): # Inject an `OSError` into the middle of the operation. mock_post = mocker.patch("requests.post") mock_post.side_effect = [good_media_response, OSError()] # 2 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] # Create application objects. obj = Apprise.instantiate(twitter_url) # We'll fail to send this time assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_basic( mock_post, twitter_url, good_message_response, good_media_response ): """ NotifyTwitter() Tweet Attachment Checks - Basic """ mock_post.side_effect = [good_media_response, good_message_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Verify API calls. assert mock_post.call_count == 2 assert ( # v2 media upload endpoint (available on all access tiers) mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( # v2 tweet endpoint mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_more_logging( mock_post, twitter_url, good_media_response ): """ NotifyTwitter() Tweet Attachment Checks - More logging """ # v2 tweet response: {"data": {"id": "...", "text": "..."}} good_tweet_response = good_response( { "data": { "id": "12345", "text": "body", }, } ) mock_post.side_effect = [good_media_response, good_tweet_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Verify API calls. assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_bad_message_response( mock_post, twitter_url, good_media_response, bad_message_response ): mock_post.side_effect = [good_media_response, bad_message_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Our notification will fail now since our tweet will error out. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Verify API calls. assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_bad_message_response_unparseable( mock_post, twitter_url, good_media_response ): bad_message_response = bad_response("") mock_post.side_effect = [good_media_response, bad_message_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # The notification will fail now since the tweet will error out. # This is the same test as above, except that the error response is not # parseable. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Verify API calls. assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_upload_fails( mock_post, twitter_url, good_media_response ): # Prepare a bad tweet response. bad_tweet_response = bad_response({}) # Test case where upload fails. mock_post.side_effect = [good_media_response, bad_tweet_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) # Send our notification; it will fail because of the message response. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # Verify API calls. assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_invalid_attachment( mock_post, twitter_url, good_message_response, good_media_response ): mock_post.side_effect = [good_media_response, good_message_response] # Create application objects. twitter_url += "?mode=tweet" obj = Apprise.instantiate(twitter_url) attach = AppriseAttachment( os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") ) # An invalid attachment will cause a failure. assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) # No post request as attachment is not good. assert mock_post.call_count == 0 @patch("requests.post") def test_plugin_twitter_tweet_attachments_multiple_batch( mock_post, twitter_url, good_message_response, good_media_response ): mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_media_response, good_message_response, good_message_response, good_message_response, good_message_response, ] # instantiate our object obj = Apprise.instantiate(twitter_url + "?mode=tweet") # 4 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 7 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[3][0][0] == "https://api.x.com/2/media/upload" ) # v2 tweet endpoint assert ( mock_post.call_args_list[4][0][0] == "https://api.twitter.com/2/tweets" ) assert ( mock_post.call_args_list[5][0][0] == "https://api.twitter.com/2/tweets" ) # The 2 images are grouped together (batch mode) assert ( mock_post.call_args_list[6][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_multiple_nobatch( mock_post, twitter_url, good_message_response, good_media_response ): mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_media_response, good_message_response, good_message_response, good_message_response, good_message_response, ] # instantiate our object (without a batch mode) obj = Apprise.instantiate(twitter_url + "?mode=tweet&batch=no") # 4 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) assert mock_post.call_count == 8 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[3][0][0] == "https://api.x.com/2/media/upload" ) # v2 tweet endpoint (one per image, no batching) assert ( mock_post.call_args_list[4][0][0] == "https://api.twitter.com/2/tweets" ) assert ( mock_post.call_args_list[5][0][0] == "https://api.twitter.com/2/tweets" ) assert ( mock_post.call_args_list[6][0][0] == "https://api.twitter.com/2/tweets" ) assert ( mock_post.call_args_list[7][0][0] == "https://api.twitter.com/2/tweets" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_multiple_oserror( mock_post, twitter_url, good_media_response ): # We have an OSError thrown in the middle of our preparation mock_post.side_effect = [good_media_response, OSError()] # 2 images are produced attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.gif"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), # This one is not supported, so it's ignored gracefully os.path.join(TEST_VAR_DIR, "apprise-test.mp4"), ] # We'll fail to send this time obj = Apprise.instantiate(twitter_url + "?mode=tweet") assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is False ) assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) @patch("requests.post") def test_plugin_twitter_tweet_attachments_jpeg_before_gif( mock_post, twitter_url, good_media_response ): """ Verify that when PNG/JPEG attachments precede a GIF in batch mode, the accumulated PNG/JPEG batch is flushed first, then the GIF is emitted alone. This exercises the ``if batch:`` flush branch inside the non-batchable (GIF) path of _send_tweet. """ # v2 tweet response good_tweet_response = good_response( {"data": {"id": "12345", "text": "body"}} ) # 3 uploads (jpeg, png, gif) + 2 tweet sends = 5 post calls mock_post.side_effect = [ good_media_response, good_media_response, good_media_response, good_tweet_response, good_tweet_response, ] # instantiate in tweet + batch mode obj = Apprise.instantiate(twitter_url + "?mode=tweet") # jpeg and png come first, then gif -- the gif triggers a flush of # the pending jpeg/png batch before being emitted in its own tweet attach = [ os.path.join(TEST_VAR_DIR, "apprise-test.jpeg"), os.path.join(TEST_VAR_DIR, "apprise-test.png"), os.path.join(TEST_VAR_DIR, "apprise-test.gif"), ] assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # 3 media uploads + 2 tweet sends assert mock_post.call_count == 5 # First 3 are v2 media uploads assert ( mock_post.call_args_list[0][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[1][0][0] == "https://api.x.com/2/media/upload" ) assert ( mock_post.call_args_list[2][0][0] == "https://api.x.com/2/media/upload" ) # jpeg + png batched into one tweet assert ( mock_post.call_args_list[3][0][0] == "https://api.twitter.com/2/tweets" ) # gif emitted alone in its own tweet assert ( mock_post.call_args_list[4][0][0] == "https://api.twitter.com/2/tweets" ) caronc-apprise-182f859/tests/test_plugin_vapid.py000066400000000000000000000610361524161175200221560ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging import os import sys from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import asset, exception, url from apprise.common import PersistentStoreMode from apprise.plugins.vapid import NotifyVapid from apprise.plugins.vapid.subscription import ( WebPushSubscription, WebPushSubscriptionManager, ) from apprise.utils.pem import ApprisePEMController logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # a test UUID we can use SUBSCRIBER = "user@example.com" PLUGIN_ID = "vapid" # Our Testing URLs apprise_url_tests = ( ( "vapid://", { "instance": TypeError, }, ), ( "vapid://:@/", { "instance": TypeError, }, ), ( "vapid://invalid-subscriber", { # An invalid Subscriber "instance": TypeError, }, ), ( "vapid://user@example.com", { # bare bone requirements met, but we don't have our subscription # file or our private key (pem) "instance": NotifyVapid, # We'll fail to respond because we would not have found any # configuration to load "notify_response": False, }, ), ( "vapid://user@example.com?keyfile=invalid&subfile=invalid", { # Test passing keyfile and subfile on our path (even if invalid) "instance": NotifyVapid, # We'll fail to respond because we would not have found any # configuration to load "notify_response": False, }, ), ( "vapid://user@example.com/newuser@example.com", { # we don't have our subscription file or private key "instance": NotifyVapid, "notify_response": False, }, ), ( "vapid://user@example.ca/newuser@example.ca", { "instance": NotifyVapid, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "vapid://user@example.uk/newuser@example.uk", { "instance": NotifyVapid, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "vapid://user@example.au/newuser@example.au", { "instance": NotifyVapid, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) @pytest.fixture def patch_persistent_store_namespace(tmpdir): """Force an easy to test environment.""" with ( mock.patch.object(url.URLBase, "url_id", return_value=PLUGIN_ID), mock.patch.object( asset.AppriseAsset, "storage_mode", PersistentStoreMode.AUTO ), mock.patch.object(asset.AppriseAsset, "storage_path", str(tmpdir)), ): tmp_dir = tmpdir.mkdir(PLUGIN_ID) # Return the directory name yield str(tmp_dir) @pytest.fixture def subscription_reference(): return { "user@example.com": { "endpoint": "https://fcm.googleapis.com/fcm/send/default", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, "user1": { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, "user2": { "endpoint": "https://fcm.googleapis.com/fcm/send/def456", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, } @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_urls(): """ NotifyVapid() Apprise URLs - No Config """ # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_urls_with_required_assets( patch_persistent_store_namespace, subscription_reference ): """NotifyVapid() Apprise URLs With Config.""" # Determine our store pc = ApprisePEMController(path=patch_persistent_store_namespace) assert pc.keygen() is True # Write our subscriptions file to disk subscription_file = os.path.join( patch_persistent_store_namespace, NotifyVapid.vapid_subscription_file ) with open(subscription_file, "w") as f: f.write(json.dumps(subscription_reference)) tests = ( ( "vapid://user@example.com", { # user@example.com loaded (also used as subscriber id) "instance": NotifyVapid, }, ), ( "vapid://user@example.com/newuser@example.com", { # no newuser@example.com key entry "instance": NotifyVapid, "notify_response": False, }, ), ( "vapid://user@example.com/user1?to=user2", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://user1?to=user2&from=user@example.com", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://?to=user2&from=user@example.com", { # No host provided "instance": NotifyVapid, }, ), ( "vapid://user@example.com?to=user2&from=user@example.com", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=15", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=invalid", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=-4000", { # bad ttl "instance": TypeError, }, ), ( "vapid://user@example.com/user1?to=user2&mode=edge", { # test mode "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&mode=", { # test mode "instance": TypeError, }, ), ( "vapid://user@example.com/user1?to=user2&mode=invalid", { # test mode more "instance": TypeError, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # Throws a series of connection and transfer exceptions # when this flag is set and tests that we gracefully handle # them "test_requests_exceptions": True, }, ), ) AppriseURLTester(tests=tests).run_all() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subscriptions(tmpdir): """NotifyVapid() Subscriptions.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") with pytest.raises(exception.AppriseInvalidData): # Integer not supported WebPushSubscription(42) with pytest.raises(exception.AppriseInvalidData): # Not the correct format WebPushSubscription("bad-content") with pytest.raises(exception.AppriseInvalidData): # Invalid JSON WebPushSubscription("{") with pytest.raises(exception.AppriseInvalidData): # Empty Dictionary WebPushSubscription({}) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR=", "auth": 42, }, } ) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": 42, "auth": "k9Xzm43nBGo=", }, } ) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", } ) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": {}, } ) with pytest.raises(exception.AppriseInvalidData): # Invalid p256dh public key provided wps = WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR=", "auth": "k9Xzm43nBGo=", }, } ) # An empty object wps = WebPushSubscription() assert bool(wps) is False assert isinstance(wps.json(), str) assert json.loads(wps.json()) assert str(wps) == "" assert wps.auth is None assert wps.endpoint is None assert wps.p256dh is None assert wps.public_key is None # We can't write anything as there is nothing loaded assert wps.write(os.path.join(str(tmpdir0), "subscriptions.json")) is False # A valid key wps = WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } ) assert bool(wps) is True assert isinstance(wps.json(), str) assert json.loads(wps.json()) assert str(wps) == "abc123" assert wps.auth == "k9Xzm43nBGo=" assert wps.endpoint == "https://fcm.googleapis.com/fcm/send/abc123" assert ( wps.p256dh == "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ) assert wps.public_key is not None # Currently no files here assert os.listdir(str(tmpdir0)) == [] # Bad content assert wps.write(object) is False assert wps.write(None) is False # Can't write to a name already taken by as a directory assert wps.write(str(tmpdir0)) is False # Can't write to a name already taken by as a directory assert wps.write(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_plugin_vapid_subscriptions_without_c(): """NotifyVapid() Subscriptions (no Cryptography)""" with pytest.raises(exception.AppriseInvalidData): # A valid key that can't be loaded because crytography is missing WebPushSubscription( { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } ) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subscription_manager(tmpdir): """NotifyVapid() Subscription Manager.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") with pytest.raises(exception.AppriseInvalidData): # An invalid object smgr = WebPushSubscriptionManager() smgr["abc"] = "invalid" with pytest.raises(exception.AppriseInvalidData): # An invalid object smgr = WebPushSubscriptionManager() smgr += "invalid" smgr = WebPushSubscriptionManager() assert bool(smgr) is False assert len(smgr) == 0 sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } assert smgr.add(sub) is True assert bool(smgr) is True assert len(smgr) == 1 # Same sub (overwrites same slot) smgr += sub assert bool(smgr) is True assert len(smgr) == 1 # This makes a copy smgr["abc"] = smgr["abc123"] assert bool(smgr) is True assert len(smgr) == 2 assert isinstance(smgr["abc123"], WebPushSubscription) # Currently no files here assert os.listdir(str(tmpdir0)) == [] # Write our content assert smgr.write(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] # Reset our object smgr.clear() assert bool(smgr) is False assert len(smgr) == 0 # Load our content back assert smgr.load(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert bool(smgr) is True assert len(smgr) == 2 # Write over our file using the standard Subscription format assert ( smgr["abc123"].write(os.path.join(str(tmpdir0), "subscriptions.json")) is True ) # We can still open this type as well assert smgr.load(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert bool(smgr) is True assert len(smgr) == 1 smgr.clear() bad_entry = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "invalid", "auth": "garbage", }, } subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: # A bad JSON file f.write("{") assert smgr.load(subscriptions) is False with open(subscriptions, "w", encoding="utf-8") as f: # not expected dictionary f.write("null") assert smgr.load(subscriptions) is False subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: json.dump(bad_entry, f) assert smgr.load(subscriptions) is False # Create bad data bad_data = { "bad1": bad_entry, "bad2": bad_entry, "bad3": bad_entry, "bad4": bad_entry, } subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: json.dump(bad_data, f) assert smgr.load(subscriptions) is False assert smgr.load("invalid-file") is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_initializations(mock_post, tmpdir): """NotifyVapid() Initializations.""" # Assign our mock object our return value okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" mock_post.return_value = okay_response # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") # Write our subfile smgr = WebPushSubscriptionManager() sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } subfile = os.path.join(str(tmpdir0), "subscriptions.json") assert smgr.add(sub) is True assert smgr.add(smgr["abc123"]) is True assert os.listdir(str(tmpdir0)) == [] with mock.patch("json.dump", side_effect=OSError): # We will fial to write assert smgr.write(subfile) is False assert smgr.write(subfile) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] assert isinstance(smgr.json(), str) asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), # Auto-gen our private/public key pair pem_autogen=True, ) # Auto-Key Generation obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) # Our subscription directory + our # persistent store where our keys were generated assert len(os.listdir(str(tmpdir0))) == 2 # Second call re-references keys previously generated obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is True # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is True obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile="/a/bad/path", asset=asset_, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False # Detect our keyfile cache_dir = next( x for x in os.listdir(str(tmpdir0)) if not x.endswith("subscriptions.json") ) # Test fixed assignment to our keyfile keyfile = os.path.join(str(tmpdir0), cache_dir, "private_key.pem") assert os.path.exists(keyfile) obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=keyfile, subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is True # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is True # Invalid Keyfile obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=subfile, subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False # AutoGen Temporary directory tmpdir1 = tmpdir.mkdir("tmp01") asset2 = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir1), # Auto-gen our private/public key pair pem_autogen=True, ) assert os.listdir(str(tmpdir1)) == [] obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=keyfile, asset=asset2, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) # We have a temporary subscription file we can use assert os.listdir(str(tmpdir1)) == ["00088ad3"] # We will have a dud configuration file, but at least it's something # to help the user with assert obj.send("test") is False # Second instance fails as well assert obj.send("test") is False # AutoGen Temporary directory tmpdir2 = tmpdir.mkdir("tmp02") asset3 = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir2), # Auto-gen our private/public key pair pem_autogen=True, ) # Test invalid keyfile assert os.path.exists(keyfile) obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile="invalid-file", subfile=subfile, asset=asset3, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_plugin_vapid_initializations_without_c(tmpdir): """NotifyVapid() Initializations without cryptography.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") # Write our subfile smgr = WebPushSubscriptionManager() sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } subfile = os.path.join(str(tmpdir0), "subscriptions.json") assert smgr.add(sub) is False asset_ = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), # Auto-gen our private/public key pair pem_autogen=True, ) # Auto-Key Generation obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=asset_, ) assert isinstance(obj, NotifyVapid) caronc-apprise-182f859/tests/test_plugin_viber.py000066400000000000000000000135471524161175200221660ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging from helpers import AppriseURLTester import requests from apprise import Apprise from apprise.plugins.viber import NotifyViber logging.disable(logging.CRITICAL) VIBER_GOOD_RESPONSE = dumps({"status": 0, "status_message": "ok"}) VIBER_BAD_RESPONSE = dumps( {"status": 12, "status_message": "Too many requests"} ) # Our Testing URLs apprise_url_tests = ( ("viber://", False), ("viber:///", False), ("viber://tokena", {"instance": NotifyViber, "notify_response": False}), ( "viber://?token=tokenb", {"instance": NotifyViber, "notify_response": False}, ), ( "viber://token/targetx", { "instance": NotifyViber, # Our response expected server response "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/t1/t2?from=Viber%20Bot", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://t1/t2?token=token", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://?token=token&to=t5", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/t3?avatar=value", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/?to=abc,def", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/m12/?from={}".format( "a" * (NotifyViber.viber_sender_name_limit + 1) ), { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://token/m12/?from={}".format( "b" * (NotifyViber.viber_sender_name_limit) ), { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://?token=token&to=hij,klm", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, }, ), ( "viber://?token=token&to=nop,qrs", { "instance": NotifyViber, "requests_response_text": VIBER_BAD_RESPONSE, "notify_response": False, }, ), ( "viber://?token=token&to=tuv,wxy", { "instance": NotifyViber, # Bad JSON "requests_response_text": "{", "notify_response": False, }, ), # Privacy redaction of token ( "viber://token/t10", { "instance": NotifyViber, "requests_response_text": VIBER_GOOD_RESPONSE, "privacy_url": "viber://****/t10", }, ), ( "viber://token/targetZ", { "instance": NotifyViber, # throw a bizarre code forcing us to fail to look it up "requests_response_text": VIBER_GOOD_RESPONSE, "response": False, "requests_response_code": 999, }, ), ( "viber://token/targetZ", { "instance": NotifyViber, # force a failure "response": False, "requests_response_text": VIBER_BAD_RESPONSE, "requests_response_code": requests.codes.internal_server_error, }, ), ( "viber://token/targetY", { "instance": NotifyViber, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "requests_response_text": VIBER_GOOD_RESPONSE, "test_requests_exceptions": True, }, ), ) def test_plugin_viber_urls(): """Verify URL parsing, privacy, and basic validation.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_viber_http_error_and_exception(mocker): """Verify HTTP error and requests exception paths.""" post = mocker.patch("requests.post") post.return_value.status_code = 400 a = Apprise() assert a.add("viber://token/target2") is True assert a.notify("test") is False post.side_effect = requests.RequestException("boom") a = Apprise() assert a.add("viber://token/target2") is True assert a.notify("test") is False caronc-apprise-182f859/tests/test_plugin_voipms.py000066400000000000000000000200411524161175200223570ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.plugins.voipms import NotifyVoipms logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "voipms://", { # No email/password specified "instance": TypeError, }, ), ( "voipms://@:", { # Invalid url "instance": TypeError, }, ), ( "voipms://{}/{}".format("user@example.com", "1" * 11), { # No password specified "instance": TypeError, }, ), ( "voipms://:{}".format("password"), { # No email specified "instance": TypeError, }, ), ( "voipms://{}:{}/{}".format("user@", "pass", "1" * 11), { # Check valid email "instance": TypeError, }, ), ( "voipms://{password}:{email}".format( email="user@example.com", password="password" ), { # No from_phone specified "instance": TypeError, }, ), # Invalid phone number test ( "voipms://{password}:{email}/1613".format( email="user@example.com", password="password" ), { # Invalid phone number "instance": TypeError, }, ), # Invalid country code phone number test ( "voipms://{password}:{email}/01133122446688".format( email="user@example.com", password="password" ), { # Non North American phone number "instance": TypeError, }, ), ( "voipms://{password}:{email}/{from_phone}/{targets}/".format( email="user@example.com", password="password", from_phone="16134448888", targets="/".join(["26134442222"]), ), { # Invalid target phone number "instance": NotifyVoipms, "response": False, "requests_response_code": 999, }, ), ( "voipms://{password}:{email}/{from_phone}".format( email="user@example.com", password="password", from_phone="16138884444", ), { "instance": NotifyVoipms, # No targets specified "response": False, "requests_response_code": 999, }, ), ( "voipms://{password}:{email}/?from={from_phone}".format( email="user@example.com", password="password", from_phone="16138884444", ), { "instance": NotifyVoipms, # No targets specified "response": False, "requests_response_code": 999, }, ), ( "voipms://{password}:{email}/{from_phone}/{targets}/".format( email="user@example.com", password="password", from_phone="16138884444", targets="/".join(["16134442222"]), ), { # Valid "instance": NotifyVoipms, "response": True, "privacy_url": "voipms://p...d:user@example.com/16...4", }, ), ( "voipms://{password}:{email}/{from_phone}/{targets}/".format( email="user@example.com", password="password", from_phone="16138884444", targets="/".join(["16134442222", "16134443333"]), ), { # Valid multiple targets "instance": NotifyVoipms, "response": True, "privacy_url": "voipms://p...d:user@example.com/16...4", }, ), ( "voipms://{password}:{email}/?from={from_phone}&to={targets}".format( email="user@example.com", password="password", from_phone="16138884444", targets="16134448888", ), { # Valid "instance": NotifyVoipms, }, ), ( "voipms://{password}:{email}/{from_phone}/{targets}/".format( email="user@example.com", password="password", from_phone="16138884444", targets="16134442222", ), { "instance": NotifyVoipms, # Throws a series of errors "test_requests_exceptions": True, }, ), ) def test_plugin_voipms(): """NotifyVoipms() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.get") def test_plugin_voipms_edge_cases(mock_get): """NotifyVoipms() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_get.return_value = response # Initialize some generic (but valid) tokens email = "user@example.com" password = "password" source = "+1 (555) 123-3456" targets = "+1 (555) 123-9876" # No email specified with pytest.raises(TypeError): NotifyVoipms(email=None, source=source) # a error response is returned response.status_code = 400 response.content = dumps( { "code": 21211, "message": "Unable to process your request.", } ) mock_get.return_value = response # Initialize our object obj = Apprise.instantiate( f"voipms://{password}:{email}/{source}/{targets}" ) assert isinstance(obj, NotifyVoipms) # We will fail with the above error code assert obj.notify("title", "body", "info") is False @mock.patch("requests.get") def test_plugin_voipms_non_success_status(mock_get): """NotifyVoipms() Non Success Status.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_get.return_value = response # A 200 response is returned but non-success message response.status_code = 200 response.content = dumps( { "status": "invalid_credentials", "message": "Username or Password is incorrect", } ) obj = Apprise.instantiate( "voipms://{password}:{email}/{source}/{targets}".format( email="user@example.com", password="badpassword", source="16134448888", targets="16134442222", ) ) assert isinstance(obj, NotifyVoipms) # We will fail with the above error code assert obj.notify("title", "body", "info") is False response.content = "{" assert obj.send("title", "body") is False caronc-apprise-182f859/tests/test_plugin_vonage.py000066400000000000000000000213571524161175200223340ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise.plugins.vonage import NotifyVonage logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "vonage://", { # No API Key specified "instance": TypeError, }, ), ( "vonage://:@/", { # invalid Auth key "instance": TypeError, }, ), ( "vonage://AC{}@12345678".format("a" * 8), { # Just a key provided "instance": TypeError, }, ), ( "vonage://AC{}:{}@{}".format("a" * 8, "b" * 16, "3" * 9), { # key and secret provided and from but invalid from no "instance": TypeError, }, ), ( "vonage://AC{}:{}@{}/?ttl=0".format("b" * 8, "c" * 16, "3" * 11), { # Invalid ttl defined "instance": TypeError, }, ), ( "vonage://AC{}:{}@{}".format("d" * 8, "e" * 16, "a" * 11), { # Invalid source number "instance": TypeError, }, ), ( "vonage://AC{}:{}@{}/123/{}/abcd/".format( "f" * 8, "g" * 16, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifyVonage, # Our expected url(privacy=True) startswith() response: "privacy_url": "vonage://A...f:****@", }, ), ( "vonage://AC{}:{}@{}".format("h" * 8, "i" * 16, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifyVonage, }, ), ( "vonage://_?key=AC{}&secret={}&from={}".format( "a" * 8, "b" * 16, "5" * 11 ), { # use get args to acomplish the same thing "instance": NotifyVonage, }, ), ( "vonage://_?key=AC{}&secret={}&source={}".format( "a" * 8, "b" * 16, "5" * 11 ), { # use get args to acomplish the same thing (use source instead # of from) "instance": NotifyVonage, }, ), ( "vonage://_?key=AC{}&secret={}&from={}&to={}".format( "a" * 8, "b" * 16, "5" * 11, "7" * 13 ), { # use to= "instance": NotifyVonage, }, ), ( "vonage://AC{}:{}@{}".format("a" * 8, "b" * 16, "6" * 11), { "instance": NotifyVonage, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "vonage://AC{}:{}@{}".format("a" * 8, "b" * 16, "6" * 11), { "instance": NotifyVonage, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # Nexmo Backwards Support ( "nexmo://", { # No API Key specified "instance": TypeError, }, ), ( "nexmo://:@/", { # invalid Auth key "instance": TypeError, }, ), ( "nexmo://AC{}@12345678".format("a" * 8), { # Just a key provided "instance": TypeError, }, ), ( "nexmo://AC{}:{}@{}".format("a" * 8, "b" * 16, "3" * 9), { # key and secret provided and from but invalid from no "instance": TypeError, }, ), ( "nexmo://AC{}:{}@{}/?ttl=0".format("b" * 8, "c" * 16, "3" * 11), { # Invalid ttl defined "instance": TypeError, }, ), ( "nexmo://AC{}:{}@{}".format("d" * 8, "e" * 16, "a" * 11), { # Invalid source number "instance": TypeError, }, ), ( "nexmo://AC{}:{}@{}/123/{}/abcd/".format( "f" * 8, "g" * 16, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifyVonage, # Our expected url(privacy=True) startswith() response: "privacy_url": "vonage://A...f:****@", }, ), ( "nexmo://AC{}:{}@{}".format("h" * 8, "i" * 16, "5" * 11), { # using phone no with no target - we text ourselves in # this case "instance": NotifyVonage, }, ), ( "nexmo://_?key=AC{}&secret={}&from={}".format( "a" * 8, "b" * 16, "5" * 11 ), { # use get args to acomplish the same thing "instance": NotifyVonage, }, ), ( "nexmo://_?key=AC{}&secret={}&source={}".format( "a" * 8, "b" * 16, "5" * 11 ), { # use get args to acomplish the same thing (use source instead of # from) "instance": NotifyVonage, }, ), ( "nexmo://_?key=AC{}&secret={}&from={}&to={}".format( "a" * 8, "b" * 16, "5" * 11, "7" * 13 ), { # use to= "instance": NotifyVonage, }, ), ( "nexmo://AC{}:{}@{}".format("a" * 8, "b" * 16, "6" * 11), { "instance": NotifyVonage, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "nexmo://AC{}:{}@{}".format("a" * 8, "b" * 16, "6" * 11), { "instance": NotifyVonage, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_vonage_urls(): """NotifyVonage() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_vonage_edge_cases(mock_post): """NotifyVonage() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens apikey = "AC{}".format("b" * 8) secret = "{}".format("b" * 16) source = "+1 (555) 123-3456" # No apikey specified with pytest.raises(TypeError): NotifyVonage(apikey=None, secret=secret, source=source) with pytest.raises(TypeError): NotifyVonage(apikey=" ", secret=secret, source=source) # No secret specified with pytest.raises(TypeError): NotifyVonage(apikey=apikey, secret=None, source=source) with pytest.raises(TypeError): NotifyVonage(apikey=apikey, secret=" ", source=source) # a error response response.status_code = 400 response.content = dumps( { "code": 21211, "message": "The 'To' number +1234567 is not a valid phone number.", } ) mock_post.return_value = response # Initialize our object obj = NotifyVonage(apikey=apikey, secret=secret, source=source) # We will fail with the above error code assert obj.notify("title", "body", "info") is False caronc-apprise-182f859/tests/test_plugin_webex_teams.py000066400000000000000000000552351524161175200233620ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import loads import logging import os from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType from apprise.plugins.webexteams import ( NotifyWebexTeams, WebexTeamsMode, ) logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # A valid webhook token (80 lowercase alphanumeric chars) WEBHOOK_TOKEN = "a" * 80 # A valid bot access token (longer than 160 chars, contains hyphen) # Must be > 160 chars or contain non-alphanumeric chars to bypass # the webhook-token regex (which only allows [a-z0-9], 80-160 chars). # Using 200 chars ending in '0' so the privacy URL ends in '0'. BOT_TOKEN = "Bc10" * 50 # 200 chars, ends in '0' # A valid Webex room ID (base64url-like, 50 chars) ROOM_ID = "Y2lzY29zcGFyazovL3VzL1JPTU9NLzEyMzQ1Njc4OTAxMjM0" ROOM_ID2 = "Y2lzY29zcGFyazovL3VzL1JPTU9NL2FiY2RlZjEyMzQ1Njc4" # Our Testing URLs apprise_url_tests = ( ( "wxteams://", { # Token missing "instance": TypeError, }, ), ( "wxteams://:@/", { # We don't have strict host checking on for wxteams, so this # URL actually becomes parseable and :@ becomes a hostname. # The below errors because a second token wasn't found "instance": TypeError, }, ), ( "wxteams://{}".format("a" * 80), { # webhook token provided - we're good "instance": NotifyWebexTeams, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxteams://a...a/", }, ), ( "wxteams://?token={}".format("a" * 80), { # token as query param - we're good "instance": NotifyWebexTeams, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxteams://a...a/", }, ), ( "webex://{}".format("a" * 140), { # token provided via 'webex' schema - we're good "instance": NotifyWebexTeams, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxteams://a...a/", }, ), # Support Native Webhook URLs ( "https://api.ciscospark.com/v1/webhooks/incoming/{}".format("a" * 80), { # token provided - we're good "instance": NotifyWebexTeams, }, ), # Support New Native Webhook URLs ( "https://webexapis.com/v1/webhooks/incoming/{}".format("a" * 100), { # token provided - we're good "instance": NotifyWebexTeams, }, ), # Support Native Webhook URLs with arguments ( "https://api.ciscospark.com/v1/webhooks/incoming/{}" "?format=text".format("a" * 80), { # token provided - we're good "instance": NotifyWebexTeams, }, ), # Bot mode: access_token + room ID ( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID), { "instance": NotifyWebexTeams, "privacy_url": "wxteams://B...0/", }, ), # Bot mode: explicit mode=bot with no room IDs - loads but fails to send ( "wxteams://{}?mode=bot".format("a" * 80), { "instance": NotifyWebexTeams, # notify() returns False with no targets (no HTTP call needed) "notify_response": False, }, ), # Explicit mode=webhook with a valid webhook token ( "wxteams://{}?mode=webhook".format("a" * 80), { "instance": NotifyWebexTeams, "privacy_url": "wxteams://a...a/", }, ), # Invalid mode ( "wxteams://{}?mode=invalid".format("a" * 80), { "instance": TypeError, }, ), # Webhook mode: HTTP 500 response ( "wxteams://{}".format("a" * 80), { "instance": NotifyWebexTeams, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), # Webhook mode: unusual HTTP response code ( "wxteams://{}".format("a" * 80), { "instance": NotifyWebexTeams, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), # Webhook mode: request exception ( "wxteams://{}".format("a" * 80), { "instance": NotifyWebexTeams, # Throws a series of i/o exceptions with this flag set and # tests that we gracefully handle them "test_requests_exceptions": True, }, ), # Bot mode: HTTP 500 response ( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID), { "instance": NotifyWebexTeams, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), # Bot mode: unusual HTTP response code ( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID), { "instance": NotifyWebexTeams, "response": False, "requests_response_code": 999, }, ), # Bot mode: request exception ( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID), { "instance": NotifyWebexTeams, "test_requests_exceptions": True, }, ), ) def test_plugin_webex_teams_urls(): """NotifyWebexTeams() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_webex_teams_webhook_mode(): """NotifyWebexTeams() Webhook mode direct tests.""" # Valid webhook token token = "b" * 80 obj = NotifyWebexTeams(token=token) assert isinstance(obj, NotifyWebexTeams) assert obj.mode == WebexTeamsMode.WEBHOOK # url() round-trip url = obj.url() assert "wxteams://" in url assert "mode=webhook" in url # parse_url round-trip result = NotifyWebexTeams.parse_url(url) assert result is not None obj2 = NotifyWebexTeams(**result) assert obj2.mode == WebexTeamsMode.WEBHOOK assert obj2.token == token # privacy URL priv = obj.url(privacy=True) assert obj.token not in priv # url_identifier assert obj.url_identifier == ( NotifyWebexTeams.secure_protocol[0], token, ) # 80-char token (minimum) assert NotifyWebexTeams(token="c" * 80) # 160-char token (maximum) assert NotifyWebexTeams(token="c" * 160) # Too short for webhook -> falls to bot mode (no targets), loads but # send() returns False obj_short = NotifyWebexTeams(token="c" * 79) assert obj_short.mode == WebexTeamsMode.BOT assert obj_short.send(body="test") is False # Too long for webhook -> bot mode, same behaviour obj_long = NotifyWebexTeams(token="c" * 161) assert obj_long.mode == WebexTeamsMode.BOT assert obj_long.send(body="test") is False # No token -> TypeError (no candidate at all) with pytest.raises(TypeError): NotifyWebexTeams(token=None) # Empty token -> TypeError with pytest.raises(TypeError): NotifyWebexTeams(token="") # Non-alphanumeric chars -> falls to bot mode (no targets) obj_hyph = NotifyWebexTeams(token="a-b-" * 20) assert obj_hyph.mode == WebexTeamsMode.BOT assert obj_hyph.send(body="test") is False def test_plugin_webex_teams_bot_mode(): """NotifyWebexTeams() Bot mode direct tests.""" obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) assert isinstance(obj, NotifyWebexTeams) assert obj.mode == WebexTeamsMode.BOT assert obj.access_token == BOT_TOKEN assert ROOM_ID in obj.targets # url() round-trip url = obj.url() assert "wxteams://" in url assert "mode=bot" in url result = NotifyWebexTeams.parse_url(url) assert result is not None obj2 = NotifyWebexTeams(**result) assert obj2.mode == WebexTeamsMode.BOT assert obj2.access_token == BOT_TOKEN assert ROOM_ID in obj2.targets # privacy URL masks the token priv = obj.url(privacy=True) assert BOT_TOKEN not in priv # url_identifier assert obj.url_identifier == ( NotifyWebexTeams.secure_protocol[0], BOT_TOKEN, ) # Multiple rooms obj3 = NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID, ROOM_ID2] ) assert len(obj3.targets) == 2 # No room IDs -> loads fine, but send() returns False obj_no_rooms = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[]) assert isinstance(obj_no_rooms, NotifyWebexTeams) assert obj_no_rooms.send(body="test") is False # No access_token -> TypeError with pytest.raises(TypeError): NotifyWebexTeams(access_token=None, targets=[ROOM_ID]) # Explicit bot mode with neither token nor access_token -> TypeError with pytest.raises(TypeError): NotifyWebexTeams(mode="bot", targets=[ROOM_ID]) # Explicit mode=bot on a short (webhook-style) token obj4 = NotifyWebexTeams(token="a" * 80, mode="bot", targets=[ROOM_ID]) assert obj4.mode == WebexTeamsMode.BOT # The 'token' argument is used as access_token in bot mode assert obj4.access_token == "a" * 80 # Invalid mode with pytest.raises(TypeError): NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID], mode="invalid", ) def test_plugin_webex_teams_body_maxlen(): """NotifyWebexTeams() body_maxlen varies by mode.""" # Webhook mode: 1000 chars obj = NotifyWebexTeams(token=WEBHOOK_TOKEN) assert obj.body_maxlen == 1000 # Bot mode: 7439 chars obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) assert obj.body_maxlen == 7439 def test_plugin_webex_teams_mode_detection(): """NotifyWebexTeams() auto-detects mode from token format.""" # 80-char lowercase alphanumeric -> WEBHOOK obj = NotifyWebexTeams(token="a" * 80) assert obj.mode == WebexTeamsMode.WEBHOOK # 80-char uppercase alphanumeric also matches webhook regex # (regex uses 'i' flag); no targets means webhook mode obj = NotifyWebexTeams(token="A" * 80) assert obj.mode == WebexTeamsMode.WEBHOOK # Token longer than 160 chars -> fails webhook regex -> BOT obj = NotifyWebexTeams(token="a" * 200, targets=[ROOM_ID]) assert obj.mode == WebexTeamsMode.BOT # Token with hyphens (non-alphanumeric) -> fails webhook regex -> BOT obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) assert obj.mode == WebexTeamsMode.BOT def test_plugin_webex_teams_url_parsing(): """NotifyWebexTeams() URL parsing edge cases.""" # Webhook via ?token= param result = NotifyWebexTeams.parse_url("wxteams://?token={}".format("a" * 80)) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.WEBHOOK assert obj.token == "a" * 80 # Bot mode: access_token in host, room ID in path result = NotifyWebexTeams.parse_url( "wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.BOT assert ROOM_ID in obj.targets # Bot mode: multiple room IDs in path result = NotifyWebexTeams.parse_url( "wxteams://{}/{}/{}".format(BOT_TOKEN, ROOM_ID, ROOM_ID2) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.BOT assert len(obj.targets) == 2 # Bot mode: room IDs via ?to= param result = NotifyWebexTeams.parse_url( "wxteams://{}?to={}&mode=bot".format(BOT_TOKEN, ROOM_ID) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.BOT assert ROOM_ID in obj.targets # Native webhook URL result = NotifyWebexTeams.parse_native_url( "https://api.ciscospark.com/v1/webhooks/incoming/{}".format("a" * 80) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.WEBHOOK # New native webhook URL result = NotifyWebexTeams.parse_native_url( "https://webexapis.com/v1/webhooks/incoming/{}".format("a" * 100) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.WEBHOOK # parse_native_url returns None for non-matching URLs assert ( NotifyWebexTeams.parse_native_url( "https://example.com/not/a/webex/url" ) is None ) # Webhook mode forced with ?mode=webhook even with path entries result = NotifyWebexTeams.parse_url( "wxteams://{}?mode=webhook".format("a" * 80) ) assert result is not None obj = NotifyWebexTeams(**result) assert obj.mode == WebexTeamsMode.WEBHOOK @mock.patch("requests.post") def test_plugin_webex_teams_webhook_send(mock_post): """NotifyWebexTeams() Webhook send() coverage.""" # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = NotifyWebexTeams(token=WEBHOOK_TOKEN) # Successful send assert obj.send(body="test message") is True assert mock_post.call_count == 1 call_url = mock_post.call_args[0][0] assert "webhooks/incoming" in call_url assert WEBHOOK_TOKEN in call_url # Verify correct Content-Type header headers = mock_post.call_args[1]["headers"] assert headers["Content-Type"] == "application/json" mock_post.reset_mock() # 204 No Content is also acceptable response.status_code = requests.codes.no_content assert obj.send(body="test") is True mock_post.reset_mock() # HTTP error response.status_code = requests.codes.internal_server_error assert obj.send(body="test") is False mock_post.reset_mock() # Unknown HTTP error code response.status_code = 999 assert obj.send(body="test") is False mock_post.reset_mock() # Connection error mock_post.side_effect = requests.RequestException() assert obj.send(body="test") is False mock_post.side_effect = None mock_post.return_value = response response.status_code = requests.codes.ok # Webhook warns about attachments (but still proceeds) mock_post.reset_mock() path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) assert obj.send(body="test", attach=attach) is True # Only one POST call: text-only webhook assert mock_post.call_count == 1 @mock.patch("requests.post") def test_plugin_webex_teams_bot_send(mock_post): """NotifyWebexTeams() Bot send() text-only coverage.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) # Successful text send assert obj.send(body="hello from bot") is True assert mock_post.call_count == 1 call_url = mock_post.call_args[0][0] assert call_url == "https://webexapis.com/v1/messages" # Verify Bearer authorization headers = mock_post.call_args[1]["headers"] assert headers["Authorization"] == f"Bearer {BOT_TOKEN}" assert headers["Content-Type"] == "application/json" mock_post.reset_mock() # Two rooms -> two calls obj2 = NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID, ROOM_ID2] ) assert obj2.send(body="broadcast") is True assert mock_post.call_count == 2 mock_post.reset_mock() # HTTP error for bot mode response.status_code = requests.codes.internal_server_error assert obj.send(body="test") is False mock_post.reset_mock() response.status_code = 999 assert obj.send(body="test") is False mock_post.reset_mock() # Connection error for bot mode mock_post.side_effect = requests.RequestException() assert obj.send(body="test") is False mock_post.side_effect = None response.status_code = requests.codes.ok mock_post.return_value = response # Two rooms: second fails -> overall failure reported mock_post.reset_mock() fail_response = mock.Mock() fail_response.status_code = requests.codes.internal_server_error fail_response.content = b"" mock_post.side_effect = [response, fail_response] obj2 = NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID, ROOM_ID2] ) assert obj2.send(body="test") is False @mock.patch("requests.post") def test_plugin_webex_teams_bot_attachments(mock_post): """NotifyWebexTeams() Bot mode attachment coverage.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response obj = NotifyWebexTeams(access_token=BOT_TOKEN, targets=[ROOM_ID]) path = os.path.join(TEST_VAR_DIR, "apprise-test.gif") attach = AppriseAttachment(path) # Successful single attachment mock_post.reset_mock() assert ( obj.notify( body="see attached", title="", notify_type=NotifyType.INFO, attach=attach, ) is True ) # One POST call for the single attachment assert mock_post.call_count == 1 call_url = mock_post.call_args[0][0] assert call_url == "https://webexapis.com/v1/messages" # No Content-Type header set (multipart sets its own) headers = mock_post.call_args[1]["headers"] assert "Content-Type" not in headers # files kwarg was provided assert mock_post.call_args[1].get("files") is not None # Multiple attachments -> one POST per attachment mock_post.reset_mock() path2 = os.path.join(TEST_VAR_DIR, "apprise-test.png") attach2 = AppriseAttachment([path, path2]) assert obj.send(body="two files", attach=attach2) is True assert mock_post.call_count == 2 # Two rooms, one attachment -> 2 POST calls mock_post.reset_mock() obj2 = NotifyWebexTeams( access_token=BOT_TOKEN, targets=[ROOM_ID, ROOM_ID2] ) assert obj2.send(body="room broadcast", attach=attach) is True assert mock_post.call_count == 2 # Connection error on attachment POST mock_post.reset_mock() mock_post.side_effect = requests.RequestException() assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.return_value = response # OSError reading attachment mock_post.reset_mock() mock_post.side_effect = OSError() assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.return_value = response # HTTP error on attachment upload mock_post.reset_mock() bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error bad_response.content = b"" mock_post.return_value = bad_response assert obj.send(body="test", attach=attach) is False mock_post.return_value = response # OSError raised by attachment.open() (before the file handle is used) mock_post.reset_mock() mock_post.side_effect = None with mock.patch( "apprise.attachment.file.AttachFile.open", side_effect=OSError("open failed"), ): assert obj.send(body="test", attach=attach) is False mock_post.side_effect = None mock_post.return_value = response # Invalid/inaccessible attachment mock_post.reset_mock() invalid_path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/file.jpg") bad_attach = AppriseAttachment(invalid_path) assert ( obj.notify( body="test", notify_type=NotifyType.INFO, attach=bad_attach, ) is False ) # No POST should have been made assert mock_post.call_count == 0 @mock.patch("requests.post") def test_plugin_webex_teams_apprise_integration(mock_post): """NotifyWebexTeams() Apprise.notify() integration.""" response = mock.Mock() response.status_code = requests.codes.ok response.content = b"" mock_post.return_value = response # Webhook mode via Apprise app = Apprise() assert app.add("wxteams://{}".format(WEBHOOK_TOKEN)) assert app.notify(body="webhook test") is True mock_post.reset_mock() # Bot mode via Apprise app2 = Apprise() assert app2.add("wxteams://{}/{}".format(BOT_TOKEN, ROOM_ID)) assert app2.notify(body="bot test") is True @mock.patch("requests.post") def test_plugin_webex_teams_html_to_markdown_format(mock_post): """NotifyWebexTeams(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Use a webhook URL (80-char token, webhook mode) aobj = Apprise() assert aobj.add("wxteams://{}".format("a" * 80)) # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Webex Teams assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown (not plain text) in the # "markdown" key, since notify_format is MARKDOWN by default payload = loads(mock_post.call_args_list[0][1]["data"]) assert payload["markdown"] == "**hello** *world*" caronc-apprise-182f859/tests/test_plugin_wechat.py000066400000000000000000000724411524161175200223300ustar00rootroot00000000000000# # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in # the documentation and/or other materials provided with the # distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from json import dumps import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise from apprise.common import NotifyFormat from apprise.plugins.wechat import ( WECHAT_ERROR_CODES, WECHAT_TOKEN_ERROR_CODES, NotifyWeChat, ) logging.disable(logging.CRITICAL) # Fixed test credentials CORPID = "wwcorpid1234567890" CORPSECRET = "abcSecret9" AGENTID = "1000002" # A successful response serves double-duty: the token GET needs # access_token and expires_in; the message POST needs errcode == 0. GOOD_RESPONSE = dumps( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN12345678", "expires_in": 7200, } ) # pprint(CORPSECRET, privacy=True) -> first + "..." + last char # "abcSecret9" -> "a...9" PRIVACY_SECRET = "a...9" # Our Testing URLs apprise_url_tests = ( # ---------------------------------------------------------------- # Invalid / missing credential cases # ---------------------------------------------------------------- ( "wechat://", { # No credentials at all "instance": TypeError, }, ), ( # Missing corpsecret and agentid "wechat://{}".format(CORPID), { "instance": TypeError, }, ), ( # Missing agentid (non-numeric host provided as port only) "wechat://{}:{}@".format(CORPID, CORPSECRET), { "instance": TypeError, }, ), ( # Non-numeric agentid "wechat://{}:{}@notanumber".format(CORPID, CORPSECRET), { "instance": TypeError, }, ), # ---------------------------------------------------------------- # No targets -- plugin loads but send() returns False early # ---------------------------------------------------------------- ( "wechat://{}:{}@{}".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "notify_response": False, "privacy_url": "wechat://{}:{}@{}/".format( CORPID, PRIVACY_SECRET, AGENTID ), }, ), # ---------------------------------------------------------------- # @all broadcast -- sends to entire organisation # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, "privacy_url": "wechat://{}:{}@{}/@all/".format( CORPID, PRIVACY_SECRET, AGENTID ), }, ), # ---------------------------------------------------------------- # Single user target (bare form -- no @ prefix) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/johndoe".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Single user target (@ prefixed form) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@johndoe".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Multiple user targets # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/user1/user2/user3".format( CORPID, CORPSECRET, AGENTID ), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Department target (%23 decodes to # prefix) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/%23100".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Tag target (+ prefix) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/+7".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Mixed recipients (user + department + tag) # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/john/%23200/+3".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Targets supplied via ?to= query parameter # ---------------------------------------------------------------- ( "wechat://{}:{}@{}?to=@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # Markdown format # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@all?format=markdown".format( CORPID, CORPSECRET, AGENTID ), { "instance": NotifyWeChat, "requests_response_text": GOOD_RESPONSE, }, ), # ---------------------------------------------------------------- # HTTP error responses -- token GET fails -> overall send fails # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "response": False, "requests_response_code": 999, }, ), # ---------------------------------------------------------------- # Network exception on all requests # ---------------------------------------------------------------- ( "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID), { "instance": NotifyWeChat, "test_requests_exceptions": True, }, ), ) def test_plugin_wechat_urls(): """Run the standard Apprise URL tester for all wechat:// entries.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_wechat_init(): """Verify __init__ validation for required fields.""" # Valid construction obj = NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID) assert isinstance(obj, NotifyWeChat) # corpid is required with ( mock.patch("apprise.plugins.wechat.validate_regex", return_value=None), mock.patch.object(NotifyWeChat, "logger"), pytest.raises(TypeError), ): NotifyWeChat(corpid="", corpsecret=CORPSECRET, agentid=AGENTID) # corpsecret is required with pytest.raises(TypeError): NotifyWeChat(corpid=CORPID, corpsecret="", agentid=AGENTID) # agentid must be numeric with pytest.raises(TypeError): NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid="notnum") # agentid=None must be rejected with pytest.raises(TypeError): NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=None) # Invalid targets are silently dropped; valid ones are kept obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["validuser", "!!invalid!!", "#42", "+7"], ) assert "validuser" in obj.users assert "42" in obj.departments assert "7" in obj.tag_ids assert "!!invalid!!" in obj.invalid_targets # The @ prefix on user IDs is optional on input; it is stripped # by the regex before storing so the API payload stays prefix-free. # The bare keyword "all" is normalised to "@all" (WeCom API form). obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@johndoe", "@all", "all"], ) assert "johndoe" in obj.users # Both "@all" and bare "all" must normalise to "@all" assert obj.users.count("@all") == 2 def test_plugin_wechat_url_round_trip(): """Verify that url() and parse_url() form a lossless round-trip.""" obj1 = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["alice", "#10", "+2", "@all"], ) url = obj1.url() result = NotifyWeChat.parse_url(url) assert result is not None obj2 = NotifyWeChat(**result) # Connection identity must be preserved assert obj1.url_identifier == obj2.url_identifier # Target counts must match assert len(obj1) == len(obj2) def test_plugin_wechat_url(): """Verify url() output for various target combinations.""" # No targets obj = NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID) url = obj.url() assert CORPID in url assert CORPSECRET in url assert AGENTID in url # Privacy URL masks the corpsecret privacy = obj.url(privacy=True) assert CORPSECRET not in privacy assert PRIVACY_SECRET in privacy assert CORPID in privacy # Department gets encoded as %23 obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["#5"], ) assert "%235" in obj.url() # Tag gets + prefix obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["+3"], ) assert "+3" in obj.url() # @all is kept as-is obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert "@all" in obj.url() # Regular user IDs are always emitted with @ prefix in the URL obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["alice"], ) assert "@alice" in obj.url() # Invalid targets survive the round-trip obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["!!bad!!"], ) assert obj.invalid_targets # url() must include the invalid target so it round-trips assert obj.url() != "" def test_plugin_wechat_url_identifier(): """url_identifier must differ when credentials differ.""" obj1 = NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID) obj2 = NotifyWeChat( corpid="other_corp", corpsecret=CORPSECRET, agentid=AGENTID ) obj3 = NotifyWeChat( corpid=CORPID, corpsecret="other_secret", agentid=AGENTID ) obj4 = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid="9999999" ) assert obj1.url_identifier != obj2.url_identifier assert obj1.url_identifier != obj3.url_identifier assert obj1.url_identifier != obj4.url_identifier @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_ok(mock_post, mock_get): """Successful end-to-end send: token GET + message POST both succeed.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r # Token GET -> success + message POST -> success mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "MYTOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is True assert mock_get.call_count == 1 assert mock_post.call_count == 1 @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_cached_token(mock_post, mock_get): """Second send reuses the cached token without a new GET request.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "CACHED_TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) # First send fetches the token assert obj.send(body="First") is True assert mock_get.call_count == 1 # Second send reuses the cached token assert obj.send(body="Second") is True assert mock_get.call_count == 1 # still 1 -- no second GET @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_all_targets(mock_post, mock_get): """A single send delivers to users, departments, and tags in one POST.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["alice", "bob", "#10", "#20", "+3", "+5"], ) assert obj.send(body="Hello") is True assert mock_post.call_count == 1 # Verify payload recipients import json call_kwargs = mock_post.call_args payload = json.loads(call_kwargs[1]["data"]) assert set(payload["touser"].split("|")) == {"alice", "bob"} assert set(payload["toparty"].split("|")) == {"10", "20"} assert set(payload["totag"].split("|")) == {"3", "5"} @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_markdown(mock_post, mock_get): """Markdown format produces msgtype=markdown in the payload.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], format=NotifyFormat.MARKDOWN, ) assert obj.send(body="# Hello") is True import json payload = json.loads(mock_post.call_args[1]["data"]) assert payload["msgtype"] == "markdown" assert "markdown" in payload @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_no_targets(mock_post, mock_get): """send() returns False immediately when no valid targets are present.""" obj = NotifyWeChat(corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID) # No targets -> early return False, no network calls assert obj.send(body="Hello") is False assert mock_get.call_count == 0 assert mock_post.call_count == 0 @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_http_error(mock_post, mock_get): """Token GET returning HTTP 500 causes send() to fail.""" r = mock.Mock() r.status_code = requests.codes.internal_server_error r.content = b"" mock_get.return_value = r obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False assert mock_get.call_count == 1 assert mock_post.call_count == 0 @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_api_error(mock_post, mock_get): """Token GET returning errcode != 0 causes send() to fail.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r # errcode 40001 -- invalid credential mock_get.return_value = _mk_resp( {"errcode": 40001, "errmsg": "invalid credential"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False assert mock_post.call_count == 0 @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_no_token_field(mock_post, mock_get): """Token GET returning errcode 0 but no access_token causes failure.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r # errcode 0 but access_token is absent mock_get.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_bad_json(mock_post, mock_get): """Token GET returning unparsable JSON is handled gracefully.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = b"{bad json" mock_get.return_value = r obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_fetch_request_exception(mock_post, mock_get): """RequestException during token GET causes send() to fail.""" mock_get.side_effect = requests.RequestException("connection error") obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_http_error(mock_post, mock_get): """Message POST returning HTTP 500 causes send() to fail.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp( {}, code=requests.codes.internal_server_error ) mock_post.return_value.content = b"" obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_api_error(mock_post, mock_get): """Message POST returning errcode != 0 causes send() to fail.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) # 81013 -- all recipients invalid mock_post.return_value = _mk_resp( {"errcode": 81013, "errmsg": "All recipients invalid"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_token_expiry_evicts_cache(mock_post, mock_get): """A token-expired errcode from the POST evicts the cached token.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) # POST returns 42001 -- token expired mock_post.return_value = _mk_resp( {"errcode": 42001, "errmsg": "access_token expired"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False # Verify the store no longer holds the token assert obj.store.get("access_token") is None @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_token_expiry_all_codes(mock_post, mock_get): """All WECHAT_TOKEN_ERROR_CODES cause the cached token to be evicted.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r for errcode in WECHAT_TOKEN_ERROR_CODES: mock_get.reset_mock() mock_post.reset_mock() mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp( {"errcode": errcode, "errmsg": "token error"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False assert obj.store.get("access_token") is None @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_bad_json(mock_post, mock_get): """Message POST returning unparsable JSON is handled gracefully.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) # POST returns unparsable body post_r = mock.Mock() post_r.status_code = requests.codes.ok post_r.content = b"{bad json" mock_post.return_value = post_r obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) # Bad JSON -> content = {} -> errcode = -1 -> failure assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_request_exception(mock_post, mock_get): """RequestException during message POST causes send() to fail.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.side_effect = requests.RequestException("network error") obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False def test_plugin_wechat_parse_url(): """Verify parse_url() handles all supported URL forms.""" # Standard form: corpid:corpsecret@agentid/targets url = "wechat://{}:{}@{}/alice".format(CORPID, CORPSECRET, AGENTID) result = NotifyWeChat.parse_url(url) assert result is not None assert result["corpid"] == CORPID assert result["corpsecret"] == CORPSECRET assert result["agentid"] == AGENTID assert "alice" in result["targets"] # Department target (%23 decodes to #) url = "wechat://{}:{}@{}/%23999".format(CORPID, CORPSECRET, AGENTID) result = NotifyWeChat.parse_url(url) assert "#999" in result["targets"] # Tag target url = "wechat://{}:{}@{}/+42".format(CORPID, CORPSECRET, AGENTID) result = NotifyWeChat.parse_url(url) assert "+42" in result["targets"] # ?to= adds targets url = "wechat://{}:{}@{}?to=@all".format(CORPID, CORPSECRET, AGENTID) result = NotifyWeChat.parse_url(url) assert "@all" in result["targets"] # None and non-string inputs return None assert NotifyWeChat.parse_url(None) is None def test_plugin_wechat_parse_native_url(): """parse_native_url() always returns None -- no native URL form.""" assert ( NotifyWeChat.parse_native_url("https://qyapi.weixin.qq.com/any/url") is None ) def test_plugin_wechat_error_codes(): """Verify the error code constants are populated correctly.""" assert 0 in WECHAT_ERROR_CODES assert 40001 in WECHAT_TOKEN_ERROR_CODES assert 40014 in WECHAT_TOKEN_ERROR_CODES assert 42001 in WECHAT_TOKEN_ERROR_CODES @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_apprise_integration(mock_post, mock_get): """End-to-end Apprise().notify() integration test.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) mock_post.return_value = _mk_resp({"errcode": 0, "errmsg": "ok"}) app = Apprise() url = "wechat://{}:{}@{}/@all".format(CORPID, CORPSECRET, AGENTID) assert app.add(url) is True assert app.notify(title="T", body="B") is True @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_send_unknown_api_error(mock_post, mock_get): """An unknown errcode falls back to the errmsg field in the response.""" def _mk_resp(data, code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = dumps(data).encode("utf-8") return r mock_get.return_value = _mk_resp( { "errcode": 0, "errmsg": "ok", "access_token": "TOKEN", "expires_in": 7200, } ) # Use an error code not in WECHAT_ERROR_CODES mock_post.return_value = _mk_resp( {"errcode": 99999, "errmsg": "custom error message"} ) obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False @mock.patch("requests.get") @mock.patch("requests.post") def test_plugin_wechat_token_unknown_api_error(mock_post, mock_get): """Unknown errcode from token GET falls back to errmsg field.""" r = mock.Mock() r.status_code = requests.codes.ok r.content = dumps( {"errcode": 99999, "errmsg": "custom token error"} ).encode("utf-8") mock_get.return_value = r obj = NotifyWeChat( corpid=CORPID, corpsecret=CORPSECRET, agentid=AGENTID, targets=["@all"], ) assert obj.send(body="Hello") is False caronc-apprise-182f859/tests/test_plugin_wecombot.py000066400000000000000000000065541524161175200226760ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise.plugins.wecombot import NotifyWeComBot logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "wecombot://", { "instance": TypeError, }, ), ( "wecombot://:@/", { "instance": TypeError, }, ), ( "wecombot://botkey", { # Minimum requirements met "instance": NotifyWeComBot, }, ), ( "wecombot://?key=botkey", { # Test ?key= "instance": NotifyWeComBot, }, ), # Support Native URLs ( "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=BOTKEY", { "instance": NotifyWeComBot, }, ), ( "https://qyapi.weixin.qq.com/cgi-bin/webhook/send/?key=BOTKEY&data=123", { # another variation (more parameters don't obstruct our key) "instance": NotifyWeComBot, }, ), ( "wecombot://botkey", { "instance": NotifyWeComBot, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "wecombot://botkey", { "instance": NotifyWeComBot, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "wecombot://botkey", { "instance": NotifyWeComBot, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_wecombot_urls(): """NotifyWeComBot() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_whatsapp.py000066400000000000000000000426151524161175200227040ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps, loads # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.plugins.whatsapp import IS_GROUP_ID, NotifyWhatsApp logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "whatsapp://", { # Not enough details "instance": TypeError, }, ), ( "whatsapp://:@/", { # invalid Access Token "instance": TypeError, }, ), ( "whatsapp://{}@_".format("a" * 32), { # token provided but invalid from "instance": TypeError, }, ), ( "whatsapp://%20:{}@12345/{}".format("e" * 32, "4" * 11), { # Invalid template "instance": TypeError, }, ), ( "whatsapp://{}@{}".format("b" * 32, 10**9), { # token provided and from but no target no "instance": NotifyWhatsApp, # Response will fail due to no targets defined "notify_response": False, }, ), ( "whatsapp://{}:{}@{}/123/{}/abcd/".format( "a" * 32, "b" * 32, "3" * 11, "9" * 15 ), { # valid everything but target numbers "instance": NotifyWhatsApp, # Response will fail due to target not being loaded "notify_response": False, }, ), ( "whatsapp://{}@12345/{}".format("e" * 32, "4" * 11), { # simple message "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://template:{}@12345/{}".format("e" * 32, "4" * 11), { # template "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://template:e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://template:{}@12345/{}?lang=fr_CA".format( "e" * 32, "4" * 11 ), { # template with language over-ride "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://template:e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://{}@12345/{}?template=template&lang=fr_CA".format( "e" * 32, "4" * 11 ), { # template specified as kwarg with language over-ride "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://template:e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://template:{}@12345/{}?lang=1234".format("e" * 32, "4" * 11), { # template with invalid language over-ride "instance": TypeError, }, ), ( "whatsapp://template:{}@12345/{}?:1=test&:body=3&:type=2".format( "e" * 32, "4" * 11 ), { # template with kwarg assignments # {{1}} assigned test # {{2}} assigned Apprise Message type (special keyword) # {{3}} assigned Apprise Message body (special keyword) "instance": NotifyWhatsApp, # Our expected url(privacy=True) startswith() response: "privacy_url": "whatsapp://template:e...e@1...5/%2B44444444444/", }, ), ( "whatsapp://template:{}@12345/{}?:invalid=23".format( "e" * 32, "4" * 11 ), { # template with kwarg assignments # Invalid keyword specified; cna only be a digit OR `body' # or 'type' "instance": TypeError, }, ), ( "whatsapp://template:{}@12345/{}?:body=".format("e" * 32, "4" * 11), { # template with kwarg assignments # No Body Assigment "instance": TypeError, }, ), ( "whatsapp://template:{}@12345/{}?:1=Test&:body=1".format( "e" * 32, "4" * 11 ), { # template with kwarg assignments # Ambiguious assignment {{1}} assigned twice "instance": TypeError, }, ), ( "whatsapp://{}:{}@123456/{}".format("a" * 32, "b" * 32, "4" * 11), { # using short-code (6 characters) "instance": NotifyWhatsApp, }, ), ( "whatsapp://_?token={}&from={}&to={}".format( "d" * 32, "5" * 11, "6" * 11 ), { # use get args to acomplish the same thing "instance": NotifyWhatsApp, }, ), ( "whatsapp://_?token={}&source={}&to={}".format( "d" * 32, "5" * 11, "6" * 11 ), { # use get args to acomplish the same thing (use source instead # of from) "instance": NotifyWhatsApp, }, ), ( "whatsapp://{}@12345/{}".format("e" * 32, "4" * 11), { "instance": NotifyWhatsApp, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "whatsapp://{}@12345/{}".format("e" * 32, "4" * 11), { "instance": NotifyWhatsApp, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # Group target tests ( # Group ID via '#' prefix "whatsapp://{}@12345/%23120363043968066561".format("e" * 32), { "instance": NotifyWhatsApp, "privacy_url": "whatsapp://e...e@1...5/%23120363043968066561/", }, ), ( # Mix of phone number and group ID "whatsapp://{}@12345/{}/%23120363043968066561".format( "e" * 32, "4" * 11 ), { "instance": NotifyWhatsApp, }, ), ( # Group target with HTTP 500 "whatsapp://{}@12345/%23120363043968066561".format("e" * 32), { "instance": NotifyWhatsApp, "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( # Group target with request exception "whatsapp://{}@12345/%23120363043968066561".format("e" * 32), { "instance": NotifyWhatsApp, "test_requests_exceptions": True, }, ), ) def test_plugin_whatsapp_urls(): """NotifyWhatsApp() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_whatsapp_auth(mock_post): """NotifyWhatsApp() Auth. - account-wide auth token - API key and its own auth token """ response = mock.Mock() response.content = "" response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens token = "{}".format("b" * 32) from_phone_id = "123456787654321" target = "+1 (555) 987-6543" message_contents = "test" # Variation of initialization without API key obj = Apprise.instantiate(f"whatsapp://{token}@{from_phone_id}/{target}") assert isinstance(obj, NotifyWhatsApp) is True assert isinstance(obj.url(), str) is True # Send Notification assert obj.send(body=message_contents) is True # Validate expected call parameters assert mock_post.call_count == 1 first_call = mock_post.call_args_list[0] # URL and message parameters are the same for both calls assert first_call[0][0] == ( "https://graph.facebook.com/" f"{NotifyWhatsApp.fb_graph_version}/{from_phone_id}/messages" ) response = loads(first_call[1]["data"]) assert response["text"]["body"] == message_contents assert response["to"] == "+15559876543" assert response["recipient_type"] == "individual" @mock.patch("requests.post") def test_plugin_whatsapp_edge_cases(mock_post): """NotifyWhatsApp() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens token = "b" * 32 from_phone_id = "123456787654321" targets = ("+1 (555) 123-3456",) # No token specified with pytest.raises(TypeError): NotifyWhatsApp( token=None, from_phone_id=from_phone_id, targets=targets ) # No from_phone_id specified with pytest.raises(TypeError): NotifyWhatsApp(token=token, from_phone_id=None, targets=targets) # a error response response.status_code = 400 response.content = dumps( { "error": { "code": 21211, "message": ( "The 'To' number +1234567 is not a valid phone number." ), }, } ) mock_post.return_value = response # Initialize our object obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=targets ) # We will fail with the above error code assert obj.notify("title", "body", "info") is False @mock.patch("requests.post") def test_plugin_whatsapp_template_notify_type_value(mock_post): response = mock.Mock() response.content = "" response.status_code = requests.codes.ok mock_post.return_value = response token = "b" * 32 from_phone_id = "12345" target = "+1 (555) 987-6543" # Map notify_type -> {{2}}, body -> {{3}} obj = Apprise.instantiate( f"whatsapp://template:{token}@{from_phone_id}/{target}?:type=2&:body=3" ) assert isinstance(obj, NotifyWhatsApp) assert ( obj.send(body="test", title="t", notify_type=NotifyType.INFO) is True ) call = mock_post.call_args_list[0] assert "NotifyType." not in call[1]["data"] payload = loads(call[1]["data"]) params = payload["template"]["components"][0]["parameters"] # Ensure values are injected, not literal strings assert params[0]["text"] == NotifyType.INFO.value assert params[1]["text"] == "test" @mock.patch("requests.post") def test_plugin_whatsapp_groups(mock_post): """NotifyWhatsApp() group target support.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.content = "" r.status_code = code return r mock_post.return_value = _mk_resp() token = "e" * 32 from_phone_id = "123456787654321" # A realistic-looking numeric group ID group_id = "120363043968066561" # IS_GROUP_ID only matches the numeric portion (no prefix/suffix) assert IS_GROUP_ID.match(group_id) assert not IS_GROUP_ID.match(f"#{group_id}") assert not IS_GROUP_ID.match(f"{group_id}@g.us") assert not IS_GROUP_ID.match("abc123") # __init__ target classification # '#' prefix β†’ group obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) assert obj.targets == [f"#{group_id}"] # '@g.us' suffix (native API format) β†’ normalised to '#' prefix obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"{group_id}@g.us"], ) assert obj.targets == [f"#{group_id}"] # '#' prefix + '@g.us' suffix (both present) β†’ normalised obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}@g.us"], ) assert obj.targets == [f"#{group_id}"] # Invalid group ID (non-numeric) is dropped with a warning obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["#badgroupid", f"#{group_id}"], ) assert obj.targets == [f"#{group_id}"] # Mixed: phone number + group ID obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671", f"#{group_id}"], ) assert len(obj.targets) == 2 assert "+14155552671" in obj.targets assert f"#{group_id}" in obj.targets # send() payload verification mock_post.reset_mock() obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) assert obj.send(body="hello group") is True assert mock_post.call_count == 1 # Group payload must carry recipient_type='group' and 'to'=id@g.us sent = loads(mock_post.call_args_list[0][1]["data"]) assert sent["recipient_type"] == "group" assert sent["to"] == f"{group_id}@g.us" # send() for individual phone preserves recipient_type='individual' mock_post.reset_mock() obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671"], ) assert obj.send(body="hello individual") is True sent = loads(mock_post.call_args_list[0][1]["data"]) assert sent["recipient_type"] == "individual" assert sent["to"] == "+14155552671" # mixed send: one phone + one group makes two POST calls mock_post.reset_mock() obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671", f"#{group_id}"], ) assert obj.send(body="mixed") is True assert mock_post.call_count == 2 payloads = [loads(c[1]["data"]) for c in mock_post.call_args_list] types = {p["recipient_type"] for p in payloads} assert types == {"individual", "group"} # HTTP error on group target returns False mock_post.return_value = _mk_resp(requests.codes.internal_server_error) obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) assert obj.send(body="should fail") is False # partial failure: phone succeeds, group errors β†’ overall False mock_post.side_effect = [ _mk_resp(requests.codes.ok), _mk_resp(requests.codes.internal_server_error), ] obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671", f"#{group_id}"], ) assert obj.send(body="partial") is False # URL round-trip: group ID survives url() -> parse_url() mock_post.side_effect = None mock_post.return_value = _mk_resp() obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) rebuilt = NotifyWhatsApp(**NotifyWhatsApp.parse_url(obj.url())) assert rebuilt.targets == obj.targets assert rebuilt.url_identifier == obj.url_identifier # Round-trip with mixed targets obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=["+14155552671", f"#{group_id}"], ) rebuilt = NotifyWhatsApp(**NotifyWhatsApp.parse_url(obj.url())) assert sorted(rebuilt.targets) == sorted(obj.targets) # targets as a plain string (comma-separated, not a list) obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=f"#{group_id}", ) assert obj.targets == [f"#{group_id}"] # whitespace-only list entry is silently skipped obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[" ", f"#{group_id}"], ) assert obj.targets == [f"#{group_id}"] # ?to= query parameter accepts group IDs url = f"whatsapp://_?token={token}&from={from_phone_id}&to=%23{group_id}" obj = Apprise.instantiate(url) assert isinstance(obj, NotifyWhatsApp) assert f"#{group_id}" in obj.targets # privacy_url masks group targets correctly obj = NotifyWhatsApp( token=token, from_phone_id=from_phone_id, targets=[f"#{group_id}"], ) priv = obj.url(privacy=True) # The token should be masked; the group ID should still be present assert token not in priv assert group_id in priv caronc-apprise-182f859/tests/test_plugin_windows.py000066400000000000000000000300741524161175200225430ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from importlib import reload # Disable logging for a cleaner testing output import logging import sys import types from unittest import mock import pytest import apprise logging.disable(logging.CRITICAL) @pytest.fixture def mocked_win32_environment(): """Inject fake win32 modules, reload apprise, and fully restore sys.modules on teardown. Previously the setup lived inside test_plugin_windows_mocked itself, with no cleanup. That permanently abandoned all apprise.* module objects, causing order-dependent failures in every test that runs afterwards and holds a module-level `from apprise.plugins.X import` reference. A fixture with a yield guarantees cleanup even on failure. """ # Build fake win32api win32api = types.ModuleType("win32api") win32api.GetModuleHandle = mock.Mock(name="win32api.GetModuleHandle") win32api.PostQuitMessage = mock.Mock(name="win32api.PostQuitMessage") # Build fake win32con win32con = types.ModuleType("win32con") win32con.CW_USEDEFAULT = mock.Mock(name="win32con.CW_USEDEFAULT") win32con.IDI_APPLICATION = mock.Mock(name="win32con.IDI_APPLICATION") win32con.IMAGE_ICON = mock.Mock(name="win32con.IMAGE_ICON") win32con.LR_DEFAULTSIZE = 1 win32con.LR_LOADFROMFILE = 2 win32con.WM_DESTROY = mock.Mock(name="win32con.WM_DESTROY") win32con.WM_USER = 0 win32con.WS_OVERLAPPED = 1 win32con.WS_SYSMENU = 2 # Build fake win32gui win32gui = types.ModuleType("win32gui") win32gui.CreateWindow = mock.Mock(name="win32gui.CreateWindow") win32gui.DestroyWindow = mock.Mock(name="win32gui.DestroyWindow") win32gui.LoadIcon = mock.Mock(name="win32gui.LoadIcon") win32gui.LoadImage = mock.Mock(name="win32gui.LoadImage") win32gui.NIF_ICON = 1 win32gui.NIF_INFO = mock.Mock(name="win32gui.NIF_INFO") win32gui.NIF_MESSAGE = 2 win32gui.NIF_TIP = 4 win32gui.NIM_ADD = mock.Mock(name="win32gui.NIM_ADD") win32gui.NIM_DELETE = mock.Mock(name="win32gui.NIM_DELETE") win32gui.NIM_MODIFY = mock.Mock(name="win32gui.NIM_MODIFY") win32gui.RegisterClass = mock.Mock(name="win32gui.RegisterClass") win32gui.UnregisterClass = mock.Mock(name="win32gui.UnregisterClass") win32gui.Shell_NotifyIcon = mock.Mock(name="win32gui.Shell_NotifyIcon") win32gui.UpdateWindow = mock.Mock(name="win32gui.UpdateWindow") win32gui.WNDCLASS = mock.Mock(name="win32gui.WNDCLASS") # Save all apprise.* module references before wiping them. # These are the same module objects whose __dict__ entries are # pointed to by module-level imports in other test files; restoring # them keeps those references valid after the test completes. saved_apprise = { k: v for k, v in sys.modules.items() if k.startswith("apprise.") } # Inject win32 stubs so the plugin's top-level import succeeds, # then wipe apprise.* and reload so it picks them up. sys.modules["win32api"] = win32api sys.modules["win32con"] = win32con sys.modules["win32gui"] = win32gui for mod in list(saved_apprise): del sys.modules[mod] reload(apprise) # Yield win32gui -- the test uses it to configure side-effects yield win32gui # --- Teardown --- # Remove all apprise.* entries created during this test for mod in [k for k in sys.modules if k.startswith("apprise.")]: del sys.modules[mod] # Remove the injected win32 stubs for stub in ("win32api", "win32con", "win32gui"): sys.modules.pop(stub, None) # Restore the original apprise.* module objects so that any # module-level `from apprise.plugins.X import NotifyX` in other # test files continues to point at live (non-abandoned) module dicts. sys.modules.update(saved_apprise) @pytest.mark.skipif( ( "win32api" in sys.modules or "win32con" in sys.modules or "win32gui" in sys.modules ), reason="Requires non-windows platform", ) def test_plugin_windows_mocked(mocked_win32_environment): """NotifyWindows() General Checks (via non-Windows platform)""" # Retrieve the win32gui mock that the fixture set up win32gui = mocked_win32_environment # Create our instance obj = apprise.Apprise.instantiate("windows://", suppress_exceptions=False) obj.duration = 0 # Test URL functionality assert isinstance(obj.url(), str) # Verify that a URL ID can not be generated assert obj.url_id() is None # Check that it found our mocked environments assert obj.enabled is True # _on_destroy check obj._on_destroy(0, "", 0, 0) # test notifications assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?image=True", suppress_exceptions=False ) obj.duration = 0 assert isinstance(obj.url(), str) assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?image=False", suppress_exceptions=False ) obj.duration = 0 assert isinstance(obj.url(), str) assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?duration=1", suppress_exceptions=False ) assert isinstance(obj.url(), str) # loads okay assert obj.duration == 1 assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?duration=invalid", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec obj = apprise.Apprise.instantiate( "windows://_/?duration=-1", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec obj = apprise.Apprise.instantiate( "windows://_/?duration=0", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec # To avoid slowdowns (for testing), turn it to zero for now obj.duration = 0 # Test our loading of our icon exception; it will still allow the # notification to be sent win32gui.LoadImage.side_effect = AttributeError assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) # Undo our change win32gui.LoadImage.side_effect = None # Test our global exception handling win32gui.UpdateWindow.side_effect = AttributeError assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) # Undo our change win32gui.UpdateWindow.side_effect = None # Toggle our testing for when we can't send notifications because the # package has been made unavailable to us obj.enabled = False assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) @pytest.mark.skipif( "win32api" not in sys.modules and "win32con" not in sys.modules and "win32gui" not in sys.modules, reason="Requires win32api, win32con, and win32gui", ) @mock.patch("win32gui.UpdateWindow") @mock.patch("win32gui.Shell_NotifyIcon") @mock.patch("win32gui.LoadImage") def test_plugin_windows_native( mock_loadimage, mock_notify, mock_update_window ): """NotifyWindows() General Checks (via Windows platform)""" # Create our instance obj = apprise.Apprise.instantiate("windows://", suppress_exceptions=False) obj.duration = 0 # Test URL functionality assert isinstance(obj.url(), str) # Check that it found our mocked environments assert obj.enabled is True # _on_destroy check obj._on_destroy(0, "", 0, 0) # test notifications assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?image=True", suppress_exceptions=False ) obj.duration = 0 assert isinstance(obj.url(), str) assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?image=False", suppress_exceptions=False ) obj.duration = 0 assert isinstance(obj.url(), str) assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) obj = apprise.Apprise.instantiate( "windows://_/?duration=1", suppress_exceptions=False ) assert isinstance(obj.url(), str) assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) # loads okay assert obj.duration == 1 obj = apprise.Apprise.instantiate( "windows://_/?duration=invalid", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec obj = apprise.Apprise.instantiate( "windows://_/?duration=-1", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec obj = apprise.Apprise.instantiate( "windows://_/?duration=0", suppress_exceptions=False ) # Falls back to default assert obj.duration == obj.default_popup_duration_sec # To avoid slowdowns (for testing), turn it to zero for now obj = apprise.Apprise.instantiate("windows://", suppress_exceptions=False) obj.duration = 0 # Test our loading of our icon exception; it will still allow the # notification to be sent mock_loadimage.side_effect = AttributeError assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is True ) # Undo our change mock_loadimage.side_effect = None # Test our global exception handling mock_update_window.side_effect = AttributeError assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) # Undo our change mock_update_window.side_effect = None # Toggle our testing for when we can't send notifications because the # package has been made unavailable to us obj.enabled = False assert ( obj.notify( title="title", body="body", notify_type=apprise.NotifyType.INFO ) is False ) caronc-apprise-182f859/tests/test_plugin_workflows.py000066400000000000000000001123101524161175200231000ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from inspect import cleandoc import json # Disable logging for a cleaner testing output import logging import sys from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseConfig, NotifyFormat, NotifyType from apprise.plugins.workflows import NotifyWorkflows logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ################################## # NotifyWorkflows ################################## ( "workflow://", { # invalid host details (parsing fails very early) "instance": None, }, ), ( "workflow://:@/", { # invalid host details (parsing fails very early) "instance": None, }, ), ( "workflow://host/workflow", { # workflow provided only, no signature "instance": TypeError, }, ), ( "workflow://host:443/^(/signature", { # invalid workflow provided "instance": TypeError, }, ), ( "workflow://host:443/workflow1a/signature/?image=no", { # All tokens provided - we're good # Tests case without image defined "instance": NotifyWorkflows, }, ), ( "workflows://host:443/workflow1b/signature/", { # support workflows (s added to end) "instance": NotifyWorkflows, }, ), ( "workflows://host:443/signature/?id=workflow1c", { # id= to store workflow id "instance": NotifyWorkflows, }, ), ( "workflows://host:443/signature/?workflow=workflow1d&wrap=yes", { # workflow= to store workflow id "instance": NotifyWorkflows, }, ), ( "workflows://host:443/signature/?workflow=workflow1d&wrap=no", { # workflow= to store workflow id "instance": NotifyWorkflows, }, ), ( "workflows://host:443/workflow1e/signature/?api-version=2024-01-01", { # support api-version which is extracted from webhook "instance": NotifyWorkflows, # Our expected url(privacy=True) startswith() response "privacy_url": "workflow://host:443/w...e/s...e/", }, ), ( "workflows://host:443/workflow1b/signature/?ver=2016-06-01", { # Support ver= (api-version alias) "instance": NotifyWorkflows, }, ), ( "workflows://host:443/?id=workflow1b&signature=signature", { # Support signature= (sig= alias) "instance": NotifyWorkflows, # Our expected url(privacy=True) startswith() response "privacy_url": "workflow://host:443/w...b/s...e/", }, ), ( "workflows://host:443/workflow1e/signature/?powerautomate=yes", { # support power_automate flag "instance": NotifyWorkflows, }, ), ( "workflows://host:443/workflow1e/signature/?pa=yes&ver=1995-01-01", { # support power_automate flag with ver flag "instance": NotifyWorkflows, }, ), ( "workflows://host:443/workflow1e/signature/?pa=yes", { # support power_automate flag "instance": NotifyWorkflows, }, ), # Support native URLs ( ( "https://server.azure.com:443/workflows/643e69f83c8944/" "triggers/manual/paths/invoke?" "api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&" "sv=1.0&sig=KODuebWbDGYFr0z0eu" ), { # All tokens provided - we're good "instance": NotifyWorkflows, # Our expected url(privacy=True) startswith() response "privacy_url": "workflow://server.azure.com:443/6...4/K...u/", }, ), ( ( "https://server.azure.com:443/" "powerautomate/automations/direct/" "workflows/643e69f83c8944/" "triggers/manual/paths/invoke?" "api-version=2022-03-01-preview&sp=%2Ftriggers%2Fmanual%2Frun&" "sv=1.0&sig=KODuebWbDGYFr0z0eu" ), { # Power-Automate alternative URL - All tokens provided - we're good "instance": NotifyWorkflows, }, ), ( "workflow://host:443/workflow2/signature/", { "instance": NotifyWorkflows, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "workflow://host:443/workflow3/signature/", { "instance": NotifyWorkflows, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "workflow://host:443/workflow4/signature/", { "instance": NotifyWorkflows, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_workflows_urls(): """NotifyWorkflows() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @pytest.fixture def workflows_url(): return "workflow://host:443/workflow/signature" @pytest.fixture def request_mock(mocker): """Prepare requests mock.""" mock_post = mocker.patch("requests.post") mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok return mock_post @pytest.fixture def simple_template(tmpdir): template = tmpdir.join("simple.json") template.write( cleandoc(""" { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "msteams": { "width": "full" }, "body": [ { "type": "TextBlock", "text": "**Test**", "style": "heading" } ] } }] } """) ) return template def test_plugin_workflows_simple_test( request_mock, workflows_url, ): """ NotifyWorkflows() simple testing """ # Instantiate our URL obj = Apprise.instantiate(workflows_url) assert isinstance(obj, NotifyWorkflows) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert request_mock.called is True assert request_mock.call_args_list[0][0][0].startswith( "https://host:443/workflows/workflow/triggers/manual/paths/invoke" ) payload = json.loads(request_mock.call_args_list[0][1]["data"]) assert "NotifyType." not in request_mock.call_args_list[0][1]["data"] assert payload == { "type": "message", "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": None, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "Image", "url": "https://github.com/caronc/apprise/raw/" "master/apprise/assets/themes/default/" "apprise-info-32x32.png", "height": "32px", "altText": NotifyType.INFO.value, }, { "type": "TextBlock", # Verify our Title is set "text": "title", "style": "heading", "weight": "Bolder", "size": "Large", "id": "title", }, { "type": "TextBlock", # Verify our Body is set "text": "body", "style": "default", "wrap": True, "id": "body", }, ], "msteams": {"width": "full"}, }, }, ], } request_mock.reset_mock() # Instantiate our URL obj = Apprise.instantiate(f"{workflows_url}?pa=yes") assert isinstance(obj, NotifyWorkflows) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert request_mock.called is True assert request_mock.call_args_list[0][0][0].startswith( "https://host:443/powerautomate/automations/direct/" "workflows/workflow/triggers/manual/paths/invoke" ) payload = json.loads(request_mock.call_args_list[0][1]["data"]) assert "NotifyType." not in request_mock.call_args_list[0][1]["data"] assert payload == { "type": "message", "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": None, "content": { "$schema": "http://adaptivecards.io/schemas/" "adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "Image", "url": "https://github.com/caronc/apprise/raw/" "master/apprise/assets/themes/default/" "apprise-info-32x32.png", "height": "32px", "altText": "info", }, { "type": "TextBlock", # Verify our Title is set "text": "title", "style": "heading", "weight": "Bolder", "size": "Large", "id": "title", }, { "type": "TextBlock", # Verify our Body is set "text": "body", "style": "default", "wrap": True, "id": "body", }, ], "msteams": { "width": "full", }, }, }, ], } def test_plugin_workflows_templating_basic_success( request_mock, workflows_url, tmpdir ): """ NotifyWorkflows() Templating - success. Test cases where URL and JSON is valid. """ template = tmpdir.join("simple.json") template.write( cleandoc(""" { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ {"type": "TextBlock", "id": "app_id", "text": "{{app_id}}"}, {"type": "TextBlock", "id": "color", "text": "{{app_color}}"}, {"type": "TextBlock", "id": "title", "text": "{{app_title}}"}, {"type": "TextBlock", "id": "body", "text": "{{app_body}}"} ] } }] } """) ) # Instantiate our URL obj = Apprise.instantiate( "{url}/?template={template}&{kwargs}".format( url=workflows_url, template=str(template), kwargs=":key1=token&:key2=token", ) ) assert isinstance(obj, NotifyWorkflows) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert request_mock.called is True assert request_mock.call_args_list[0][0][0].startswith( "https://host:443/workflows/workflow/triggers/manual/paths/invoke" ) # Our Posted JSON Object posted_json = json.loads(request_mock.call_args_list[0][1]["data"]) assert "NotifyType." not in request_mock.call_args_list[0][1]["data"] # Verify the Adaptive Card envelope assert posted_json["type"] == "message" content = posted_json["attachments"][0]["content"] # Index body blocks by their id for easy lookup blocks = {b["id"]: b["text"] for b in content["body"] if "id" in b} assert blocks["app_id"] == "Apprise" assert blocks["color"] == "#3AA3E3" assert blocks["title"] == "title" assert blocks["body"] == "body" def test_plugin_workflows_templating_invalid_json( request_mock, workflows_url, tmpdir ): """ NotifyWorkflows() Templating - invalid JSON. """ template = tmpdir.join("invalid.json") template.write("}") # Instantiate our URL obj = Apprise.instantiate( "{url}/?template={template}&{kwargs}".format( url=workflows_url, template=str(template), kwargs=":key1=token&:key2=token", ) ) assert isinstance(obj, NotifyWorkflows) # We will fail to preform our notifcation because the JSON is bad assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) def test_plugin_workflows_templating_load_json_failure( request_mock, workflows_url, tmpdir ): """ NotifyWorkflows() Templating - template loading failure. Test a case where we can not access the file. """ template = tmpdir.join("empty.json") template.write("") obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") with mock.patch("json.loads", side_effect=OSError): # we fail, but this time it's because we couldn't # access the cached file contents for reading assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) def test_plugin_workflows_templating_target_success( request_mock, workflows_url, tmpdir ): """ NotifyWorkflows() Templating - success with target. A more complicated example; uses a custom token. """ template = tmpdir.join("more_complicated_example.json") template.write( cleandoc(""" { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ {"type": "TextBlock", "id": "desc", "text": "{{app_desc}}"}, {"type": "TextBlock", "id": "color", "text": "{{app_color}}"}, {"type": "TextBlock", "id": "title", "text": "{{app_title}}"}, {"type": "TextBlock", "id": "body", "text": "{{app_body}}"} ], "actions": [ { "type": "Action.OpenUrl", "title": "Open", "url": "{{ target }}" } ] } }] } """) ) # Instantiate our URL obj = Apprise.instantiate( "{url}/?template={template}&{kwargs}".format( url=workflows_url, template=str(template), kwargs=":key1=token&:key2=token&:target=http://localhost", ) ) assert isinstance(obj, NotifyWorkflows) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) assert request_mock.called is True assert request_mock.call_args_list[0][0][0].startswith( "https://host:443/workflows/workflow/triggers/manual/paths/invoke" ) # Our Posted JSON Object posted_json = json.loads(request_mock.call_args_list[0][1]["data"]) assert "NotifyType." not in request_mock.call_args_list[0][1]["data"] # Verify the Adaptive Card envelope assert posted_json["type"] == "message" content = posted_json["attachments"][0]["content"] # Index body blocks by their id for easy lookup blocks = {b["id"]: b["text"] for b in content["body"] if "id" in b} assert blocks["desc"] == "Apprise Notifications" assert blocks["color"] == "#3AA3E3" assert blocks["title"] == "title" assert blocks["body"] == "body" # The custom :target token must be substituted into the action URL assert content["actions"][0]["url"] == "http://localhost" def test_plugin_workflows_templating_invalid_type( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - root 'type' != 'message' is rejected.""" template = tmpdir.join("bad_type.json") template.write( cleandoc(""" { "type": "wrongtype", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": {} }] } """) ) obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) # Validation rejects incorrect 'type' value assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert request_mock.called is False def test_plugin_workflows_templating_missing_attachments( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - absent or empty attachments fails.""" template = tmpdir.join("no_attachments.json") # Missing 'attachments' key entirely template.write('{"type": "message"}') obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert request_mock.called is False # Empty list is also rejected template.write('{"type": "message", "attachments": []}') obj2 = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj2, NotifyWorkflows) assert ( obj2.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert request_mock.called is False def test_plugin_workflows_templating_invalid_contenttype( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - attachment missing contentType fails.""" template = tmpdir.join("no_contenttype.json") template.write('{"type": "message", "attachments": [{"content": {}}]}') obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) # An attachment without a 'contentType' string is rejected assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert request_mock.called is False @pytest.mark.skipif( sys.version_info < (3, 10), reason="Python <3.10: see test_plugin_workflows_template_add_failure_py39", ) def test_plugin_workflows_template_add_failure(): """NotifyWorkflows() - TypeError when add() drops the entry.""" # Simulate add() silently failing (len stays 0); no HTTP call needed with mock.patch("apprise.plugins.workflows.AppriseAttachment") as mock_cls: inst = mock.MagicMock() inst.__len__ = mock.Mock(return_value=0) mock_cls.return_value = inst with pytest.raises(TypeError): NotifyWorkflows( workflow="T00000000001", signature="AbCdEfGhIjKlMnOpQrStUvWx", template="file:///some/template.json", ) # --- Python v3.9 Support --- # On Python 3.9, mock.patch() resolves a dotted target ("a.b.c.Name") by # walking getattr(pkg, "sub") chains rather than going straight to # sys.modules (that was fixed in Python 3.10+). # Delete this test once Python 3.9 reaches end-of-life. @pytest.mark.skipif( sys.version_info >= (3, 10), reason="Python 3.10+ mock.patch resolves via sys.modules directly", ) def test_plugin_workflows_template_add_failure_py39_compat(): """NotifyWorkflows() - TypeError when add() drops the entry (Py 3.9).""" import importlib # Resolve the live module so patch.object and the class share the same # module dict regardless of any sys.modules manipulation by earlier tests. wf_mod = importlib.import_module("apprise.plugins.workflows") _NotifyWorkflows = wf_mod.NotifyWorkflows # Simulate add() silently failing (len stays 0); no HTTP call needed with mock.patch.object(wf_mod, "AppriseAttachment") as mock_cls: inst = mock.MagicMock() inst.__len__ = mock.Mock(return_value=0) mock_cls.return_value = inst with pytest.raises(TypeError): _NotifyWorkflows( workflow="T00000000001", signature="AbCdEfGhIjKlMnOpQrStUvWx", template="file:///some/template.json", ) # --- End Python v3.9 Compat --- def test_plugin_workflows_templating_content_not_dict( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - template that parses to a JSON array is rejected cleanly.""" # Valid JSON but a list rather than an object; no HTTP call is made template = tmpdir.join("array.json") template.write('[{"type": "message"}]') obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert request_mock.called is False def test_plugin_workflows_templating_attachment_not_dict( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - non-dict in attachments rejected.""" # attachments list contains a string rather than a dict; no HTTP call made template = tmpdir.join("bad_attach.json") template.write('{"type": "message", "attachments": ["not-a-dict"]}') obj = Apprise.instantiate(f"{workflows_url}/?template={template!s}") assert isinstance(obj, NotifyWorkflows) assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert request_mock.called is False def test_plugin_workflows_templating_none_token_value( request_mock, workflows_url, tmpdir ): """NotifyWorkflows() Templating - None token value (e.g. app_image_url) is coerced to empty string before JSON-escaping.""" # Template references app_image_url which will be None when # include_image=False; old code produced corrupted JSON ("ul" from None) template = tmpdir.join("img_ref.json") template.write( '{"type": "message", "attachments": [{' '"contentType": "application/vnd.microsoft.card.adaptive",' '"content": {"body": [{"type": "TextBlock",' '"text": "{{app_body}} img={{app_image_url}}"}]}}]}' ) # image=no forces app_image_url to None, exercising the None->"" # coercion in safe_tokens obj = Apprise.instantiate( f"{workflows_url}/?image=no&template={template!s}" ) assert isinstance(obj, NotifyWorkflows) # Must succeed -- None coerced to "" keeps the JSON valid assert ( obj.notify(body="hello", title="t", notify_type=NotifyType.INFO) is True ) assert request_mock.called is True posted_data = json.loads(request_mock.call_args_list[0][1]["data"]) body_text = posted_data["attachments"][0]["content"]["body"][0]["text"] # app_image_url should expand to "" not "ul" assert "img=" in body_text assert "ul" not in body_text def test_workflows_yaml_config_missing_template_filename( request_mock, workflows_url, simple_template, tmpdir ): """ NotifyWorkflows() YAML Configuration Entries - Missing template reference. """ config = tmpdir.join("workflow01.yml") config.write( cleandoc(f""" urls: - {workflows_url}: - tag: 'workflow' template: {simple_template!s}.missing :name: 'Template.Missing' :body: 'test body' :title: 'test title' """) ) # Config still loads okay cfg = AppriseConfig() cfg.add(str(config)) assert len(cfg) == 1 assert len(cfg[0]) == 1 obj = cfg[0][0] assert isinstance(obj, NotifyWorkflows) # However we can't send notification since the template couldn't be loaded assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is False ) assert request_mock.called is False def test_plugin_workflows_edge_cases(): """NotifyWorkflows() Edge Cases.""" # Initializes the plugin with an invalid token with pytest.raises(TypeError): NotifyWorkflows(workflow="@", signature="@") with pytest.raises(TypeError): NotifyWorkflows(workflow="", signature="abcd") with pytest.raises(TypeError): NotifyWorkflows(workflow=None, signature="abcd") # Whitespace also acts as an invalid token value with pytest.raises(TypeError): NotifyWorkflows(workflow=" ", signature="abcd") with pytest.raises(TypeError): NotifyWorkflows(workflow="abcd", signature=None) # Whitespace also acts as an invalid token value with pytest.raises(TypeError): NotifyWorkflows(workflow="abcd", signature=" ") # test case where invalid tokens are specified with pytest.raises(TypeError): NotifyWorkflows( workflow="workflow", signature="signature", tokens="not-a-dict" ) # test case where no tokens are specified obj = NotifyWorkflows(workflow="workflow", signature="signature") assert isinstance(obj, NotifyWorkflows) def test_plugin_workflows_azure_webhooks(request_mock): """NotifyWorkflows() Azure Webhooks.""" url = ( "https://prod-15.uksouth.logic.azure.com:443" "/workflows/3XXX5/triggers/manual/paths/invoke" "?api-version=2016-06-01&" "sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=iXXXU" ) # # Initialize # obj = Apprise.instantiate(url) assert isinstance(obj, NotifyWorkflows) assert obj.workflow == "3XXX5" assert obj.signature == "iXXXU" assert obj.api_version == "2016-06-01" @pytest.mark.parametrize("routing_id", (123456789, 987654321)) def test_plugin_workflows_power_automate_cu_webhooks(request_mock, routing_id): """NotifyWorkflows() preserves Power Automate CU routing IDs.""" url = ( "https://default.environment.api.powerplatform.com:443" f"/powerautomate/automations/direct/cu/{routing_id}" "/workflows/3XXX5/triggers/manual/paths/invoke" "?api-version=2022-03-01-preview&" "sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=iXXXU" ) obj = Apprise.instantiate(url) assert isinstance(obj, NotifyWorkflows) assert obj.notify(body="body", title="title") is True assert request_mock.call_args.args[0] == ( "https://default.environment.api.powerplatform.com:443" f"/powerautomate/automations/direct/cu/{routing_id}" "/workflows/3XXX5/triggers/manual/paths/invoke" ) # Our routing ID is tracked and advertised in the Apprise URL assert obj.routing_id == str(routing_id) assert f"route={routing_id}" in obj.url() @pytest.mark.parametrize("key", ("route", "routeid")) def test_plugin_workflows_routing_id_parse_url(key): """NotifyWorkflows() parses the routing ID from an Apprise URL.""" results = NotifyWorkflows.parse_url( f"workflow://host/3XXX5/iXXXU/?pa=yes&{key}=123456789" ) assert isinstance(results, dict) assert results["routing_id"] == "123456789" obj = Apprise.instantiate( f"workflow://host/3XXX5/iXXXU/?pa=yes&{key}=123456789" ) assert isinstance(obj, NotifyWorkflows) assert obj.routing_id == "123456789" # No routing ID provided; nothing is set and nothing is advertised obj = Apprise.instantiate("workflow://host/3XXX5/iXXXU/?pa=yes") assert isinstance(obj, NotifyWorkflows) assert obj.routing_id is None assert "route=" not in obj.url() def test_plugin_workflows_routing_id_invalid(): """NotifyWorkflows() rejects a non-numeric routing ID.""" with pytest.raises(TypeError): NotifyWorkflows( workflow="3XXX5", signature="iXXXU", routing_id="1/../evil" ) # Confirmed unusable (and therefore never reflected) via a full URL too assert ( Apprise.instantiate( "workflow://host/3XXX5/iXXXU/?pa=yes&route=1/../evil" ) is None ) def test_plugin_workflows_routing_id_round_trip(request_mock): """NotifyWorkflows() round-trips the routing ID through url().""" obj = Apprise.instantiate( "https://default.environment.api.powerplatform.com:443" "/powerautomate/automations/direct/cu/123456789" "/workflows/3XXX5/triggers/manual/paths/invoke" "?api-version=2022-03-01-preview&" "sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=iXXXU" ) assert isinstance(obj, NotifyWorkflows) assert obj.routing_id == "123456789" # Re-instantiate ourselves from our own generated URL obj_copy = Apprise.instantiate(obj.url()) assert isinstance(obj_copy, NotifyWorkflows) assert obj_copy.routing_id == obj.routing_id assert obj_copy.url() == obj.url() # The routed endpoint survives the round trip assert obj_copy.notify(body="body", title="title") is True assert request_mock.call_args.args[0] == ( "https://default.environment.api.powerplatform.com:443" "/powerautomate/automations/direct/cu/123456789" "/workflows/3XXX5/triggers/manual/paths/invoke" ) @mock.patch("requests.post") def test_plugin_workflows_html_to_markdown_format(mock_post): """NotifyWorkflows(): HTML body is converted to Markdown.""" # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Workflows' notify_format is MARKDOWN by default aobj = Apprise() assert aobj.add("workflow://hostname/workflow1/signature1/?image=no") # Notify with an HTML body; the framework should convert it # to Markdown before dispatching to Workflows assert ( aobj.notify( body="hello world", body_format=NotifyFormat.HTML, ) is True ) assert mock_post.call_count == 1 # The body must arrive as Markdown inside the adaptive card body block, # not as stripped plain text payload = json.loads(mock_post.call_args_list[0][1]["data"]) body_blocks = payload["attachments"][0]["content"]["body"] body_text = next(b["text"] for b in body_blocks if b.get("id") == "body") assert body_text == "**hello** *world*" @mock.patch("requests.post") def test_plugin_workflows_mention_entities(mock_post): """Teams mention tags populate unique ``msteams.entities`` entries.""" # Prepare a successful mocked HTTP response. mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok aobj = Apprise() assert aobj.add("workflow://hostname/workflow1/signature1/?image=no") # Keep Teams metadata minimal when the body contains no mentions. assert aobj.notify(body="Hello world", title="T") is True payload = json.loads(mock_post.call_args_list[0][1]["data"]) msteams = payload["attachments"][0]["content"]["msteams"] assert msteams == {"width": "full"} mock_post.reset_mock() # Add one entity for a single mention. assert ( aobj.notify( body="Hello foo@example.com!", title="T", ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) msteams = payload["attachments"][0]["content"]["msteams"] assert msteams == { "width": "full", "entities": [ { "type": "mention", "text": "foo@example.com", "mentioned": { "id": "foo@example.com", "name": "foo@example.com", }, } ], } mock_post.reset_mock() # Preserve source order for distinct mentions. assert ( aobj.notify( body=( "Hi alice@example.com and bob@example.com!" ), title="T", ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entities = payload["attachments"][0]["content"]["msteams"]["entities"] assert len(entities) == 2 assert entities[0]["mentioned"]["id"] == "alice@example.com" assert entities[1]["mentioned"]["id"] == "bob@example.com" mock_post.reset_mock() # Deduplicate repeated mention IDs. assert ( aobj.notify( body=( "dup@example.com and dup@example.com again" ), title="T", ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entities = payload["attachments"][0]["content"]["msteams"]["entities"] assert len(entities) == 1 assert entities[0]["mentioned"]["id"] == "dup@example.com" mock_post.reset_mock() # Recover the valid inner tag from a nested opening sequence. assert ( aobj.notify( body="Hi user@example.com!", title="T", ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entities = payload["attachments"][0]["content"]["msteams"]["entities"] assert len(entities) == 1 assert entities[0]["mentioned"]["id"] == "user@example.com" mock_post.reset_mock() # Recover an inner mention when text invalidates the outer tag. assert ( aobj.notify( body="garbageinner@example.com", title="T", ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entities = payload["attachments"][0]["content"]["msteams"]["entities"] assert len(entities) == 1 assert entities[0]["mentioned"]["id"] == "inner@example.com" mock_post.reset_mock() # Ignore empty, whitespace-only, and unclosed mention tags. for bad_body in ("", " ", "unclosed"): assert aobj.notify(body=bad_body, title="T") is True payload = json.loads(mock_post.call_args_list[-1][1]["data"]) msteams = payload["attachments"][0]["content"]["msteams"] assert msteams == {"width": "full"}, ( f"expected no entities for {bad_body!r}" ) mock_post.reset_mock() # Preserve mixed-case entity text for Teams' verbatim body match. assert ( aobj.notify( body="Hi user@example.com!", title="T", ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entity = payload["attachments"][0]["content"]["msteams"]["entities"][0] assert entity["text"] == "user@example.com" assert entity["mentioned"]["id"] == "user@example.com" mock_post.reset_mock() # Preserve tag padding while trimming the ID used for user resolution. assert ( aobj.notify( body="Hi padded@example.com !", title="T", ) is True ) payload = json.loads(mock_post.call_args_list[0][1]["data"]) entity = payload["attachments"][0]["content"]["msteams"]["entities"][0] assert entity["text"] == " padded@example.com " assert entity["mentioned"]["id"] == "padded@example.com" caronc-apprise-182f859/tests/test_plugin_wxpusher.py000066400000000000000000000304121524161175200227320ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps, loads # Disable logging for a cleaner testing output import logging import os from unittest import mock from helpers import AppriseURLTester import requests from apprise import Apprise from apprise.plugins.wxpusher import NotifyWxPusher logging.disable(logging.CRITICAL) WXPUSHER_GOOD_RESPONSE = dumps({"code": 1000}) WXPUSHER_BAD_RESPONSE = dumps({"code": 99}) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "wxpusher://", { # No token specified "instance": TypeError, }, ), ( "wxpusher://:@/", { # invalid url "instance": TypeError, }, ), ( "wxpusher://invalid", { # invalid app token "instance": TypeError, }, ), ( "wxpusher://AT_appid/123/", { # invalid 'to' phone number "instance": NotifyWxPusher, # Notify will fail because it couldn't send to anyone "response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/123/", # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/%20/%20/", { # invalid 'to' phone number "instance": NotifyWxPusher, # Notify will fail because it couldn't send to anyone "response": False, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/", # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/123/", { # one phone number will notify ourselves "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://123?token=AT_abc1234", { # pass our token in as an argument and our host actually becomes a # target "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://?token=AT_abc1234", { # slightly different then test above; a token is defined, but # there are no targets "instance": NotifyWxPusher, # Notify will fail because it couldn't send to anyone "response": False, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://?token=AT_abc1234&to=UID_abc", { # all kwargs to load url with "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/UID_abcd/", { # a valid contact "instance": NotifyWxPusher, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/UID_abcd", # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/@/#/,/", { # Test case where we provide bad data "instance": NotifyWxPusher, # Our failed response "requests_response_text": WXPUSHER_GOOD_RESPONSE, # as a result, we expect a failed notification "response": False, }, ), ( "wxpusher://AT_appid/123/", { # Test case where we get a bad response "instance": NotifyWxPusher, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/123", # Our failed response "requests_response_text": WXPUSHER_BAD_RESPONSE, # as a result, we expect a failed notification "response": False, }, ), ( "wxpusher://AT_appid/UID_345/", { # Test case where we get a bad response "instance": NotifyWxPusher, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/UID_345", # Our failed response "requests_response_text": None, # as a result, we expect a failed notification "response": False, }, ), ( "wxpusher://AT_appid/UID_345/", { # Test case where we get a bad response "instance": NotifyWxPusher, # Our expected url(privacy=True) startswith() response: "privacy_url": "wxpusher://****/UID_345", # Our failed response (bad json) "requests_response_text": "{", # as a result, we expect a failed notification "response": False, }, ), ( "wxpusher://AT_appid/?to={},{}".format("2" * 11, "3" * 11), { # use get args to acomplish the same thing "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/?to={},{},{}".format("2" * 11, "3" * 11, "5" * 3), { # 2 good targets and one invalid one "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/{}/{}/".format("2" * 11, "3" * 11), { # If we have from= specified, then all elements take on the # to= value "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/{}".format("3" * 11), { # use get args to acomplish the same thing (use source instead # of from) "instance": NotifyWxPusher, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/{}".format("4" * 11), { "instance": NotifyWxPusher, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, # Our response expected server response "requests_response_text": WXPUSHER_GOOD_RESPONSE, }, ), ( "wxpusher://AT_appid/{}".format("4" * 11), { "instance": NotifyWxPusher, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_wxpusher_urls(): """NotifyWxPusher() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_wxpusher_edge_cases(mock_post): """NotifyWxPusher() Edge Cases.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok response.content = WXPUSHER_GOOD_RESPONSE # Prepare Mock mock_post.return_value = response # Initialize some generic (but valid) tokens target = "UID_abcd" body = "test body" title = "My Title" aobj = Apprise() assert aobj.add(f"wxpusher://AT_appid/{target}") assert len(aobj) == 1 assert aobj.notify(title=title, body=body) assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert details[0][0] == "https://wxpusher.zjiecode.com/api/send/message" payload = loads(details[1]["data"]) assert payload == { "appToken": "AT_appid", "content": "test body", "summary": "My Title", "contentType": 1, "topicIds": [], "uids": ["UID_abcd"], "url": None, } # Reset our mock object mock_post.reset_mock() @mock.patch("requests.post") def test_plugin_wxpusher_result_set(mock_post): """NotifyWxPusher() Result Sets.""" # Prepare our response response = requests.Request() response.status_code = requests.codes.ok response.content = WXPUSHER_GOOD_RESPONSE # Prepare Mock mock_post.return_value = response body = "test body" title = "My Title" aobj = Apprise() aobj.add("wxpusher://AT_appid/123/abc/UID_456") # One bad entry and 2 good assert len(aobj[0]) == 1 assert aobj.notify(title=title, body=body) # 2 posts made assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert details[0][0] == "https://wxpusher.zjiecode.com/api/send/message" payload = loads(details[1]["data"]) assert payload == { "appToken": "AT_appid", "content": "test body", "summary": "My Title", "contentType": 1, "topicIds": [123], "uids": ["UID_456"], "url": None, } # Validate our information is also placed back into the assembled URL assert "/123" in aobj[0].url() assert "/UID_456" in aobj[0].url() assert "/abc" in aobj[0].url() mock_post.reset_mock() aobj = Apprise() aobj.add("wxpusher://AT_appid//UID_123/UID_abc/123456789") assert len(aobj[0]) == 1 assert aobj.notify(title=title, body=body) # If batch is off then there is a post per entry assert mock_post.call_count == 1 details = mock_post.call_args_list[0] assert details[0][0] == "https://wxpusher.zjiecode.com/api/send/message" payload = loads(details[1]["data"]) assert payload == { "appToken": "AT_appid", "content": "test body", "summary": "My Title", "contentType": 1, "topicIds": [123456789], "uids": ["UID_123", "UID_abc"], "url": None, } assert "/123456789" in aobj[0].url() assert "/UID_123" in aobj[0].url() assert "/UID_abc" in aobj[0].url() @mock.patch("requests.post") def test_notify_wxpusher_plugin_result_list(mock_post): """NotifyWxPusher() Result List Response.""" okay_response = requests.Request() okay_response.status_code = requests.codes.ok # We want to test the case where the `result` set returned is a list # Invalid JSON response okay_response.content = "{" # Assign our mock object our return value mock_post.return_value = okay_response obj = Apprise.instantiate("wxpusher://AT_apptoken/UID_abcd/") assert isinstance(obj, NotifyWxPusher) # We should now fail assert obj.notify("test") is False caronc-apprise-182f859/tests/test_plugin_xbmc_kodi.py000066400000000000000000000170201524161175200230040ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import requests from apprise import NotifyType from apprise.plugins.kodi import NotifyXBMC logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "kodi://", { "instance": None, }, ), ( "kodis://", { "instance": None, }, ), ( "kodi://localhost", { "instance": NotifyXBMC, }, ), ( "kodi://192.168.4.1", { # Support IPv4 Addresses "instance": NotifyXBMC, }, ), ( "kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]", { # Support IPv6 Addresses "instance": NotifyXBMC, # Privacy URL "privacy_url": "kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]", }, ), ( "kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8282", { # Support IPv6 Addresses with port "instance": NotifyXBMC, # Privacy URL "privacy_url": ( "kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8282" ), }, ), ( "kodi://user:pass@localhost", { "instance": NotifyXBMC, # Our expected url(privacy=True) startswith() response: "privacy_url": "kodi://user:****@localhost", }, ), ( "kodi://localhost:8080", { "instance": NotifyXBMC, }, ), ( "kodi://user:pass@localhost:8080", { "instance": NotifyXBMC, }, ), ( "kodis://localhost", { "instance": NotifyXBMC, }, ), ( "kodis://user:pass@localhost", { "instance": NotifyXBMC, }, ), ( "kodis://localhost:8080/path/", { "instance": NotifyXBMC, }, ), ( "kodis://user:password@localhost:8080", { "instance": NotifyXBMC, # Our expected url(privacy=True) startswith() response: "privacy_url": "kodis://user:****@localhost:8080", }, ), ( "kodi://localhost", { "instance": NotifyXBMC, # Experement with different notification types "notify_type": NotifyType.WARNING, }, ), ( "kodi://localhost", { "instance": NotifyXBMC, # Experement with different notification types "notify_type": NotifyType.FAILURE, }, ), ( "kodis://localhost:443", { "instance": NotifyXBMC, # don't include an image by default "include_image": False, }, ), ( "kodi://:@/", { "instance": None, }, ), ( "kodi://user:pass@localhost:8081", { "instance": NotifyXBMC, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "kodi://user:pass@localhost:8082", { "instance": NotifyXBMC, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "kodi://user:pass@localhost:8083", { "instance": NotifyXBMC, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), # # XMBC (Legacy Platform) Shares this same KODI Plugin # ( "xbmc://", { "instance": None, }, ), ( "xbmc://localhost", { "instance": NotifyXBMC, }, ), ( "xbmc://localhost?duration=14", { "instance": NotifyXBMC, }, ), ( "xbmc://localhost?duration=invalid", { "instance": NotifyXBMC, }, ), ( "xbmc://localhost?duration=-1", { "instance": NotifyXBMC, }, ), ( "xbmc://user:pass@localhost", { "instance": NotifyXBMC, }, ), ( "xbmc://localhost:8080", { "instance": NotifyXBMC, }, ), ( "xbmc://user:pass@localhost:8080", { "instance": NotifyXBMC, }, ), ( "xbmc://user@localhost", { "instance": NotifyXBMC, # don't include an image by default "include_image": False, }, ), ( "xbmc://localhost", { "instance": NotifyXBMC, # Experement with different notification types "notify_type": NotifyType.WARNING, }, ), ( "xbmc://localhost", { "instance": NotifyXBMC, # Experement with different notification types "notify_type": NotifyType.FAILURE, }, ), ( "xbmc://:@/", { "instance": None, }, ), ( "xbmc://user:pass@localhost:8081", { "instance": NotifyXBMC, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "xbmc://user:pass@localhost:8082", { "instance": NotifyXBMC, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "xbmc://user:pass@localhost:8083", { "instance": NotifyXBMC, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_xbmc_kodi_urls(): """NotifyXBMC() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() caronc-apprise-182f859/tests/test_plugin_xmpp.py000066400000000000000000004776541524161175200220600ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """ Unit Tests for the XMPP plugin. """ from __future__ import annotations import asyncio import contextlib import gc import logging import ssl import threading import time from types import SimpleNamespace from typing import Any, Optional import pytest from apprise import LOGGER_NAME, Apprise, NotifyType from apprise.plugins.xmpp import adapter as xmpp_adapter, base as xmpp_base from apprise.plugins.xmpp.base import NotifyXMPP def run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any) -> Any: """Run a coroutine on a specific event loop.""" asyncio.set_event_loop(loop) return loop.run_until_complete(coro) # --------------------------------------------------------------------------- # Slixmpp Availability Handling # --------------------------------------------------------------------------- try: # Enforce slixmpp >= Minimum Supported Version SLIXMPP_AVAILABLE = xmpp_adapter.SlixmppAdapter.supported_version() except Exception: SLIXMPP_AVAILABLE = False # Seconds to sleep before forcing the timeout branch in _FakeDoneEvent.wait(). # This gives the worker thread enough time to progress to a known checkpoint # (e.g. event loop created, client instantiated) without relying on a longer # wall-clock timeout that would slow tests down. WORKER_THREAD_STARTUP_DELAY: float = 0.02 # --------------------------------------------------------------------------- # Fake Slixmpp Client # --------------------------------------------------------------------------- class FakeClientXMPP: def __init__(self, jid: str, password: str) -> None: self.jid = jid self.password = password # Loop is assigned by adapter via `client.loop = loop`, but we default # to the current event loop for safety in tests. self.loop: Optional[asyncio.AbstractEventLoop] = None self.handlers: dict[str, Any] = {} self.auto_reconnect = True # Slixmpp >= 1.16.0: adapter waits on this Future self.disconnected: Optional[asyncio.Future[bool]] = None # Slixmpp toggles used by adapter self.enable_plaintext = True self.enable_starttls = True self.enable_direct_tls = False self.ssl_context = None # Track plugins registered by the adapter (keepalive path) self.registered_plugins: dict[str, Any] = {} def add_event_handler(self, name: str, handler: Any) -> None: self.handlers[name] = handler def send_presence(self) -> None: return None async def get_roster(self) -> None: return None def send_message(self, **kwargs: Any) -> None: return None def register_plugin(self, name: str, config: Optional[Any] = None) -> None: self.registered_plugins[name] = config return None def disconnect(self) -> None: # Complete the disconnected Future if it is not already done. if self.disconnected is not None and not self.disconnected.done(): self.disconnected.set_result(True) def connect(self, **kwargs: Any) -> asyncio.Future[bool]: """ Slixmpp >= 1.16.0 connect() returns a Future. Our adapter awaits it. This fake schedules session_start on the assigned loop so the adapter's loop.run_until_complete() drives it, and ensures disconnected resolves. """ loop = self.loop or asyncio.get_running_loop() # Ensure disconnected future exists on the correct loop if self.disconnected is None: self.disconnected = loop.create_future() fut: asyncio.Future[bool] = loop.create_future() fut.set_result(True) handler = self.handlers.get("session_start") if handler: def _fire_session_start() -> None: rv = handler() if asyncio.iscoroutine(rv): task = loop.create_task(rv) # Ensure we always disconnect after session_start task.add_done_callback(lambda _: self.disconnect()) else: # sync handler, just disconnect self.disconnect() # Schedule for when the adapter starts running the loop loop.call_soon(_fire_session_start) return fut # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def install_fake_slixmpp(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(xmpp_adapter, "asyncio", asyncio, raising=False) monkeypatch.setattr( xmpp_adapter, "slixmpp", SimpleNamespace(ClientXMPP=FakeClientXMPP), raising=False, ) monkeypatch.setattr( xmpp_adapter, "SLIXMPP_SUPPORT_AVAILABLE", True, raising=False ) monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=False, ) def _patch_threading( monkeypatch: pytest.MonkeyPatch, *, done_event_cls: type ) -> None: """Patch adapter threading to use a custom done-event class.""" import threading as _real_threading class _ThreadingProxy: Event = done_event_cls Thread = _real_threading.Thread def __getattr__(self, name: str) -> Any: return getattr(_real_threading, name) monkeypatch.setattr( xmpp_adapter, "threading", _ThreadingProxy(), raising=True ) # --------------------------------------------------------------------------- # NotifyXMPP Tests # --------------------------------------------------------------------------- def _make_fake_done_event_cls( signal_evt: Optional[threading.Event] = None, ) -> type: """Return a deterministic threading.Event replacement for timeout tests. The adapter under test uses ``threading.Event()`` for *done*, then calls ``done.wait(timeout=...)`` and later ``done.set()`` from the worker thread. These tests want ``wait()`` to return ``False`` deterministically (forcing the timeout branch), while still giving the worker thread a chance to reach a known checkpoint first (e.g. event loop created, client created). Capturing *signal_evt* in a closure rather than as a class-level attribute ensures that each test's event class is fully independent β€” parallel test runs cannot interfere with one another through shared mutable class state. Parameters ---------- signal_evt: Optional ``threading.Event`` that the worker thread sets when it reaches a stable checkpoint. If provided, ``wait()`` pauses until that event is set before forcing the timeout branch. """ class _FakeDoneEvent: def __init__(self) -> None: self._set = False self._wait_calls = 0 def is_set(self) -> bool: return self._set def set(self) -> None: self._set = True def clear(self) -> None: self._set = False def wait(self, timeout: Optional[float] = None) -> bool: self._wait_calls += 1 if self._wait_calls == 1: if signal_evt is not None: # The specific timeout value isn't important; we just # don't want this to hang if the checkpoint is never # reached. signal_evt.wait(timeout=1.0) # Give the worker thread a brief chance to run before we # force the timeout condition in the code under test. time.sleep(WORKER_THREAD_STARTUP_DELAY) return False return self._set return _FakeDoneEvent @pytest.mark.skipif(SLIXMPP_AVAILABLE, reason="Requires slixmpp NOT installed") def test_slixmpp_unavailable() -> None: obj = NotifyXMPP(host="example.com", user="me@example.com", password="x") assert obj.send("will fail") is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_invalid_jid() -> None: with pytest.raises(TypeError): NotifyXMPP(host="example.com", user="bad jid", password="x") @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_targets_filtered() -> None: n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["ok@example.com", " user1 user2 ", "", "also@example.net"], ) # parse_list() tokenises on whitespace, so "bad jid" becomes two targets. # Empty values are dropped before they reach the validation loop, so no # warning is guaranteed for the empty string. assert sorted(n.targets) == [ ("chat", "also@example.net"), ("chat", "ok@example.com"), ("chat", "user1@example.com"), ("chat", "user2@example.com"), ] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_len() -> None: n1 = NotifyXMPP(host="example.com", user="me@example.com", password="x") assert len(n1) == 1 n2 = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["a@example.com", "b@example.com"], ) # still just one upstream message assert len(n2) == 1 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_url_privacy() -> None: n = NotifyXMPP( host="example.com", user="me@example.com", password="secret", targets=["a@example.com"], verify=False, ) u = n.url(privacy=True) assert "secret" not in u assert "verify=" in u @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_parse_url() -> None: r = NotifyXMPP.parse_url( "xmpps://me:pass@example.com/a@example.com?verify=no" ) assert r["verify"] is False assert r["targets"] == ["a@example.com"] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_parse_url_to() -> None: r = NotifyXMPP.parse_url( "xmpp://me:pass@example.com?to=a@example.com,b@example.net" ) assert sorted(r["targets"]) == ["a@example.com", "b@example.net"] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_invalid_targets_logged( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Invalid XMPP targets are dropped with a warning.""" caplog.set_level(logging.WARNING, logger=LOGGER_NAME) caplog.set_level(logging.WARNING, logger=xmpp_base.__name__) # parse_list() tokenises on whitespace, so it is difficult to naturally # feed a whitespace-containing value through. Patch parse_list() to # return a value that will fail IS_JID validation. monkeypatch.setattr( xmpp_base, "parse_list", lambda _v: ["bad jid", "ok@example.com"], raising=True, ) n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["ignored"], ) assert n.targets == [("chat", "ok@example.com")] assert "Dropped invalid XMPP target" in caplog.text @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_url_identifier_is_accessible() -> None: """url_identifier property should be accessible and stable.""" n = NotifyXMPP( host="example.com", user="me@example.com", password="secret" ) schema, host, xmpp_host, user, password, port = n.url_identifier assert schema == "xmpp" assert host == "example.com" assert xmpp_host == "" assert user == "me@example.com" assert password == "secret" assert port is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_parse_url() -> None: """?xmpp= is extracted into xmpp_host by parse_url().""" r = NotifyXMPP.parse_url( "xmpps://user@example.com/joe?xmpp=xmpp.example.com" ) assert r is not None assert r["xmpp_host"] == "xmpp.example.com" # host stays as the JID domain assert r["host"] == "example.com" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_constructor_and_url() -> None: """xmpp_host is stored and round-trips through url().""" n = NotifyXMPP( host="example.com", user="user", password="pass", xmpp_host="xmpp.example.com", ) # Stored correctly assert n.xmpp_host == "xmpp.example.com" # JIDs are built from host, not xmpp_host assert n.jid == "user@example.com" # url() emits xmpp= when it differs from host u = n.url(privacy=False) assert "xmpp=xmpp.example.com" in u assert "example.com" in u # url() omits xmpp= when xmpp_host matches host (redundant) n2 = NotifyXMPP( host="example.com", user="user", password="pass", xmpp_host="example.com", ) u2 = n2.url(privacy=False) assert "xmpp=" not in u2 # url() omits xmpp= when xmpp_host is empty n3 = NotifyXMPP( host="example.com", user="user", password="pass", ) u3 = n3.url(privacy=False) assert "xmpp=" not in u3 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_target_jids_use_host() -> None: """Target JIDs are built from host, not xmpp_host.""" n = NotifyXMPP( host="example.com", user="user", password="pass", targets=["joe"], xmpp_host="xmpp.example.com", ) # Target 'joe' gets host domain appended, not xmpp_host assert n.targets == [("chat", "joe@example.com")] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_url_identifier() -> None: """url_identifier carries xmpp_host so deduplication is xmpp_host-aware.""" n = NotifyXMPP( host="example.com", user="user", password="pass", xmpp_host="xmpp.example.com", ) _schema, host, xmpp_host, _user, _password, _port = n.url_identifier assert host == "example.com" assert xmpp_host == "xmpp.example.com" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_config_host( monkeypatch: pytest.MonkeyPatch, ) -> None: """send() passes xmpp_host (not host) to XMPPConfig when set.""" captured: dict = {} class _Adapter: def __init__(self, config: Any, **kwargs: Any) -> None: captured["config_host"] = config.host def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpps://user@example.com/joe?xmpp=xmpp.example.com") apobj.notify("hello") assert captured["config_host"] == "xmpp.example.com" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_server_override_config_host_defaults_to_host( monkeypatch: pytest.MonkeyPatch, ) -> None: """send() passes host to XMPPConfig when xmpp_host is not set.""" captured: dict = {} class _Adapter: def __init__(self, config: Any, **kwargs: Any) -> None: captured["config_host"] = config.host def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpps://user@example.com/joe") apobj.notify("hello") assert captured["config_host"] == "example.com" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_url_handling() -> None: """url() must not append a path when there are no targets.""" apobj = Apprise() assert apobj.add("xmpp://me:secret@example.com?verify=no") is True assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) assert "example.com/?" in u assert "mode=none" in u apobj = Apprise() assert apobj.add("xmpps://me:secret@example.com?mode=tls") is True assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) assert "mode=tls" in u # invalid Mode apobj = Apprise() assert apobj.add("xmpps://me:secret@example.com?mode=invalid") is False # Ambiguous secure=true (xmpps://) and mode = none apobj = Apprise() assert apobj.add("xmpps://me:secret@example.com?mode=none") is True assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) # starttls (upgraded from none - most secure path) assert "mode=starttls" in u # Ambiguous secure=False (xmpp://) and mode != none apobj = Apprise() assert apobj.add("xmpp://me:secret@example.com?mode=tls") is True assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) # most secure path prevails assert "mode=tls" in u apobj = Apprise() assert ( apobj.add("xmpp://me:secret@example.com?subject=yes&roster=yes") is True ) assert len(apobj) == 1 plugin = apobj[0] u = plugin.url(privacy=False) # most secure path prevails assert "mode=none" in u assert "roster=yes" in u assert "subject=yes" in u @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_parse_url_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: """parse_url() returns None when NotifyBase.parse_url() fails.""" monkeypatch.setattr( xmpp_base.NotifyBase, "parse_url", lambda *_args, **_kwargs: None, raising=True, ) assert NotifyXMPP.parse_url("xmpp://bad") is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_finalize_loop_when_already_closed_does_not_close_again() -> None: loop = asyncio.new_event_loop() loop.close() # If _finalize_loop tried to close again, this would still be harmless, # but this test specifically forces the "if not loop.is_closed()" branch # to be False for coverage. xmpp_adapter.SlixmppAdapter._finalize_loop(loop) assert loop.is_closed() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_apprise_notify_invokes_adapter( monkeypatch: pytest.MonkeyPatch, ) -> None: """Exercise the Apprise.notify() workflow while mocking network I/O.""" captured: dict[str, Any] = {} class _Adapter: def __init__( self, config: xmpp_adapter.XMPPConfig, targets: list[str], subject: str, body: str, timeout: float = 0.0, roster: bool = False, before_message: Any = None, logger: Optional[logging.Logger] = None, **kwargs: Any, ) -> None: captured["targets"] = targets captured["body"] = body captured["subject"] = subject captured["roster"] = roster def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpp://me:secret@example.com/a@example.com") assert ( apobj.notify("hello", title="subject", notify_type=NotifyType.INFO) is True ) assert captured["targets"] == [("chat", "a@example.com")] assert captured["subject"] == "" assert "subject\r\nhello" in captured["body"] apobj = Apprise() apobj.add("xmpp://me:secret@example.com/a@example.com?subject=yes") assert ( apobj.notify("hello", title="subject", notify_type=NotifyType.INFO) is True ) assert captured["targets"] == [("chat", "a@example.com")] assert captured["subject"] == "subject" assert "hello" in captured["body"] # --------------------------------------------------------------------------- # Adapter Tests # --------------------------------------------------------------------------- @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_ssl_context() -> None: ctx = xmpp_adapter.SlixmppAdapter._ssl_context(True) assert ctx.verify_mode != ssl.CERT_NONE ctx2 = xmpp_adapter.SlixmppAdapter._ssl_context(False) assert ctx2.verify_mode == ssl.CERT_NONE assert ctx2.check_hostname is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_cancel_pending_empty(monkeypatch: pytest.MonkeyPatch) -> None: loop = asyncio.new_event_loop() try: monkeypatch.setattr( asyncio, "all_tasks", lambda _: set(), raising=True ) xmpp_adapter.SlixmppAdapter._finalize_loop(loop) finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_cancel_pending_tasks(monkeypatch: pytest.MonkeyPatch) -> None: loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) async def _noop() -> None: await asyncio.sleep(0) t1 = loop.create_task(_noop()) t2 = loop.create_task(_noop()) monkeypatch.setattr( asyncio, "all_tasks", lambda _: {t1, t2}, raising=True ) xmpp_adapter.SlixmppAdapter._finalize_loop(loop) assert t1.cancelled() assert t2.cancelled() finally: # Avoid asyncio.get_event_loop() here to prevent DeprecationWarning on # newer Python versions. Setting to None is sufficient for isolation. with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_disabled(monkeypatch: pytest.MonkeyPatch) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=True, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=1, ) monkeypatch.setattr(a, "_enabled", False, raising=True) assert a.process() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_fail( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING) def bad_connect(self: FakeClientXMPP, **kw: Any) -> asyncio.Future[bool]: fut: asyncio.Future[bool] = self.loop.create_future() fut.set_result(False) return fut monkeypatch.setattr(FakeClientXMPP, "connect", bad_connect, raising=True) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=True, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1, ) assert a.process() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_success(monkeypatch: pytest.MonkeyPatch) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, ) assert a.process() is True # Test with roster=True a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, roster=True, ) assert a.process() is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_default_target(monkeypatch: pytest.MonkeyPatch) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=1.0, ) assert a.process() is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_exception( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: install_fake_slixmpp(monkeypatch) caplog.set_level(logging.DEBUG) def explode(self: FakeClientXMPP, **kw: Any) -> bool: raise RuntimeError("boom") monkeypatch.setattr(FakeClientXMPP, "connect", explode, raising=True) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=True, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1, ) assert a.process() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_before_message(monkeypatch: pytest.MonkeyPatch) -> None: """Ensure before_message callback is invoked for each target.""" install_fake_slixmpp(monkeypatch) called: list[int] = [] def before_message() -> None: called.append(1) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com"), ("chat", "b@example.com")], subject="s", body="b", timeout=1, before_message=before_message, ) assert a.process() is True assert len(called) == 2 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_failed_auth_disconnect( monkeypatch: pytest.MonkeyPatch, ) -> None: """Authentication failure path must still call disconnect().""" install_fake_slixmpp(monkeypatch) # Force the connect() path to trigger the failed_auth handler. def connect_failed_auth( self: FakeClientXMPP, **kw: Any ) -> asyncio.Future[bool]: loop = self.loop or asyncio.get_running_loop() if self.disconnected is None: self.disconnected = loop.create_future() fut: asyncio.Future[bool] = loop.create_future() fut.set_result(True) handler = self.handlers.get("failed_auth") if handler: loop.call_soon(handler) loop.call_soon(self.disconnect) return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_failed_auth, raising=True ) disconnected = {"called": False} def disconnect(self: FakeClientXMPP) -> None: disconnected["called"] = True if not self.disconnected.done(): self.disconnected.set_result(True) monkeypatch.setattr(FakeClientXMPP, "disconnect", disconnect, raising=True) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1, ) assert a.process() is False assert disconnected["called"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_normalize_jid() -> None: """Tests normalize_jid()""" assert NotifyXMPP.normalize_jid("user", "example.ca") == ( "user@example.ca", False, ) assert NotifyXMPP.normalize_jid("user/resource", "example.ca") == ( "user@example.ca/resource", False, ) assert NotifyXMPP.normalize_jid( "user/resource/extra/crap", "example.ca" ) == ("user@example.ca/resource", False) assert NotifyXMPP.normalize_jid("user@example.com/r1", "example.ca") == ( "user@example.com/r1", False, ) assert NotifyXMPP.normalize_jid( "#room@conference.example.ca", "example.ca" ) == ("room@conference.example.ca", True) with pytest.raises(ValueError): # Bad entry NotifyXMPP.normalize_jid("", "example.ca") @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_bridge_logging_safe_lock( monkeypatch: pytest.MonkeyPatch, ) -> None: """tests bridge logging on multiple attempts""" # Reset module globals monkeypatch.setattr(xmpp_adapter, "_LOG_BRIDGED", False, raising=True) # Acquire the lock so the worker blocks inside bridge_slixmpp_logging() xmpp_adapter._LOG_BRIDGE_LOCK.acquire() done = {"ok": False} def worker() -> None: xmpp_adapter.bridge_slixmpp_logging() done["ok"] = True t = threading.Thread(target=worker, daemon=True) t.start() # Give worker a moment to pass the outer if and block on the lock time.sleep(0.02) # Now flip the flag while the lock is still held; when released, worker # will acquire the lock and hit the *inner* early-return. monkeypatch.setattr(xmpp_adapter, "_LOG_BRIDGED", True, raising=True) xmpp_adapter._LOG_BRIDGE_LOCK.release() t.join(timeout=1.0) assert done["ok"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_multi_logging_bridge_handling( monkeypatch: pytest.MonkeyPatch, ) -> None: """Handles multiple logging attempts made by multiple xmpp instances""" # Reset module globals monkeypatch.setattr(xmpp_adapter, "_LOG_BRIDGED", False, raising=True) apprise_logger = logging.getLogger("apprise") slix_logger = logging.getLogger("slixmpp") # Preserve existing handlers old_apprise = list(apprise_logger.handlers) old_slix = list(slix_logger.handlers) try: handler = logging.NullHandler() # Arrange for slix to already have the handler apprise_logger.handlers = [handler] slix_logger.handlers = [handler] xmpp_adapter.bridge_slixmpp_logging() # Handler should not be duplicated assert slix_logger.handlers.count(handler) == 1 finally: apprise_logger.handlers = old_apprise slix_logger.handlers = old_slix @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_failure_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: """Tests edge cases for failures that require cleanup""" install_fake_slixmpp(monkeypatch) # Ensure adapter is enabled monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=True ) # Make new_event_loop fail before shared['loop'] is assigned def raise_new_event_loop() -> Any: raise RuntimeError("poof") monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", raise_new_event_loop, raising=True, ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) # Should return False assert a.process() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_timeout_cleanup_disconnect_exception_suppressed( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover timeout cleanup: loop_obj != None""" install_fake_slixmpp(monkeypatch) # Ensure adapter is enabled monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=True ) client_created = threading.Event() # Let us know when _Client() has been constructed (super().__init__ call) orig_init = FakeClientXMPP.__init__ def init_signal( self: FakeClientXMPP, jid: str, password: str, *args: Any, **kwargs: Any, ) -> None: orig_init(self, jid, password, *args, **kwargs) client_created.set() monkeypatch.setattr(FakeClientXMPP, "__init__", init_signal, raising=True) # Block connect so runner cannot complete before the timeout branch. def connect_blocks( self: FakeClientXMPP, **kw: Any ) -> asyncio.Future[bool]: fut: asyncio.Future[bool] = self.loop.create_future() # Never resolve. return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_blocks, raising=True ) # Make done.wait deterministic without touching real threading.Event used # by Thread internals _patch_threading( monkeypatch, done_event_cls=_make_fake_done_event_cls(client_created), ) # Capture and control loop.call_soon_threadsafe behaviour import asyncio as _real_asyncio orig_new_event_loop = _real_asyncio.new_event_loop calls: list[str] = [] def new_event_loop_wrapped() -> _real_asyncio.AbstractEventLoop: loop = orig_new_event_loop() orig_csts = loop.call_soon_threadsafe def csts(cb: Any, *a: Any) -> Any: # Identify which callback is being scheduled name = getattr(cb, "__name__", repr(cb)) calls.append(name) # Raise only for disconnect callback to hit try/except if name == "disconnect": raise RuntimeError("boom") return orig_csts(cb, *a) monkeypatch.setattr(loop, "call_soon_threadsafe", csts, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) assert a.process() is False # We should have attempted both disconnect and loop.stop scheduling assert "disconnect" in calls assert "stop" in calls @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_timeout_cleanup_no_client_stop_exception_suppressed( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover timeout cleanup: loop_obj != None""" install_fake_slixmpp(monkeypatch) monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=True ) loop_created = threading.Event() allow_client_create = threading.Event() # Block FakeClientXMPP.__init__ so shared['client'] never gets set before # timeout orig_init = FakeClientXMPP.__init__ def init_block( self: FakeClientXMPP, jid: str, password: str, *args: Any, **kwargs: Any, ) -> None: # Wait until main thread has already taken the timeout path allow_client_create.wait(timeout=1.0) orig_init(self, jid, password, *args, **kwargs) monkeypatch.setattr(FakeClientXMPP, "__init__", init_block, raising=True) # Deterministic done.wait: wait until loop exists, then force timeout _patch_threading( monkeypatch, done_event_cls=_make_fake_done_event_cls(loop_created), ) # Wrap new_event_loop so we can (a) signal loop exists and (b) make stop # raise import asyncio as _real_asyncio orig_new_event_loop = _real_asyncio.new_event_loop calls: list[str] = [] def new_event_loop_wrapped() -> _real_asyncio.AbstractEventLoop: loop = orig_new_event_loop() loop_created.set() def csts(cb: Any, *a: Any) -> Any: name = getattr(cb, "__name__", repr(cb)) calls.append(name) if name == "stop": raise RuntimeError("boom-stop") return None monkeypatch.setattr(loop, "call_soon_threadsafe", csts, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.NONE, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) try: assert a.process() is False # client_obj is None, so no disconnect scheduling attempt should occur assert "disconnect" not in calls # stop scheduling attempted and exception suppressed assert "stop" in calls finally: # Unblock the runner thread so it does not linger allow_client_create.set() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_timeout_cleanup_loop_none_skips_disconnect_and_stop( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Cover timeout clean-up branch where shared['loop'] is still None.""" install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING, logger="apprise.xmpp") # Ensure adapter is enabled monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", True, raising=True ) # Gate used to block runner before it can assign shared["loop"]. gate = threading.Event() # Raised by new_event_loop_blocking as soon as the worker enters it -- # before any loop object is created and before shared["loop"] is set. # This lets done.wait() synchronize on this checkpoint so we have a # deterministic guarantee that shared["loop"] is still None when the # main-thread timeout cleanup runs. worker_entered_gate = threading.Event() # Patch asyncio.new_event_loop to block until we allow it, so # shared["loop"] remains None when the timeout clean-up executes. import asyncio as _real_asyncio orig_new_event_loop = _real_asyncio.new_event_loop def new_event_loop_blocking() -> _real_asyncio.AbstractEventLoop: # Signal the main thread: we are inside new_event_loop_blocking and # shared["loop"] has not been set yet. worker_entered_gate.set() gate.wait(timeout=2.0) return orig_new_event_loop() monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_blocking, raising=True, ) # Make done.wait deterministic: wait until the worker has entered # new_event_loop_blocking (guaranteeing shared["loop"] is still None) # before forcing the timeout branch. _patch_threading( monkeypatch, done_event_cls=_make_fake_done_event_cls(worker_entered_gate), ) # Track whether any loop clean-up scheduling occurs. # It must NOT when loop_obj is None. called = {"disconnect": 0, "stop": 0} # Block connect so runner cannot complete before the timeout branch. def connect_blocks( self: FakeClientXMPP, **kw: Any ) -> asyncio.Future[bool]: fut: asyncio.Future[bool] = self.loop.create_future() # Never resolve. return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_blocks, raising=True ) # As an extra guard, if loop.call_soon_threadsafe were somehow reached, # we'd see it via this patch. Since loop_obj is None, it must not. def fake_disconnect(self: FakeClientXMPP) -> None: called["disconnect"] += 1 monkeypatch.setattr( FakeClientXMPP, "disconnect", fake_disconnect, raising=True ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5223, secure=xmpp_adapter.SecureXMPPMode.TLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) try: # done.wait blocks until the worker signals worker_entered_gate # (guaranteeing shared["loop"] is None), then returns False # immediately, so the timeout clean-up sees loop_obj=None. assert a.process() is False assert "XMPP send timed out" in caplog.text # No disconnect should have been scheduled because loop_obj was None. assert called["disconnect"] == 0 finally: # Let the runner proceed and exit gate.set() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_unsupported_secure_mode( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Cover unsupported secure mode path (ValueError inside runner).""" install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING, logger="apprise.xmpp") config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure="invalid", verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) assert a.process() is False assert "XMPP send failed" in caplog.text @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_connect_timeout( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Cover connect timeout branch (asyncio.TimeoutError).""" install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING, logger="apprise.xmpp") # Return a Future that never resolves. connect_future: dict[str, Any] = {"fut": None} def connect_never(self: FakeClientXMPP, **kw: Any) -> asyncio.Future[bool]: fut: asyncio.Future[bool] = self.loop.create_future() connect_future["fut"] = fut return fut monkeypatch.setattr(FakeClientXMPP, "connect", connect_never, raising=True) # Force wait_for() to raise TimeoutError immediately for this connect # future. real_wait_for = xmpp_adapter.asyncio.wait_for async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: if aw is connect_future["fut"]: raise asyncio.TimeoutError() return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) assert a.process() is False assert "XMPP connect timed out" in caplog.text @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_session_timeout( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Cover session timeout branch when waiting on client.disconnected.""" install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING, logger="apprise.xmpp") # Ensure disconnect does not complete the disconnected Future. def disconnect_no_complete(self: FakeClientXMPP) -> None: return None monkeypatch.setattr( FakeClientXMPP, "disconnect", disconnect_no_complete, raising=True ) # Capture the disconnected future once connect() creates it. It # does not exist yet at __init__ time (see FakeClientXMPP.__init__), # so it must be captured here instead. real_connect = FakeClientXMPP.connect disconnected_future: dict[str, Any] = {"fut": None} def connect_capture(self: FakeClientXMPP, **kwargs: Any) -> Any: fut = real_connect(self, **kwargs) disconnected_future["fut"] = self.disconnected return fut monkeypatch.setattr( FakeClientXMPP, "connect", connect_capture, raising=True ) # Force wait_for() to raise TimeoutError immediately, but only for # the disconnected future -- matching by identity rather than call # order keeps this deterministic even if session_start's own # concurrently-scheduled task (MUC join, etc.) happens to call # wait_for() of its own accord first. real_wait_for = xmpp_adapter.asyncio.wait_for def wait_for_patched(aw: Any, timeout: Optional[float] = None) -> Any: if aw is disconnected_future["fut"]: # If aw is a coroutine, close it to avoid warnings with contextlib.suppress(Exception): if hasattr(aw, "close"): aw.close() raise asyncio.TimeoutError() return real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, ) assert a.process() is False assert "XMPP session timed out" in caplog.text @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_slixmpp_versioning(monkeypatch: pytest.MonkeyPatch) -> None: """ Simple checks to verify versioning works correctly """ # Garbage in... garbage out assert xmpp_adapter.SlixmppAdapter.supported_version("garbage") is False # These versions are too old assert xmpp_adapter.SlixmppAdapter.supported_version("1.0.0") is False assert xmpp_adapter.SlixmppAdapter.supported_version("1.10.0") is False assert xmpp_adapter.SlixmppAdapter.supported_version("1.14.1") is False assert xmpp_adapter.SlixmppAdapter.supported_version("1.15.0") is False assert xmpp_adapter.SlixmppAdapter.supported_version("1.15.1") is False # Good version checks assert xmpp_adapter.SlixmppAdapter.supported_version("1.16.0") is True assert xmpp_adapter.SlixmppAdapter.supported_version("1.16") is True assert xmpp_adapter.SlixmppAdapter.supported_version("1.16.1") is True assert xmpp_adapter.SlixmppAdapter.supported_version("1.16.100") is True assert xmpp_adapter.SlixmppAdapter.supported_version("1.17.0") is True # This version is too old assert xmpp_adapter.SlixmppAdapter.supported_version("1") is False # This version is good assert xmpp_adapter.SlixmppAdapter.supported_version("3") is True assert xmpp_adapter.SlixmppAdapter.supported_version("3.2") is True monkeypatch.setattr( xmpp_adapter, "SLIXMPP_SUPPORT_AVAILABLE", False, raising=False ) monkeypatch.setattr( xmpp_adapter.SlixmppAdapter, "_enabled", False, raising=False, ) assert xmpp_adapter.SlixmppAdapter.supported_version("3.2") is False # --------------------------------------------------------------------------- # Keepalive Tests # --------------------------------------------------------------------------- @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_keepalive_url_and_parse() -> None: n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["a@example.com"], keepalive=True, ) u = n.url(privacy=False) assert "keepalive=yes" in u r = NotifyXMPP.parse_url( "xmpp://me:pass@example.com/a@example.com?keepalive=yes" ) assert r["keepalive"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_keepalive_reuses_adapter_instance( monkeypatch: pytest.MonkeyPatch, ) -> None: created = {"count": 0} calls = {"send_message": 0, "process": 0} class _Adapter: def __init__(self, *args: Any, **kwargs: Any) -> None: created["count"] += 1 self.keepalive = kwargs.get("keepalive", False) def process(self) -> bool: calls["process"] += 1 return True def send_message(self, **kwargs: Any) -> bool: calls["send_message"] += 1 return True def close(self) -> None: return None monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) # keepalive=yes should create adapter once, then reuse it n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["a@example.com"], keepalive=True, ) assert n.send("body1", title="t1") is True assert n.send("body2", title="t2") is True assert created["count"] == 1 assert calls["send_message"] == 2 assert calls["process"] == 0 # keepalive=no should not reuse adapter, it uses process() one-shot created["count"] = 0 calls["send_message"] = 0 calls["process"] = 0 n = NotifyXMPP( host="example.com", user="me@example.com", password="x", targets=["a@example.com"], keepalive=False, ) assert n.send("body", title="t") is True assert created["count"] == 1 assert calls["process"] == 1 assert calls["send_message"] == 0 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_false_calls_process( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.NONE, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, keepalive=False, ) called = {"process": 0} def process() -> bool: called["process"] += 1 return True monkeypatch.setattr(a, "process", process, raising=True) assert a.send_message(targets=["x"], subject="y", body="z") is True assert called["process"] == 1 assert a.targets == ["x"] assert a.subject == "y" assert a.body == "z" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_ensure_keepalive_worker_edge_cases( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) # keepalive False => False a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=False, ) assert a._ensure_keepalive_worker() is False # keepalive True but closing => False a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True, ) a._closing = True assert a._ensure_keepalive_worker() is False # keepalive True but disabled => False a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True, ) monkeypatch.setattr(a, "_enabled", False, raising=True) assert a._ensure_keepalive_worker() is False # thread already alive => True a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True, ) class _AliveThread: def is_alive(self) -> bool: return True a._thread = _AliveThread() assert a._ensure_keepalive_worker() is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_ensure_keepalive_worker_wait_timeout( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING, logger="apprise.xmpp") config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=0.1, keepalive=True, ) # Prevent starting a real loop thread monkeypatch.setattr(a, "_keepalive_runner", lambda: None, raising=True) # Force wait() to fail monkeypatch.setattr( a._loop_ready, "wait", lambda timeout=None: False, raising=True ) assert a._ensure_keepalive_worker() is False assert "keepalive worker failed to start" in caplog.text @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_early_returns( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True ) assert asyncio.run(a._connect_if_required()) is False loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop assert run_on_loop(loop, a._connect_if_required()) is False a._client = SimpleNamespace() assert run_on_loop(loop, a._connect_if_required()) is False a._connect_lock = asyncio.Lock() assert run_on_loop(loop, a._connect_if_required()) is False a._session_started = asyncio.Event() assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_already_connected( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a._session_started.set() called = {"connect": 0} class _Client: def connect(self, **kwargs: Any) -> Any: called["connect"] += 1 fut = loop.create_future() fut.set_result(True) return fut a._client = _Client() assert run_on_loop(loop, a._connect_if_required()) is True assert called["connect"] == 0 finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_connect_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() class _Client: def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() real_wait_for = xmpp_adapter.asyncio.wait_for def wait_for_patched(aw: Any, timeout: Optional[float] = None) -> Any: if aw is fut: raise asyncio.TimeoutError() return real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_connect_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() class _Client: def connect(self, **kwargs: Any) -> Any: raise RuntimeError("boom") a._client = _Client() assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_session_start_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() fut.set_result(True) class _Client: def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() # First wait_for succeeds (connect), second one raises # (session start wait) real_wait_for = xmpp_adapter.asyncio.wait_for calls = {"n": 0} async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: calls["n"] += 1 if calls["n"] == 2: # Prevent "coroutine Event.wait was never awaited" with contextlib.suppress(Exception): if hasattr(aw, "close"): aw.close() raise asyncio.TimeoutError() return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_default_target_and_send_error( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a._session_started.set() sent: list[str] = [] class _Client: def send_message(self, **kwargs: Any) -> None: sent.append(kwargs.get("mto")) a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr( a, "_connect_if_required", ok_connect, raising=True ) assert ( run_on_loop( loop, a._send_keepalive_async(targets=[], subject="s", body="b"), ) is True ) assert sent == ["me@example.com"] # Now force send_message to raise and ensure session_started clears def boom_send_message(self: _Client, **kwargs: Any) -> None: raise RuntimeError("boom-send") monkeypatch.setattr( _Client, "send_message", boom_send_message, raising=True ) assert a._session_started.is_set() is True assert ( run_on_loop( loop, a._send_keepalive_async(targets=[], subject="s", body="b"), ) is False ) assert a._session_started.is_set() is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_timeout_and_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) # Ensure worker exists without spinning a real thread monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) calls: list[str] = [] class _Loop: def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: calls.append(getattr(cb, "__name__", "cb")) cb(*args) a._loop = _Loop() cleared = {"n": 0} class _Evt: def clear(self) -> None: cleared["n"] += 1 a._session_started = _Evt() # Timeout path class _FutureTimeout: def result(self, timeout: Optional[float] = None) -> Any: raise xmpp_adapter.FuturesTimeoutError() def run_coroutine_threadsafe_timeout(coro: Any, loop: Any) -> Any: # Prevent "never awaited" warnings with contextlib.suppress(Exception): coro.close() return _FutureTimeout() monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_timeout, raising=True, ) assert a.send_message(targets=[], subject="s", body="b") is False assert cleared["n"] == 1 # Exception path class _FutureError: def result(self, timeout: Optional[float] = None) -> Any: raise RuntimeError("boom") monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_timeout, raising=True, ) assert a.send_message(targets=[], subject="s", body="b") is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_del_suppresses_close_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover SlixmppAdapter.__del__() exception suppression """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) def boom_close() -> None: raise RuntimeError("boom-close") monkeypatch.setattr(a, "close", boom_close, raising=True) # Ensure __del__ runs and does not raise del a gc.collect() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_close_shutdown_paths_and_state_reset( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover close(): _shutdown() branches and loop.call_soon_threadsafe exception suppression, plus state reset """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) class _Client: def disconnect(self) -> None: raise RuntimeError("disconnect boom") class _Loop: def __init__(self) -> None: self.called: list[str] = [] def stop(self) -> None: self.called.append("stop") raise RuntimeError("stop boom") def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: self.called.append("call_soon_threadsafe") # Execute immediately so _shutdown() runs in this test cb(*args) class _Thread: def join(self, timeout: Optional[float] = None) -> None: return None # Populate internals so close() takes the full path loop = _Loop() a._loop = loop a._client = _Client() a._thread = _Thread() # Also set asyncio primitives to ensure they are nulled out a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a.close() # State must be cleared assert a._thread is None assert a._loop is None assert a._client is None assert a._connect_lock is None assert a._session_started is None # Ensure call_soon_threadsafe ran (and stop attempted inside _shutdown) assert "call_soon_threadsafe" in loop.called assert "stop" in loop.called @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_close_call_soon_threadsafe_raises_still_resets( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover close(): loop.call_soon_threadsafe(_shutdown) raising and being suppressed, while still joining and clearing state. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) class _Loop: def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: raise RuntimeError("csts boom") def stop(self) -> None: return None class _Thread: def join(self, timeout: Optional[float] = None) -> None: return None a._loop = _Loop() a._client = SimpleNamespace(disconnect=lambda: None) a._thread = _Thread() a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a.close() assert a._thread is None assert a._loop is None assert a._client is None assert a._connect_lock is None assert a._session_started is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_ensure_keepalive_worker_returns_true_at_end( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Hit _ensure_keepalive_worker() bottom return True """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) # Make the runner set loop_ready quickly and exit, using a real thread. def runner() -> None: a._loop_ready.set() return None monkeypatch.setattr(a, "_keepalive_runner", runner, raising=True) assert a._ensure_keepalive_worker() is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_executes_and_register_plugin_suppressed( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Execute _keepalive_runner() including: - secure mode config - register_plugin() suppression - loop.run_forever() exit - finally: loop.stop/close """ install_fake_slixmpp(monkeypatch) # Force register_plugin to raise so the suppress(Exception) is exercised def boom_register_plugin( self: FakeClientXMPP, name: str, config: Optional[Any] = None ) -> None: raise RuntimeError("boom-plugin") monkeypatch.setattr( FakeClientXMPP, "register_plugin", boom_register_plugin, raising=True ) # Wrap new_event_loop so we can make run_forever return immediately, and # make stop/close callable. real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() # Exit immediately instead of blocking forever monkeypatch.setattr(loop, "run_forever", lambda: None, raising=True) # Ensure stop/close are callable (real loop has them already) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Run synchronously. Since run_forever() is patched to return, this # should exit quickly. a._keepalive_runner() # Worker should have published these before entering run_forever() assert a._loop is not None assert a._client is not None assert a._connect_lock is not None assert a._session_started is not None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_hits_bottom_return_true( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover _connect_if_required() success path reaching the final return True, not the early already-connected return. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() fut.set_result(True) class _Client: def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() # Patch wait_for so the second wait_for (session_started.wait()) sets # the event just in time, forcing the bottom return True. real_wait_for = xmpp_adapter.asyncio.wait_for calls = {"n": 0} async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: calls["n"] += 1 if calls["n"] == 2: assert a._session_started is not None a._session_started.set() return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) assert run_on_loop(loop, a._connect_if_required()) is True finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_connect_fail_branch( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover _send_keepalive_async(): if not ok: return False """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Must not be None, or it returns False earlier a._client = SimpleNamespace(send_message=lambda **kw: None) async def fail_connect() -> bool: return False monkeypatch.setattr(a, "_connect_if_required", fail_connect, raising=True) assert ( asyncio.run( a._send_keepalive_async(targets=["x"], subject="s", body="b") ) is False ) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_exception_when_session_started_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover _send_keepalive_async() exception path where _session_started is None so clear() is skipped """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) class _Client: def send_message(self, **kwargs: Any) -> None: raise RuntimeError("boom-send") a._client = _Client() # Key detail: explicitly set to None to take the "skip clear()" branch a._session_started = None assert ( asyncio.run( a._send_keepalive_async(targets=["x"], subject="s", body="b") ) is False ) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_close_shutdown_client_none_branch( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover close(): _shutdown() path where client is None. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) class _Loop: def __init__(self) -> None: self.stopped = False self.called = False def stop(self) -> None: self.stopped = True def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: self.called = True cb(*args) class _Thread: def join(self, timeout: Optional[float] = None) -> None: return None loop = _Loop() a._loop = loop a._thread = _Thread() # Key: ensure client is None so if client is not None is False a._client = None a.close() assert loop.called is True assert loop.stopped is True assert a._loop is None assert a._thread is None assert a._client is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_close_thread_alive_returns_without_clearing_state( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover close() branch where worker thread is still alive.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) class _Client: def disconnect(self) -> None: return None class _Loop: def __init__(self) -> None: self.called: list[str] = [] def stop(self) -> None: self.called.append("stop") def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: self.called.append("call_soon_threadsafe") cb(*args) class _Thread: def join(self, timeout: Optional[float] = None) -> None: return None def is_alive(self) -> bool: return True loop = _Loop() thread = _Thread() a._loop = loop a._client = _Client() a._thread = thread a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() a.close() assert a._loop is loop assert a._thread is thread assert loop.called[0] == "call_soon_threadsafe" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_returns_if_closing_before_publish( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _keepalive_runner() state-lock early return when closing.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) def _closing_register_plugin( self: FakeClientXMPP, name: str, config: Optional[Any] = None ) -> None: a._closing = True return None monkeypatch.setattr( FakeClientXMPP, "register_plugin", _closing_register_plugin, raising=True, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def _new_event_loop() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() monkeypatch.setattr( loop, "run_forever", lambda: (_ for _ in ()).throw(AssertionError("run_forever")), raising=True, ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", _new_event_loop, raising=True, ) a._loop_ready.clear() a._keepalive_runner() assert a._loop_ready.is_set() is False assert a._loop is None assert a._client is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_finally_clears_state_when_closing( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _keepalive_runner() finally state-clear when closing.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop created: list[asyncio.AbstractEventLoop] = [] def _new_event_loop() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() created.append(loop) def _run_forever() -> None: a._closing = True return None monkeypatch.setattr(loop, "run_forever", _run_forever, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", _new_event_loop, raising=True, ) a._loop_ready.clear() a._keepalive_runner() assert a._loop_ready.is_set() is True assert a._loop is None assert a._client is None assert a._connect_lock is None assert a._session_started is None assert created assert created[0].is_closed() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_session_start_sets_event_even_on_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover keepalive _Client._session_start() exception handling.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="ex.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, roster=True, timeout=5.0, ) loop = asyncio.new_event_loop() # Detach from policy initially asyncio.set_event_loop(None) try: def run_forever_hook() -> None: client = a._client started = a._session_started monkeypatch.setattr( client, "send_presence", lambda: (_ for _ in ()).throw(RuntimeError("fail")), raising=True, ) client._on_session_start() async def wait_for_event(): while not started.is_set(): await asyncio.sleep(0.01) # Assign to variable so we can explicitly close it if needed coro = wait_for_event() try: wf = asyncio.wait_for(coro, timeout=1.0) try: loop.run_until_complete(wf) finally: with contextlib.suppress(Exception): wf.close() finally: # Prevent "coroutine was never awaited" warnings with contextlib.suppress(Exception): coro.close() assert started.is_set() is True return None monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", lambda: loop, ) monkeypatch.setattr(loop, "run_forever", run_forever_hook) a._keepalive_runner() finally: if not loop.is_closed(): # Cancel all tasks, including those from internal asyncio # machinery for task in asyncio.all_tasks(loop): task.cancel() with contextlib.suppress(Exception): loop.run_until_complete(asyncio.sleep(0)) loop.close() # Clean up global policies asyncio.set_event_loop(None) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_roster_timeout_closes_awaitable( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover roster timeout close path in _session_start().""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) called: list[object] = [] orig_close = xmpp_adapter._close_awaitable def _spy_close(obj: object) -> None: called.append(obj) orig_close(obj) monkeypatch.setattr( xmpp_adapter, "_close_awaitable", _spy_close, raising=True ) async def _raise_timeout(*args: object, **kwargs: object) -> None: raise asyncio.TimeoutError() monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", _raise_timeout, raising=True ) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: evt = asyncio.Event() client = client_cls( jid="user@example.com", password="pass", oneshot=False, want_roster=True, roster_timeout=1.0, session_started_evt=evt, ) loop.run_until_complete(client._session_start()) assert len(called) == 1 finally: asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_keepalive_sets_ready_event( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover non-oneshot path in _session_start().""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: evt = asyncio.Event() client = client_cls( jid="user@example.com", password="pass", oneshot=False, targets=["a", "b"], subject="s", body="b", before_message=lambda: None, want_roster=False, roster_timeout=0.0, session_started_evt=evt, ) monkeypatch.setattr( client, "send_message", lambda **k: (_ for _ in ()).throw(AssertionError()), raising=True, ) monkeypatch.setattr( client, "disconnect", lambda: (_ for _ in ()).throw(AssertionError()), raising=True, ) loop.run_until_complete(client._session_start()) assert evt.is_set() is True finally: asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_oneshot_sends_and_disconnects( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover oneshot path in _session_start() and finally disconnect.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) sent: list[dict[str, Any]] = [] before: list[int] = [] disconnected: list[bool] = [] def _before() -> None: before.append(1) def _send_message(**kwargs: Any) -> None: sent.append(kwargs) def _disconnect() -> None: disconnected.append(True) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: client = client_cls( jid="user@example.com", password="pass", oneshot=True, targets=[("chat", "a@example.com"), ("chat", "b@example.com")], subject="sub", body="body", before_message=_before, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) monkeypatch.setattr( client, "send_message", _send_message, raising=True ) monkeypatch.setattr(client, "disconnect", _disconnect, raising=True) loop.run_until_complete(client._session_start()) assert len(before) == 2 assert len(sent) == 2 assert all(msg.get("mtype") == "chat" for msg in sent) assert disconnected == [True] finally: asyncio.set_event_loop(None) loop.close() def test_adapter_keepalive_runner_failed_handlers( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover keepalive _Client._failed_auth() and _disconnected(). """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, roster=False, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: client = a._client started = a._session_started assert client is not None assert started is not None disconnected = {"called": 0} def disconnect_spy() -> None: disconnected["called"] += 1 monkeypatch.setattr( client, "disconnect", disconnect_spy, raising=True ) # Mark started True, then failed_auth must clear + disconnect started.set() client._failed_auth() assert started.is_set() is False assert disconnected["called"] == 1 # Mark started True again, disconnected must clear, no disconnect # call started.set() client._disconnected() assert started.is_set() is False assert disconnected["called"] == 1 return None monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_unsupported_secure_mode_hits_valueerror( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover keepalive runner unsupported secure mode ValueError. The exception is caught internally, but the raise line is executed. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure="definitely-not-supported", verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Ensure clean initial state a._loop_ready.clear() # Patch run_forever to be safe if it ever reaches it (it should not) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() monkeypatch.setattr(loop, "run_forever", lambda: None, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() # Since secure mode invalid, it should not publish loop/client state assert a._loop_ready.is_set() is False assert a._loop is None assert a._client is None assert a._connect_lock is None assert a._session_started is None @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_session_start_try_finally_and_roster( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover _session_start() try/finally and roster handling: - try/finally always sets _session_started_evt - roster path is taken and roster errors are suppressed - send_presence exception still leads to finally:set() """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, roster=True, timeout=5.0, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: client = a._client evt = a._session_started assert client is not None assert evt is not None # Case 1: roster enabled, get_roster raises but suppressed def send_presence_ok() -> None: return None async def get_roster_boom() -> None: raise RuntimeError("boom-roster") monkeypatch.setattr( client, "send_presence", send_presence_ok, raising=True ) monkeypatch.setattr( client, "get_roster", get_roster_boom, raising=True ) evt.clear() client._on_session_start() # Drive the loop without creating extra coroutine objects for _ in range(10): fut = loop.create_future() loop.call_soon(fut.set_result, None) loop.run_until_complete(fut) if evt.is_set(): break xmpp_adapter.SlixmppAdapter._finalize_loop(loop) assert evt.is_set() is True # Case 2: send_presence raises, finally must still set def send_presence_boom() -> None: raise RuntimeError("boom-presence") monkeypatch.setattr( client, "send_presence", send_presence_boom, raising=True ) evt.clear() client._on_session_start() # Drive the loop without creating extra coroutine objects for _ in range(10): fut = loop.create_future() loop.call_soon(fut.set_result, None) loop.run_until_complete(fut) if evt.is_set(): break xmpp_adapter.SlixmppAdapter._finalize_loop(loop) assert evt.is_set() is True return None monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_plaintext_mode_skips_ssl_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover adapter.py - ensuring enable_plaintext=True, so 'if not client.enable_plaintext' is False and ssl_context is not set. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.NONE, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, timeout=5.0, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: # At this point the client has been created and configured. assert a._client is not None # In plaintext mode we expect ssl_context not to be forced by # adapter. assert getattr(a._client, "enable_plaintext", None) is True assert getattr(a._client, "ssl_context", None) is None return None monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_finally_loop_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover adapter.py when loop is None (new_event_loop fails), so both 'if loop is not None' conditions are False. """ install_fake_slixmpp(monkeypatch) monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", lambda: (_ for _ in ()).throw(RuntimeError("boom")), raising=True, ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Should not raise, finally must handle loop=None a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_runner_suppresses_cleanup_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: """Ensure event-loop cleanup errors do not escape the runner.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="ex.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, roster=True, timeout=5.0, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() # Force finally path with a raised exception from run_forever def run_forever_boom() -> None: raise RuntimeError("boom-forever") monkeypatch.setattr( loop, "run_forever", run_forever_boom, raising=True ) # Raise after real cleanup so the event loop does not leak resources. real_stop = loop.stop real_close = loop.close def stop_boom() -> None: real_stop() raise RuntimeError("boom-stop") def close_boom() -> None: real_close() raise RuntimeError("boom-close") monkeypatch.setattr(loop, "stop", stop_boom, raising=True) monkeypatch.setattr(loop, "close", close_boom, raising=True) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) # Should not raise, stop/close exceptions must be suppressed a._keepalive_runner() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_returns_false_when_client_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover adapter.py _client is None -> return False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, ) a._client = None loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) result = loop.run_until_complete( a._send_keepalive_async(["t"], "s", "b") ) assert result is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) # Safe shutdown of asyncgens if not loop.is_closed(): with contextlib.suppress(Exception): ag_coro = loop.shutdown_asyncgens() try: loop.run_until_complete(ag_coro) except Exception: # Reuse the helper from the adapter module if available, # or do a manual close if hasattr(ag_coro, "close"): ag_coro.close() # Ensure close() is called even if the block above had issues with contextlib.suppress(Exception): loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_exception_close_failure_suppressed( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[], subject="s", body="b", timeout=5.0, keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) class _Loop: def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: cb(*args) a._loop = _Loop() class _BadCoro: def close(self) -> None: raise RuntimeError("close-failed") monkeypatch.setattr( a, "_send_keepalive_async", lambda **kwargs: _BadCoro(), raising=True ) def run_coroutine_threadsafe_raises(coro: Any, loop: Any) -> Any: raise RuntimeError("schedule-failed") monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_raises, raising=True, ) assert a.send_message(targets=[], subject="s", body="b") is False def test_adapter_send_message_keepalive_false_none_params_does_not_override( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.NONE, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "orig@example.com")], subject="orig-subj", body="orig-body", keepalive=False, ) called = {"process": 0} def process() -> bool: called["process"] += 1 return True monkeypatch.setattr(a, "process", process, raising=True) assert a.send_message() is True assert called["process"] == 1 assert a.targets == [("chat", "orig@example.com")] assert a.subject == "orig-subj" assert a.body == "orig-body" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_worker_failure_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: install_fake_slixmpp(monkeypatch) config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: False, raising=True ) assert a.send_message() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_timeout_with_no_session_started_event( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING, logger="apprise.xmpp") config = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=config, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=5.0, keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) class _Loop: def call_soon_threadsafe(self, cb: Any, *args: Any) -> None: raise AssertionError( "should not be called when _session_started is None" ) a._loop = _Loop() a._session_started = None # key for branch coverage class _FutureTimeout: def result(self, timeout: Optional[float] = None) -> Any: raise xmpp_adapter.FuturesTimeoutError() def run_coroutine_threadsafe_timeout(coro: Any, loop: Any) -> Any: # Close coroutine to avoid RuntimeWarning with contextlib.suppress(Exception): coro.close() return _FutureTimeout() monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_timeout, raising=True, ) assert a.send_message() is False assert "XMPP keepalive send timed out" in caplog.text @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_keepalive_evt_none_no_set( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _session_start() when session event is None.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) client = client_cls( jid="user@example.com", password="pass", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) loop.run_until_complete(client._session_start()) finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_general_function( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: install_fake_slixmpp(monkeypatch) def _close(): raise Exception("boom") arg = {"close": _close} # does nothing xmpp_adapter._close_awaitable(None) xmpp_adapter._close_awaitable(arg) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_on_session_start_create_task_failure_closes_coro( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover loop.create_task() failure that closes the awaitable.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) client = client_cls( jid="user@example.com", password="pass", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=asyncio.Event(), ) class _FakeCoro: def __init__(self) -> None: self.closed = False def close(self) -> None: self.closed = True fake_coro = _FakeCoro() def _session_start(*args: object, **kwargs: object) -> _FakeCoro: return fake_coro monkeypatch.setattr( client, "_session_start", _session_start, raising=True ) class _FakeLoop: def is_running(self) -> bool: return True def create_task(self, coro: object) -> object: raise RuntimeError("boom") client.loop = _FakeLoop() # type: ignore[assignment] client._on_session_start() assert fake_coro.closed is True finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_disconnected_evt_none_no_clear( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _disconnected() branch where session event is None.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) client = client_cls( jid="user@example.com", password="pass", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) client._disconnected() finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_loop_none_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover send_message(): loop is None -> return False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "t@example.com")], subject="s", body="b", keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) a._loop = None assert a.send_message() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_message_keepalive_exception_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover send_message(): exception path returns False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "t@example.com")], subject="s", body="b", timeout=5.0, keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) class _Loop: pass a._loop = _Loop() def run_coroutine_threadsafe_boom(coro: Any, loop: Any) -> Any: with contextlib.suppress(Exception): coro.close() raise RuntimeError("boom") monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_boom, raising=True, ) assert a.send_message() is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_send_keepalive_async_auth_failed_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _send_keepalive_async(): auth_failed -> return False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True, ) class _Client: _auth_failed = True def send_message(self, **kwargs: Any) -> None: raise AssertionError("send must not occur") a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) assert ( asyncio.run(a._send_keepalive_async(["t@example.com"], "s", "b")) is False ) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_auth_failed_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _connect_if_required(): auth_failed -> return False.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() class _Client: _auth_failed = True a._client = _Client() assert run_on_loop(loop, a._connect_if_required()) is False finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_connect_ok_false_disconnects( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Cover _connect_if_required(): connect_ok False path.""" install_fake_slixmpp(monkeypatch) caplog.set_level(logging.WARNING, logger="apprise.xmpp") cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() disconnected = {"n": 0} class _Client: def connect(self, **kwargs: Any) -> Any: fut = loop.create_future() fut.set_result(False) return fut def disconnect(self) -> None: disconnected["n"] += 1 a._client = _Client() assert run_on_loop(loop, a._connect_if_required()) is False assert disconnected["n"] == 1 assert "XMPP connect failed" in caplog.text finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_connect_if_required_session_wait_exception_closes( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _connect_if_required(): session_wait Exception path.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() fut.set_result(True) class _Client: def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() called: list[object] = [] real_close = xmpp_adapter._close_awaitable def close_spy(obj: object) -> None: called.append(obj) real_close(obj) monkeypatch.setattr( xmpp_adapter, "_close_awaitable", close_spy, raising=True ) real_wait_for = xmpp_adapter.asyncio.wait_for calls = {"n": 0} async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: calls["n"] += 1 if calls["n"] == 2: raise RuntimeError("boom") return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) assert run_on_loop(loop, a._connect_if_required()) is False assert len(called) == 1 finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_on_session_start_add_done_callback_executes( monkeypatch: pytest.MonkeyPatch, ) -> None: """ Cover adapter.py _on_session_start() keepalive path where we create a task and attach a done callback that calls t.exception() when not cancelled. This test avoids patching asyncio.Task (immutable on newer Pythons) by using a fake loop and fake task object. """ install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) evt = asyncio.Event() client = client_cls( jid="me@example.com", password="x", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=evt, ) class _FakeTask: def __init__(self) -> None: self.exception_called = False self.callback_called = False def add_done_callback(self, cb: Any) -> None: self.callback_called = True cb(self) def cancelled(self) -> bool: return False def exception(self) -> None: self.exception_called = True return None class _FakeLoop: def __init__(self) -> None: self.task = _FakeTask() self.create_task_called = False def is_running(self) -> bool: return True def create_task(self, coro: Any) -> _FakeTask: self.create_task_called = True return self.task fake_loop = _FakeLoop() client.loop = fake_loop # type: ignore[assignment] class _FakeCoro: def close(self) -> None: return None def _session_start(*args: object, **kwargs: object) -> _FakeCoro: return _FakeCoro() monkeypatch.setattr( client, "_session_start", _session_start, raising=True ) client._on_session_start() assert fake_loop.create_task_called is True assert fake_loop.task.callback_called is True assert fake_loop.task.exception_called is True finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) with contextlib.suppress(Exception): loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_on_session_start_done_callback_cancelled_returns( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _log_task() early return when task is cancelled.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) evt = asyncio.Event() client = client_cls( jid="me@example.com", password="x", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=evt, ) errors: list[str] = [] class _Logger: def error(self, msg: str, *args: Any) -> None: errors.append(msg % args if args else msg) client.logger = _Logger() # type: ignore[assignment] class _FakeTask: def __init__(self) -> None: self.exception_called = False def add_done_callback(self, cb: Any) -> None: cb(self) def cancelled(self) -> bool: return True def exception(self) -> None: self.exception_called = True return RuntimeError("should-not-be-read") class _FakeLoop: def is_running(self) -> bool: return True def create_task(self, coro: Any) -> _FakeTask: return _FakeTask() client.loop = _FakeLoop() # type: ignore[assignment] class _FakeCoro: def close(self) -> None: return None monkeypatch.setattr( client, "_session_start", lambda *_a, **_k: _FakeCoro(), raising=True, ) client._on_session_start() assert errors == [] finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) with contextlib.suppress(Exception): loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_on_session_start_done_callback_logs_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _log_task() error log when task.exception() is not None.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) evt = asyncio.Event() client = client_cls( jid="me@example.com", password="x", oneshot=False, targets=[], subject="s", body="b", before_message=None, want_roster=False, roster_timeout=0.0, session_started_evt=evt, ) errors: list[str] = [] class _Logger: def error(self, msg: str, *args: Any) -> None: errors.append(msg % args if args else msg) client.logger = _Logger() # type: ignore[assignment] class _FakeTask: def add_done_callback(self, cb: Any) -> None: cb(self) def cancelled(self) -> bool: return False def exception(self) -> Exception: return RuntimeError("boom-task") class _FakeLoop: def is_running(self) -> bool: return True def create_task(self, coro: Any) -> _FakeTask: return _FakeTask() client.loop = _FakeLoop() # type: ignore[assignment] class _FakeCoro: def close(self) -> None: return None monkeypatch.setattr( client, "_session_start", lambda *_a, **_k: _FakeCoro(), raising=True, ) client._on_session_start() assert len(errors) == 1 assert "XMPP task failed" in errors[0] finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) with contextlib.suppress(Exception): loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_muc_url_parsing_and_reconstruction() -> None: """MUC targets parse from '#room@conf.host' and round-trip via url().""" n = NotifyXMPP( host="example.com", user="me", password="secret", targets=["#room@conference.example.com", "user@example.com"], ) # MUC target becomes groupchat, regular becomes chat assert ("groupchat", "room@conference.example.com") in n.targets assert ("chat", "user@example.com") in n.targets assert n.want_muc is True # url() encodes MUC '#' as %23 to avoid URL fragment misparse u = n.url() assert "%23room%40conference.example.com" in u # Verify round-trip: parse_url -> NotifyXMPP reconstructs same targets results = NotifyXMPP.parse_url(u) assert results is not None n2 = NotifyXMPP(**results) assert ("groupchat", "room@conference.example.com") in n2.targets assert ("chat", "user@example.com") in n2.targets assert n2.want_muc is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_name_parameter() -> None: """name= is stored, emitted in url(), and round-trips via parse_url().""" # Valid name: stored as-is and emitted in the URL n = NotifyXMPP( host="example.com", user="me", password="secret", name="my_bot", ) assert n.name == "my_bot" u = n.url() assert "name=my_bot" in u # Round-trip through parse_url results = NotifyXMPP.parse_url(u) assert results is not None assert results.get("name") == "my_bot" n2 = NotifyXMPP(**results) assert n2.name == "my_bot" # Invalid name falls back to user (then app_id if no user) n3 = NotifyXMPP( host="example.com", user="me", password="secret", name="bad name!", ) assert n3.name == n3.user # No name provided -> defaults to user (then app_id if no user); # not emitted in URL n4 = NotifyXMPP( host="example.com", user="me", password="secret", ) assert n4.name == n4.user assert "name=" not in n4.url() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_muc_only_no_regular_targets() -> None: """NotifyXMPP with only MUC targets sets want_muc=True.""" n = NotifyXMPP( host="example.com", user="me", password="secret", targets=["#general@conference.example.com"], ) assert n.want_muc is True assert len(n.targets) == 1 assert n.targets[0] == ("groupchat", "general@conference.example.com") @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_oneshot_muc_join( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover join_muc() in _session_start() for oneshot groupchat target.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) sent: list[dict[str, Any]] = [] join_calls: list[tuple[str, str]] = [] async def fake_join_muc(room: str, nick: str) -> None: join_calls.append((room, nick)) class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return fake_join_muc(room, nick) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: client = client_cls( jid="me@example.com", password="pass", oneshot=True, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", want_muc=True, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) # Inject fake xep_0045 plugin and boundjid (not on FakeClientXMPP) class _BoundJID: user = "me" monkeypatch.setattr(client, "boundjid", _BoundJID(), raising=False) monkeypatch.setattr( client, "plugin", {"xep_0045": FakeMucPlugin()}, raising=False ) monkeypatch.setattr( client, "send_message", lambda **kw: sent.append(kw), raising=True ) monkeypatch.setattr(client, "disconnect", lambda: None, raising=True) loop.run_until_complete(client._session_start()) assert len(join_calls) == 1 assert join_calls[0][0] == "room@conference.example.com" assert join_calls[0][1] == "me" assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" finally: asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_client_session_start_oneshot_muc_join_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover join_muc() error path: exception is caught, send still happens.""" install_fake_slixmpp(monkeypatch) client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) sent: list[dict[str, Any]] = [] async def join_muc_fail(room: str, nick: str) -> None: raise asyncio.TimeoutError() # simulate timeout class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return join_muc_fail(room, nick) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: client = client_cls( jid="me@example.com", password="pass", oneshot=True, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", want_muc=True, want_roster=False, roster_timeout=0.0, session_started_evt=None, ) class _BoundJID: user = "me" monkeypatch.setattr(client, "boundjid", _BoundJID(), raising=False) monkeypatch.setattr( client, "plugin", {"xep_0045": FakeMucPlugin()}, raising=False ) monkeypatch.setattr( client, "send_message", lambda **kw: sent.append(kw), raising=True ) monkeypatch.setattr(client, "disconnect", lambda: None, raising=True) loop.run_until_complete(client._session_start()) # Message is still sent even when MUC join errors assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" finally: asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_send_keepalive_async_muc_join( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover join_muc() in _send_keepalive_async() for groupchat target.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", keepalive=True, want_muc=True, ) join_calls: list[tuple[str, str]] = [] sent: list[dict[str, Any]] = [] async def fake_join_muc(room: str, nick: str) -> None: join_calls.append((room, nick)) class FakeBoundJID: user = "me" class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return fake_join_muc(room, nick) class _Client: _auth_failed = False boundjid = FakeBoundJID() plugin = {"xep_0045": FakeMucPlugin()} def send_message(self, **kwargs: Any) -> None: sent.append(kwargs) a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) result = asyncio.run( a._send_keepalive_async( targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", ) ) assert result is True assert len(join_calls) == 1 assert join_calls[0][0] == "room@conference.example.com" # boundjid.user == "me" -> nick must be taken from boundjid, not nickname assert join_calls[0][1] == "me" assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_send_keepalive_async_muc_join_nick_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: """When boundjid.user is empty, nick falls back to adapter nickname.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", keepalive=True, want_muc=True, default_nickname="mybot", ) join_calls: list[tuple[str, str]] = [] sent: list[dict[str, Any]] = [] async def fake_join_muc(room: str, nick: str) -> None: join_calls.append((room, nick)) class FakeBoundJID: user = "" # empty -> should fall back to nickname class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return fake_join_muc(room, nick) class _Client: _auth_failed = False boundjid = FakeBoundJID() plugin = {"xep_0045": FakeMucPlugin()} def send_message(self, **kwargs: Any) -> None: sent.append(kwargs) a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) result = asyncio.run( a._send_keepalive_async( targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", ) ) assert result is True assert len(join_calls) == 1 assert join_calls[0][0] == "room@conference.example.com" # boundjid.user is empty -> nick must fall back to adapter's nickname assert join_calls[0][1] == "mybot" assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_adapter_keepalive_runner_registers_xep_0045_when_want_muc( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover _keepalive_runner(): register_plugin('xep_0045') for want_muc.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", keepalive=True, want_muc=True, ) registered: list[str] = [] real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: client = a._client assert client is not None # Capture what was registered and then stop registered.extend(client.registered_plugins.keys()) monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() assert "xep_0045" in registered @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_send_keepalive_async_muc_join_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cover join_muc() exception path in _send_keepalive_async().""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", keepalive=True, want_muc=True, ) sent: list[dict[str, Any]] = [] async def join_muc_fail(room: str, nick: str) -> None: raise asyncio.TimeoutError() # simulate timeout -> exception path class FakeBoundJID: user = "me" class FakeMucPlugin: def join_muc(self, room: str, nick: str, **kw: Any) -> Any: return join_muc_fail(room, nick) class _Client: _auth_failed = False boundjid = FakeBoundJID() plugin = {"xep_0045": FakeMucPlugin()} def send_message(self, **kwargs: Any) -> None: sent.append(kwargs) a._client = _Client() async def ok_connect() -> bool: return True monkeypatch.setattr(a, "_connect_if_required", ok_connect, raising=True) result = asyncio.run( a._send_keepalive_async( targets=[("groupchat", "room@conference.example.com")], subject="s", body="b", ) ) # Exception from join_muc is caught; message is still sent assert result is True assert len(sent) == 1 assert sent[0]["mtype"] == "groupchat" @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_client_logger_presence_regression(monkeypatch): """ Regression test: Verify that the internal _Client class automatically receives a 'logger' attribute so _log_task does not AttributeError. """ install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="ex.com", port=5222 ) # Test standard adapter initialization adapter = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b" ) # Use the same factory the adapter uses to create a client client = xmpp_adapter._build_client( jid="me@example.com", password="x", oneshot=True, logger=adapter.logger ) # This was the point of failure: self.logger must exist assert hasattr(client, "logger") assert client.logger == adapter.logger @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_log_task_crash_regression(monkeypatch): """ Regression test: Verify that _log_task can handle an exception without crashing on a missing logger attribute. """ install_fake_slixmpp(monkeypatch) # Create a client using the production factory client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP) client = client_cls( jid="user@example.com", password="pass", oneshot=False, # Provide Logger logger=logging.getLogger("apprise"), ) # Simulate a task failure class MockTask: def cancelled(self): return False def exception(self): return RuntimeError("Simulated XMPP Failure") # Manually trigger the callback logic client.handlers.get("session_start") # Triggers _log_task client._on_session_start() # --------------------------------------------------------------------------- # SCRAM-PLUS / Channel Binding Tests # --------------------------------------------------------------------------- @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_default_true() -> None: """scramplus defaults to True (SCRAM-PLUS enabled).""" n = NotifyXMPP(host="example.com", user="me@example.com", password="x") assert n.scramplus is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_explicit_false() -> None: """scramplus=False is stored and reflected in url().""" n = NotifyXMPP( host="example.com", user="me@example.com", password="x", scramplus=False, ) assert n.scramplus is False u = n.url() assert "scramplus=no" in u @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_url_round_trip() -> None: """scramplus round-trips through url() and parse_url().""" n = NotifyXMPP( host="example.com", user="me@example.com", password="secret", scramplus=False, ) u = n.url() r = NotifyXMPP.parse_url(u) assert r is not None assert r["scramplus"] is False n2 = NotifyXMPP(**r) assert n2.scramplus is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_parse_url_no() -> None: """?scramplus=no is parsed and stored correctly.""" r = NotifyXMPP.parse_url( "xmpps://me:pass@example.com/a@example.com?scramplus=no" ) assert r is not None assert r["scramplus"] is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_parse_url_yes() -> None: """?scramplus=yes is parsed and stored correctly.""" r = NotifyXMPP.parse_url( "xmpps://me:pass@example.com/a@example.com?scramplus=yes" ) assert r is not None assert r["scramplus"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_passes_to_config( monkeypatch: pytest.MonkeyPatch, ) -> None: """scramplus=False propagates use_channel_binding=False to XMPPConfig.""" captured: dict = {} class _Adapter: def __init__(self, config: Any, **kwargs: Any) -> None: captured["use_channel_binding"] = config.use_channel_binding def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpps://user:pass@example.com/joe?scramplus=no") apobj.notify("hello") assert captured["use_channel_binding"] is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_true_passes_to_config( monkeypatch: pytest.MonkeyPatch, ) -> None: """Default scramplus=True propagates use_channel_binding=True.""" captured: dict = {} class _Adapter: def __init__(self, config: Any, **kwargs: Any) -> None: captured["use_channel_binding"] = config.use_channel_binding def process(self) -> bool: return True monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _Adapter, raising=True) apobj = Apprise() apobj.add("xmpps://user:pass@example.com/joe") apobj.notify("hello") assert captured["use_channel_binding"] is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_disable_channel_binding_filters_plus_mechs() -> None: """_disable_channel_binding patches _send_auth to strip -PLUS mechs.""" from types import SimpleNamespace captured: list[set] = [] plugin = SimpleNamespace( mech_list={"SCRAM-SHA-256-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-1"}, ) def orig_send_auth() -> None: captured.append(set(plugin.mech_list)) plugin._send_auth = orig_send_auth class _Client: def __getitem__(self, key: str) -> Any: if key == "feature_mechanisms": return plugin raise KeyError(key) xmpp_adapter._disable_channel_binding(_Client()) # Trigger the patched _send_auth plugin._send_auth() assert len(captured) == 1 assert "SCRAM-SHA-256-PLUS" not in captured[0] assert "SCRAM-SHA-256" in captured[0] assert "SCRAM-SHA-1" in captured[0] @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_disable_channel_binding_safe_when_no_plugin() -> None: """_disable_channel_binding no-ops when feature_mechanisms is absent.""" class _BadClient: def __getitem__(self, key: str) -> Any: raise KeyError(key) # Must not raise xmpp_adapter._disable_channel_binding(_BadClient()) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_disabled_process( monkeypatch: pytest.MonkeyPatch, ) -> None: """process() calls _disable_channel_binding when binding disabled.""" install_fake_slixmpp(monkeypatch) patched: list[bool] = [] orig_disable = xmpp_adapter._disable_channel_binding def spy_disable(client: Any) -> None: patched.append(True) orig_disable(client) monkeypatch.setattr( xmpp_adapter, "_disable_channel_binding", spy_disable, raising=True ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, use_channel_binding=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, ) assert a.process() is True assert len(patched) == 1 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_enabled_no_patch( monkeypatch: pytest.MonkeyPatch, ) -> None: """process() skips _disable_channel_binding when binding enabled.""" install_fake_slixmpp(monkeypatch) patched: list[bool] = [] orig_disable = xmpp_adapter._disable_channel_binding def spy_disable(client: Any) -> None: patched.append(True) orig_disable(client) monkeypatch.setattr( xmpp_adapter, "_disable_channel_binding", spy_disable, raising=True ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, use_channel_binding=True, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, ) assert a.process() is True # No patching when SCRAM-PLUS is enabled assert len(patched) == 0 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_scramplus_disabled_keepalive_runner( monkeypatch: pytest.MonkeyPatch, ) -> None: """_keepalive_runner calls _disable_channel_binding when disabled.""" install_fake_slixmpp(monkeypatch) patched: list[bool] = [] orig_disable = xmpp_adapter._disable_channel_binding def spy_disable(client: Any) -> None: patched.append(True) orig_disable(client) monkeypatch.setattr( xmpp_adapter, "_disable_channel_binding", spy_disable, raising=True ) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, use_channel_binding=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "a@example.com")], subject="s", body="b", timeout=1.0, keepalive=True, ) real_new_event_loop = xmpp_adapter.asyncio.new_event_loop def new_event_loop_wrapped() -> asyncio.AbstractEventLoop: loop = real_new_event_loop() def run_forever_hook() -> None: pass monkeypatch.setattr( loop, "run_forever", run_forever_hook, raising=True ) return loop monkeypatch.setattr( xmpp_adapter.asyncio, "new_event_loop", new_event_loop_wrapped, raising=True, ) a._keepalive_runner() assert len(patched) == 1 @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_failed_auth_stanza_sets_channel_binding_flag( monkeypatch: pytest.MonkeyPatch, ) -> None: """_failed_auth sets _channel_binding_failed when phrase detected.""" install_fake_slixmpp(monkeypatch) # Clear the subclass cache so we pick up the patched FakeClientXMPP. xmpp_adapter._CLIENT_SUBCLASS_CACHE.clear() # Build a _Client instance directly using the fake base class. _ClientCls = xmpp_adapter._get_client_subclass(FakeClientXMPP) client = _ClientCls( jid="me@example.com", password="x", oneshot=True, logger=logging.getLogger("test"), ) # Verify initial state. assert client._auth_failed is False assert client._channel_binding_failed is False # Simulate a failed_auth stanza containing the channel binding phrase. fake_stanza = ( "" "Invalid channel binding" ) client._failed_auth(fake_stanza) assert client._auth_failed is True assert client._channel_binding_failed is True @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_failed_auth_plain_does_not_set_channel_binding_flag( monkeypatch: pytest.MonkeyPatch, ) -> None: """_failed_auth skips _channel_binding_failed for plain auth failures.""" install_fake_slixmpp(monkeypatch) xmpp_adapter._CLIENT_SUBCLASS_CACHE.clear() _ClientCls = xmpp_adapter._get_client_subclass(FakeClientXMPP) client = _ClientCls( jid="me@example.com", password="x", oneshot=True, logger=logging.getLogger("test"), ) # Stanza does not mention channel binding. fake_stanza = "" client._failed_auth(fake_stanza) assert client._auth_failed is True assert client._channel_binding_failed is False @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_process_raises_channel_binding_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """process() raises XMPPChannelBindingError when channel binding fails.""" install_fake_slixmpp(monkeypatch) xmpp_adapter._CLIENT_SUBCLASS_CACHE.clear() # Patch FakeClientXMPP.connect to fire failed_auth with a channel # binding stanza so the adapter detects the failure. orig_connect = FakeClientXMPP.connect def connect_cb_fail(self, **kwargs: Any) -> asyncio.Future[bool]: loop = self.loop or asyncio.get_running_loop() if self.disconnected is None: self.disconnected = loop.create_future() fut: asyncio.Future[bool] = loop.create_future() fut.set_result(True) def _fire_failed_auth() -> None: # Simulate slixmpp firing failed_auth with a channel binding # stanza before setting up session_start. failed_handler = self.handlers.get("failed_auth") if failed_handler: fake_stanza = ( "" "Invalid channel binding" ) failed_handler(fake_stanza) # Complete disconnect so the adapter loop unblocks. if self.disconnected is not None and not self.disconnected.done(): self.disconnected.set_result(True) loop.call_soon(_fire_failed_auth) return fut monkeypatch.setattr(FakeClientXMPP, "connect", connect_cb_fail) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "joe@example.com")], subject="s", body="b", timeout=5.0, ) with pytest.raises(xmpp_adapter.XMPPChannelBindingError): a.process() monkeypatch.setattr(FakeClientXMPP, "connect", orig_connect) @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_send_catches_channel_binding_error_and_logs( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """base.py send() catches XMPPChannelBindingError and logs the hint.""" class _RaisingAdapter: def __init__(self, **kwargs: Any) -> None: pass def process(self) -> bool: raise xmpp_adapter.XMPPChannelBindingError() monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _RaisingAdapter) monkeypatch.setattr( xmpp_base, "SLIXMPP_SUPPORT_AVAILABLE", True, raising=False ) monkeypatch.setattr(xmpp_base.NotifyXMPP, "enabled", True, raising=False) n = NotifyXMPP(host="example.com", user="me", password="x") with caplog.at_level(logging.WARNING, logger="apprise"): result = n.send(body="hi") assert result is False assert "scramplus=no" in caplog.text @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_xmpp_send_channel_binding_error_scramplus_already_disabled( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """No ?scramplus=no hint when scramplus is already False.""" class _RaisingAdapter: def __init__(self, **kwargs: Any) -> None: pass def process(self) -> bool: raise xmpp_adapter.XMPPChannelBindingError() monkeypatch.setattr(xmpp_base, "SlixmppAdapter", _RaisingAdapter) monkeypatch.setattr( xmpp_base, "SLIXMPP_SUPPORT_AVAILABLE", True, raising=False ) monkeypatch.setattr(xmpp_base.NotifyXMPP, "enabled", True, raising=False) # scramplus explicitly disabled -- the hint must NOT repeat the advice n = NotifyXMPP( host="example.com", user="me", password="x", scramplus=False ) with caplog.at_level(logging.WARNING, logger="apprise"): result = n.send(body="hi") assert result is False # The "Add ?scramplus=no" suggestion must not appear when already set assert "Add ?scramplus=no" not in caplog.text # A compatibility-oriented message must appear instead assert "compatibility" in caplog.text @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_connect_if_required_channel_binding_early_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: """_connect_if_required() raises early when _channel_binding_failed.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop # Client with channel binding already flagged before connect. a._client = SimpleNamespace( _channel_binding_failed=True, _auth_failed=False ) a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() with pytest.raises(xmpp_adapter.XMPPChannelBindingError): run_on_loop(loop, a._connect_if_required()) finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_connect_if_required_channel_binding_bottom_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: """_connect_if_required() raises XMPPChannelBindingError check.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[], subject="s", body="b", keepalive=True ) loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) a._loop = loop a._connect_lock = asyncio.Lock() a._session_started = asyncio.Event() fut = loop.create_future() fut.set_result(True) class _Client: def __init__(self) -> None: self._channel_binding_failed = False self._auth_failed = False def connect(self, **kwargs: Any) -> Any: return fut a._client = _Client() # Patch wait_for to set channel-binding flag after session wait. real_wait_for = xmpp_adapter.asyncio.wait_for calls: dict[str, int] = {"n": 0} async def wait_for_patched( aw: Any, timeout: Optional[float] = None ) -> Any: calls["n"] += 1 if calls["n"] == 2: assert a._session_started is not None a._session_started.set() assert isinstance(a._client, _Client) a._client._channel_binding_failed = True return await real_wait_for(aw, timeout=timeout) monkeypatch.setattr( xmpp_adapter.asyncio, "wait_for", wait_for_patched, raising=True ) with pytest.raises(xmpp_adapter.XMPPChannelBindingError): run_on_loop(loop, a._connect_if_required()) finally: with contextlib.suppress(Exception): asyncio.set_event_loop(None) loop.close() @pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason="Requires slixmpp") def test_send_message_keepalive_reraises_channel_binding_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """send_message() keepalive path re-raises XMPPChannelBindingError.""" install_fake_slixmpp(monkeypatch) cfg = xmpp_adapter.XMPPConfig( jid="me@example.com", password="x", host="example.com", port=5222, secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False, ) a = xmpp_adapter.SlixmppAdapter( config=cfg, targets=[("chat", "t@example.com")], subject="s", body="b", timeout=5.0, keepalive=True, ) monkeypatch.setattr( a, "_ensure_keepalive_worker", lambda: True, raising=True ) class _Loop: pass a._loop = _Loop() def run_coroutine_threadsafe_cb_fail(coro: Any, loop: Any) -> Any: # Close the coroutine to avoid RuntimeWarning about it not being run. with contextlib.suppress(Exception): coro.close() class _Fut: def result(self, timeout: Optional[float] = None) -> bool: raise xmpp_adapter.XMPPChannelBindingError() return _Fut() monkeypatch.setattr( xmpp_adapter.asyncio, "run_coroutine_threadsafe", run_coroutine_threadsafe_cb_fail, raising=True, ) with pytest.raises(xmpp_adapter.XMPPChannelBindingError): a.send_message() caronc-apprise-182f859/tests/test_plugin_zoom.py000066400000000000000000000327201524161175200220350ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, NotifyType from apprise.plugins.zoom import ( ZOOM_MODE_DEFAULT, NotifyZoom, ZoomMode, ) logging.disable(logging.CRITICAL) # Reusable test credentials WEBHOOK_ID = "abcHook123" TOKEN = "xyzVerify456" # Our Testing URLs apprise_url_tests = ( # Missing everything ( "zoom://", { "instance": TypeError, }, ), # Empty user/host segment ( "zoom://:@/", { "instance": TypeError, }, ), # Missing token (webhook_id present but no path segment) ( "zoom://{}".format(WEBHOOK_ID), { "instance": TypeError, }, ), # Valid full-mode (default) ( "zoom://{}/{}".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "privacy_url": "zoom://a...3/x...6/", }, ), # Explicit mode=full ( "zoom://{}/{}?mode=full".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "privacy_url": "zoom://a...3/x...6/", }, ), # Explicit mode=simple ( "zoom://{}/{}?mode=simple".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "privacy_url": "zoom://a...3/x...6/", }, ), # Invalid mode ( "zoom://{}/{}?mode=invalid".format(WEBHOOK_ID, TOKEN), { "instance": TypeError, }, ), # Native URL with ?token= query param ( "https://inbots.zoom.us/incoming/hook/{}?token={}".format( WEBHOOK_ID, TOKEN ), { "instance": NotifyZoom, }, ), # Full mode: HTTP 500 ( "zoom://{}/{}".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), # Full mode: unusual status code ( "zoom://{}/{}".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "response": False, "requests_response_code": 999, }, ), # Full mode: request exception ( "zoom://{}/{}".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "test_requests_exceptions": True, }, ), # Simple mode: HTTP 500 ( "zoom://{}/{}?mode=simple".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "response": False, "requests_response_code": (requests.codes.internal_server_error), }, ), # Simple mode: unusual status code ( "zoom://{}/{}?mode=simple".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "response": False, "requests_response_code": 999, }, ), # Simple mode: request exception ( "zoom://{}/{}?mode=simple".format(WEBHOOK_ID, TOKEN), { "instance": NotifyZoom, "test_requests_exceptions": True, }, ), ) def test_plugin_zoom_urls(): """NotifyZoom() Apprise URLs.""" AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_zoom_init(): """Test NotifyZoom initialization and validation.""" # Missing webhook_id with pytest.raises(TypeError): NotifyZoom(webhook_id=None, token=TOKEN) # Empty webhook_id with pytest.raises(TypeError): NotifyZoom(webhook_id="", token=TOKEN) # Webhook ID with invalid characters (slash) with pytest.raises(TypeError): NotifyZoom(webhook_id="bad/id", token=TOKEN) # Missing token with pytest.raises(TypeError): NotifyZoom(webhook_id=WEBHOOK_ID, token=None) # Empty token with pytest.raises(TypeError): NotifyZoom(webhook_id=WEBHOOK_ID, token="") # Invalid mode with pytest.raises(TypeError): NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="bogus") # Empty string mode is also invalid (must not silently resolve to simple) with pytest.raises(TypeError): NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="") # Whitespace-only mode is also invalid with pytest.raises(TypeError): NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode=" ") # Valid: default mode obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN) assert obj.mode == ZOOM_MODE_DEFAULT # Valid: explicit full mode obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="full") assert obj.mode == ZoomMode.FULL # Valid: explicit simple mode obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="simple") assert obj.mode == ZoomMode.SIMPLE # Partial prefix matching: "sim" -> "simple" obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="sim") assert obj.mode == ZoomMode.SIMPLE # Partial prefix matching: "f" -> "full" obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="f") assert obj.mode == ZoomMode.FULL def test_plugin_zoom_url_round_trip(): """Verify that url() -> parse_url() -> __init__ is lossless.""" # Full mode round-trip obj1 = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="full") result = NotifyZoom.parse_url(obj1.url()) obj2 = NotifyZoom(**result) assert obj1.url_identifier == obj2.url_identifier assert obj1.mode == obj2.mode # Simple mode round-trip obj1 = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="simple") result = NotifyZoom.parse_url(obj1.url()) obj2 = NotifyZoom(**result) assert obj1.url_identifier == obj2.url_identifier assert obj1.mode == obj2.mode def test_plugin_zoom_url_privacy(): """Verify url(privacy=True) masks webhook_id and token.""" obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN) priv = obj.url(privacy=True) assert WEBHOOK_ID not in priv assert TOKEN not in priv # Schema is still present assert priv.startswith("zoom://") def test_plugin_zoom_url_identifier(): """Verify url_identifier contains webhook_id, token, and mode.""" obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN) uid = obj.url_identifier assert "zoom" in uid assert WEBHOOK_ID in uid assert TOKEN in uid # Two instances with different tokens are distinct obj2 = NotifyZoom(webhook_id=WEBHOOK_ID, token="other") assert obj.url_identifier != obj2.url_identifier # Same credentials but different modes produce different identifiers obj_full = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="full") obj_simple = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="simple") assert obj_full.url_identifier != obj_simple.url_identifier # mode is the 4th element (index 3) of the identifier tuple assert obj_full.url_identifier[3] == ZoomMode.FULL assert obj_simple.url_identifier[3] == ZoomMode.SIMPLE @mock.patch("requests.post") def test_plugin_zoom_full_send(mock_post): """Test _send_full() success and failure paths.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() # Success: with title obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN) assert obj.notify( body="body text", title="My Title", notify_type=NotifyType.INFO, ) assert mock_post.call_count == 1 # Verify ?format=full in URL call_url = mock_post.call_args[0][0] assert "format=full" in call_url mock_post.reset_mock() # Success: without title (no head section) assert obj.notify( body="body only", title="", notify_type=NotifyType.INFO, ) assert mock_post.call_count == 1 mock_post.reset_mock() # HTTP 204 is also a success mock_post.return_value = _mk_resp(requests.codes.no_content) assert obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # HTTP 500 -> failure mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert not obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # Unknown status code -> failure mock_post.return_value = _mk_resp(999) assert not obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # RequestException -> failure mock_post.side_effect = requests.RequestException("boom") assert not obj.notify(body="test", notify_type=NotifyType.INFO) @mock.patch("requests.post") def test_plugin_zoom_simple_send(mock_post): """Test _send_simple() success and failure paths.""" def _mk_resp(code=requests.codes.ok): r = mock.Mock() r.status_code = code r.content = b"" return r mock_post.return_value = _mk_resp() # Create a simple-mode instance obj = NotifyZoom(webhook_id=WEBHOOK_ID, token=TOKEN, mode="simple") # Success: with title (title prepended to body) assert obj.notify( body="msg body", title="Alert", notify_type=NotifyType.INFO, ) assert mock_post.call_count == 1 # Verify no ?format= in simple mode URL call_url = mock_post.call_args[0][0] assert "format" not in call_url mock_post.reset_mock() # Success: without title assert obj.notify( body="no title", title="", notify_type=NotifyType.INFO, ) mock_post.reset_mock() # HTTP 204 is also a success mock_post.return_value = _mk_resp(requests.codes.no_content) assert obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # HTTP 500 -> failure mock_post.return_value = _mk_resp(requests.codes.internal_server_error) assert not obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # Unknown status code -> failure mock_post.return_value = _mk_resp(999) assert not obj.notify(body="test", notify_type=NotifyType.INFO) mock_post.reset_mock() # RequestException -> failure mock_post.side_effect = requests.RequestException("err") assert not obj.notify(body="test", notify_type=NotifyType.INFO) def test_plugin_zoom_parse_url(): """Exercise parse_url() edge cases.""" # Basic: webhook_id in host, token in path result = NotifyZoom.parse_url("zoom://myhookid/mytoken/") assert result["webhook_id"] == "myhookid" assert result["token"] == "mytoken" # ?token= override result = NotifyZoom.parse_url("zoom://myhookid/?token=override123") assert result["webhook_id"] == "myhookid" assert result["token"] == "override123" # ?mode= parameter result = NotifyZoom.parse_url("zoom://myhookid/mytoken/?mode=simple") assert result["mode"] == "simple" # A URL that fails base parsing returns None result = NotifyZoom.parse_url(None) assert result is None def test_plugin_zoom_parse_native_url(): """Exercise parse_native_url().""" # Native URL with ?token= appended result = NotifyZoom.parse_native_url( "https://inbots.zoom.us/incoming/hook/{}?token={}".format( WEBHOOK_ID, TOKEN ) ) assert result is not None assert result["webhook_id"] == WEBHOOK_ID assert result["token"] == TOKEN # Native URL without token -> parse succeeds but # token will be None/missing (instantiation would fail) result = NotifyZoom.parse_native_url( "https://inbots.zoom.us/incoming/hook/{}".format(WEBHOOK_ID) ) assert result is not None assert result["webhook_id"] == WEBHOOK_ID # Non-matching URL -> None assert ( NotifyZoom.parse_native_url("https://example.com/not/a/zoom/url") is None ) @mock.patch("requests.post") def test_plugin_zoom_apprise_integration(mock_post): """Test end-to-end via Apprise.add() and Apprise.notify().""" r = mock.Mock() r.status_code = requests.codes.ok r.content = b"" mock_post.return_value = r app = Apprise() assert app.add("zoom://{}/{}".format(WEBHOOK_ID, TOKEN)) assert app.notify(title="Hello", body="World") assert mock_post.call_count == 1 caronc-apprise-182f859/tests/test_plugin_zulip.py000066400000000000000000000124411524161175200222120ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging from helpers import AppriseURLTester import pytest import requests from apprise.plugins.zulip import NotifyZulip logging.disable(logging.CRITICAL) # Our Testing URLs apprise_url_tests = ( ( "zulip://", { "instance": TypeError, }, ), ( "zulip://:@/", { "instance": TypeError, }, ), ( "zulip://apprise", { # Just org provided (no token or botname) "instance": TypeError, }, ), ( "zulip://botname@apprise", { # Just org and botname provided (no token) "instance": TypeError, }, ), # invalid token ( "zulip://botname@apprise/{}".format("a" * 24), { "instance": TypeError, }, ), # invalid botname ( "zulip://....@apprise/{}".format("a" * 32), { "instance": TypeError, }, ), # Valid everything - botname with a dash ( "zulip://bot-name@apprise/{}".format("a" * 32), { "instance": NotifyZulip, "privacy_url": "zulip://bot-name@apprise/a...a/", }, ), # Valid everything - no target so default is used ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # Our expected url(privacy=True) startswith() response: "privacy_url": "zulip://botname@apprise/a...a/", }, ), # Valid everything - organization as hostname ( "zulip://botname@apprise.zulipchat.com/{}".format("a" * 32), { "instance": NotifyZulip, }, ), # Valid everything - 2 streams specified ( "zulip://botname@apprise/{}/channel1/channel2".format("a" * 32), { "instance": NotifyZulip, }, ), # Valid everything - 2 streams specified (using to=) ( "zulip://botname@apprise/{}/?to=channel1/channel2".format("a" * 32), { "instance": NotifyZulip, }, ), # Test token= ( "zulip://botname@apprise/?token={}&to=channel1".format("a" * 32), { "instance": NotifyZulip, }, ), # Valid everything - 2 emails specified ( "zulip://botname@apprise/{}/user@example.com/user2@example.com".format( "a" * 32 ), { "instance": NotifyZulip, }, ), ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # don't include an image by default "include_image": False, }, ), ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # throw a bizarre code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "zulip://botname@apprise/{}".format("a" * 32), { "instance": NotifyZulip, # Throws a series of i/o exceptions with this flag # is set and tests that we gracefully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_zulip_urls(): """NotifyZulip() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() def test_plugin_zulip_edge_cases(): """NotifyZulip() Edge Cases.""" # must be 32 characters long token = "a" * 32 # Invalid organization with pytest.raises(TypeError): NotifyZulip(botname="test", organization="#", token=token) caronc-apprise-182f859/tests/test_retry_wait.py000066400000000000000000002175631524161175200216760ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import asyncio import logging from unittest import mock import pytest from apprise import Apprise, AppriseAsset from apprise.common import ( APPRISE_MAX_SERVICE_RETRY, APPRISE_MAX_SERVICE_WAIT, MATCH_ALL_TAG, ) from apprise.config.base import ConfigBase from apprise.manager_plugins import NotificationManager from apprise.plugins import NotifyBase from apprise.tag import AppriseTag logging.disable(logging.CRITICAL) N_MGR = NotificationManager() class _TestNotify(NotifyBase): """Test notification plugin -- controlled pass/fail via side_effect.""" app_id = "TestApp" app_desc = "Test" notify_url = "test://" title_maxlen = 250 body_maxlen = 32768 # Override so NotifyBase doesn't reject the URL def url(self, privacy=False, *args, **kwargs): return "test://localhost" def send(self, **kwargs): return True @staticmethod def parse_url(url): results = NotifyBase.parse_url(url, verify_host=False) return results class TestWaitValidation: """Tests for the wait= parameter accepted by NotifyBase.""" def _make(self, **kwargs): return _TestNotify(host="localhost", **kwargs) @pytest.mark.parametrize( "value,expected", [ (0, 0.0), (0.0, 0.0), (0.5, 0.5), (1, 1.0), (1.0, 1.0), ("0.5", 0.5), ("1", 1.0), ("1.4", 1.4), ("20", 20.0), (APPRISE_MAX_SERVICE_WAIT, APPRISE_MAX_SERVICE_WAIT), ], ) def test_valid_wait(self, value, expected): nb = self._make(wait=value) assert nb.wait == pytest.approx(expected) def test_wait_clamped_to_max(self): # Values above the cap are clamped, not rejected nb = self._make(wait=APPRISE_MAX_SERVICE_WAIT + 9999) assert nb.wait == pytest.approx(APPRISE_MAX_SERVICE_WAIT) @pytest.mark.parametrize( "bad_value", [ "-3", "-0.1", "1.3.3.", "abc", "inf", "-inf", "nan", None, # None means "use asset default" -- tested separately ], ) def test_invalid_wait_uses_asset_default(self, bad_value): if bad_value is None: # Not passing wait= at all should use asset default asset = AppriseAsset(default_service_wait=1.25) nb = _TestNotify(host="localhost", asset=asset) assert nb.wait == pytest.approx(1.25) return asset = AppriseAsset(default_service_wait=0.75) nb = self._make(wait=bad_value, asset=asset) # Falls back to asset default on invalid input assert nb.wait == pytest.approx(0.75) def test_negative_wait_falls_back(self): asset = AppriseAsset(default_service_wait=2.0) nb = self._make(wait=-1.0, asset=asset) assert nb.wait == pytest.approx(2.0) def test_asset_default_zero(self): asset = AppriseAsset(default_service_wait=0.0) nb = _TestNotify(host="localhost", asset=asset) assert nb.wait == pytest.approx(0.0) def test_asset_default_clamped_to_max(self): asset = AppriseAsset(default_service_wait=APPRISE_MAX_SERVICE_WAIT + 1) nb = _TestNotify(host="localhost", asset=asset) assert nb.wait == pytest.approx(APPRISE_MAX_SERVICE_WAIT) class TestRetryValidation: """Sanity checks for the retry= parameter.""" def _make(self, **kwargs): return _TestNotify(host="localhost", **kwargs) @pytest.mark.parametrize( "value,expected", [ (0, 0), (1, 1), ("3", 3), (APPRISE_MAX_SERVICE_RETRY, APPRISE_MAX_SERVICE_RETRY), ], ) def test_valid_retry(self, value, expected): nb = self._make(retry=value) assert nb.retry == expected def test_retry_clamped_to_max(self): nb = self._make(retry=APPRISE_MAX_SERVICE_RETRY + 999) assert nb.retry == APPRISE_MAX_SERVICE_RETRY @pytest.mark.parametrize("bad", ["-1", "abc", "1.3.3."]) def test_invalid_retry_uses_asset_default(self, bad): asset = AppriseAsset(default_service_retry=2) nb = self._make(retry=bad, asset=asset) assert nb.retry == 2 class TestUrlRoundTrip: def test_wait_in_url_parameters(self): nb = _TestNotify(host="localhost", wait=1.5, retry=2) params = nb.url_parameters() assert "wait" in params assert params["wait"] == "1.5" assert "retry" in params assert params["retry"] == "2" def test_wait_zero_always_in_url_parameters(self): """retry= and wait= are always present, even at their zero defaults.""" nb = _TestNotify(host="localhost", wait=0.0, retry=0) params = nb.url_parameters() assert "wait" in params assert params["wait"] == "0.0" assert "retry" in params assert params["retry"] == "0" def test_parse_url_extracts_wait(self): results = _TestNotify.parse_url("test://localhost?wait=2.5&retry=3") assert results is not None assert results.get("wait") == "2.5" assert results.get("retry") == "3" def test_parse_url_no_wait_key_absent(self): results = _TestNotify.parse_url("test://localhost") assert results is not None assert "wait" not in results class _FailThenSucceedNotify(NotifyBase): """Fails the first N calls, then succeeds.""" app_id = "FailThenSucceed" app_desc = "Test" notify_url = "failpass://" title_maxlen = 250 body_maxlen = 32768 def __init__(self, fail_times=1, **kwargs): super().__init__(**kwargs) self._fail_times = fail_times self._calls = 0 def url(self, *args, **kwargs): return "failpass://localhost" def send(self, **kwargs): self._calls += 1 return not self._calls <= self._fail_times async def async_notify(self, **kwargs): return self.send(**kwargs) @staticmethod def parse_url(url): return NotifyBase.parse_url(url, verify_host=False) class TestSequentialSleep: """time.sleep is called between retries in sequential dispatch.""" def test_sleep_called_on_retry(self): N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) server = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.3, ) a.add(server) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert result is True # Failed once -> sleep once before retry mock_sleep.assert_called_once_with(pytest.approx(0.3)) finally: N_MGR.unload_modules() def test_sleep_not_called_when_wait_zero(self): N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) server = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.0, ) a.add(server) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert result is True mock_sleep.assert_not_called() finally: N_MGR.unload_modules() def test_sleep_not_called_when_no_retry(self): N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) # retry=0 means only one attempt; with wait=1.0 sleep should # never fire since there are no retries server = _FailThenSucceedNotify( host="localhost", asset=asset, retry=0, wait=1.0, fail_times=0, # always succeeds ) a.add(server) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert result is True mock_sleep.assert_not_called() finally: N_MGR.unload_modules() def test_sleep_called_multiple_times(self): """Fails twice, succeeds on third attempt -> sleep called twice.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) server = _FailThenSucceedNotify( host="localhost", asset=asset, retry=3, wait=0.5, fail_times=2, ) a.add(server) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert result is True assert mock_sleep.call_count == 2 for call in mock_sleep.call_args_list: assert call == mock.call(pytest.approx(0.5)) finally: N_MGR.unload_modules() class TestThreadPoolSleep: """time.sleep is called between retries in threadpool dispatch.""" def test_sleep_called_in_threadpool(self): N_MGR["failpass"] = _FailThenSucceedNotify try: # async_mode=True -> uses threadpool when > 1 server; # but with a single server it falls through to sequential. # Use two servers so we actually hit the threadpool path. asset = AppriseAsset(async_mode=True) # Two separate server objects so a threadpool is used s1 = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.2, fail_times=1, ) s2 = _FailThenSucceedNotify( host="localhost2", asset=asset, retry=0, wait=0.0, fail_times=0, ) a = Apprise(asset=asset) a.add(s1) a.add(s2) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert result is True # s1 fails once -> sleep once mock_sleep.assert_called_once_with(pytest.approx(0.2)) finally: N_MGR.unload_modules() def test_sleep_not_called_in_threadpool_when_wait_zero(self): N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) s1 = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.0, fail_times=1, ) s2 = _FailThenSucceedNotify( host="localhost2", asset=asset, retry=0, wait=0.0, fail_times=0, ) a = Apprise(asset=asset) a.add(s1) a.add(s2) with mock.patch("apprise.apprise.time.sleep") as mock_sleep: result = a.notify(body="test") assert result is True mock_sleep.assert_not_called() finally: N_MGR.unload_modules() class TestAsyncioSleep: """asyncio.sleep is called between retries in async dispatch.""" def test_asyncio_sleep_called(self): N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() server = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.4, fail_times=1, ) a = Apprise(asset=asset) a.add(server) async def run(): with mock.patch("apprise.apprise.asyncio.sleep") as mock_sleep: mock_sleep.return_value = None result = await a.async_notify(body="test") return result, mock_sleep result, mock_sleep = asyncio.run(run()) assert result is True mock_sleep.assert_called_once_with(pytest.approx(0.4)) finally: N_MGR.unload_modules() def test_asyncio_sleep_not_called_when_wait_zero(self): N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() server = _FailThenSucceedNotify( host="localhost", asset=asset, retry=2, wait=0.0, fail_times=1, ) a = Apprise(asset=asset) a.add(server) async def run(): with mock.patch("apprise.apprise.asyncio.sleep") as mock_sleep: mock_sleep.return_value = None result = await a.async_notify(body="test") return result, mock_sleep result, mock_sleep = asyncio.run(run()) assert result is True mock_sleep.assert_not_called() finally: N_MGR.unload_modules() class TestAssetDefault: def test_asset_wait_default_used(self): asset = AppriseAsset(default_service_wait=1.5) nb = _TestNotify(host="localhost", asset=asset) assert nb.wait == pytest.approx(1.5) def test_explicit_wait_overrides_asset(self): asset = AppriseAsset(default_service_wait=1.5) nb = _TestNotify(host="localhost", asset=asset, wait=0.2) assert nb.wait == pytest.approx(0.2) def test_asset_retry_default_used(self): asset = AppriseAsset(default_service_retry=3) nb = _TestNotify(host="localhost", asset=asset) assert nb.retry == 3 def test_explicit_retry_overrides_asset(self): asset = AppriseAsset(default_service_retry=3) nb = _TestNotify(host="localhost", asset=asset, retry=1) assert nb.retry == 1 class _RaisingNotify(NotifyBase): """Plugin that raises a bare Exception from notify().""" app_id = "RaisingApp" app_desc = "Test" notify_url = "raise://" title_maxlen = 250 body_maxlen = 32768 def url(self, *args, **kwargs): return "raise://localhost" def send(self, **kwargs): raise RuntimeError("plugin exploded") async def async_notify(self, **kwargs): raise RuntimeError("plugin exploded async") @staticmethod def parse_url(url): return NotifyBase.parse_url(url, verify_host=False) class TestAppriseTag: """Full branch coverage for AppriseTag.""" def test_parse_returns_same_instance(self): """parse() short-circuits when the input is already an AppriseTag.""" t = AppriseTag("alerts", priority=2) # Must return the exact same object, not a copy assert AppriseTag.parse(t) is t def test_parse_fallback_for_unrecognised_input(self): """parse() falls back to a raw-name tag when the regex has no match.""" # Input starting with '@' does not match _RE_TAG; the fallback branch # stores the whole input as the name. t = AppriseTag.parse("@invalid") assert str(t) == "@invalid" assert t.priority == 0 assert t.retry is None # --- __repr__ --- def test_repr_plain(self): """repr() with default priority and no retry is minimal.""" assert repr(AppriseTag("simple")) == "AppriseTag('simple')" def test_repr_with_priority(self): """repr() includes priority= when non-zero (tag.py lines 121-122).""" r = repr(AppriseTag("test", priority=3)) assert "priority=3" in r def test_repr_with_retry(self): """repr() includes retry= when set (tag.py lines 123-125).""" r = repr(AppriseTag("test", retry=5)) assert "retry=5" in r def test_repr_with_priority_and_retry(self): """repr() includes both fields when both are non-default.""" r = repr(AppriseTag("test", priority=2, retry=3)) assert "priority=2" in r assert "retry=3" in r # --- __eq__ --- def test_eq_two_apprisetags_same_name(self): """Two AppriseTag objects are equal when their names match, regardless of priority or retry.""" assert AppriseTag("abc") == AppriseTag("abc", priority=5) assert AppriseTag("ABC") == AppriseTag("abc") def test_eq_with_string(self): """An AppriseTag equals a plain str by name.""" assert AppriseTag("hello") == "hello" assert AppriseTag("HELLO") == "hello" def test_eq_not_equal_different_name(self): assert AppriseTag("abc") != AppriseTag("xyz") assert AppriseTag("abc") != "xyz" def test_eq_returns_notimplemented_for_other_types(self): """Comparing to a non-str/AppriseTag returns NotImplemented""" result = AppriseTag("abc").__eq__(42) assert result is NotImplemented # --- __lt__ --- def test_lt_two_apprisetags(self): """__lt__ sorts by tag name (tag.py lines 159-160).""" assert AppriseTag("abc") < AppriseTag("def") assert not (AppriseTag("def") < AppriseTag("abc")) def test_lt_with_string(self): """AppriseTag can be less-than compared to a plain string (tag.py lines 161-162).""" assert AppriseTag("abc") < "def" assert not (AppriseTag("zzz") < "aaa") def test_lt_returns_notimplemented_for_other_types(self): """__lt__ with non-str/AppriseTag returns NotImplemented""" result = AppriseTag("abc").__lt__(42) assert result is NotImplemented # --- __bool__ --- def test_bool_empty_tag_is_false(self): """An AppriseTag with an empty name is falsy""" assert not AppriseTag("") assert bool(AppriseTag("")) is False def test_bool_nonempty_tag_is_true(self): assert bool(AppriseTag("something")) is True # --- set / dict integration --- def test_apprisetag_in_set_with_plain_string(self): """AppriseTag in a set is found by plain string membership test.""" s = {AppriseTag("endpoint")} assert "endpoint" in s def test_plain_string_in_set_with_apprisetag(self): """A plain string is found in a set containing AppriseTag.""" s = {"endpoint"} assert AppriseTag("endpoint") in s class TestStaticHelpers: """Tests for _extract_filter_retry and _filter_has_explicit_priority covering the nested-list branches not reached by integration tests.""" def test_extract_retry_from_nested_list(self): """Retry suffix found inside a nested list entry (apprise.py:413).""" # tag = [["alerts:3"]] -- outer list, inner list as AND group result = Apprise._extract_filter_retry([["alerts:3"]]) assert result == 3 def test_extract_retry_from_list_of_strings(self): """Retry suffix found in a plain-string list entry (apprise.py:417).""" result = Apprise._extract_filter_retry(["alerts:3"]) assert result == 3 def test_extract_retry_none_when_absent(self): """Returns None when no retry suffix is present.""" assert Apprise._extract_filter_retry("alerts") is None assert Apprise._extract_filter_retry(["alerts"]) is None def test_extract_retry_none_from_nested_list_no_suffix(self): """Nested list without retry suffix returns None. Covers the 'if ft.retry is not None' False branch and the inner for-loop completing without returning. """ result = Apprise._extract_filter_retry([["alerts"]]) assert result is None def test_server_priority_for_tag_name_absent(self): """Returns 0 when no tag in server.tags matches tag_name.""" server = mock.Mock() server.tags = {AppriseTag("alerts", priority=3, has_priority=True)} assert Apprise._server_priority_for_tag_name(server, "backup") == 0 def test_match_service_retry_plain_string_tag_backward_compat(self): """Plain string in server.tags matched by a priority-prefixed token. Covers the else/str fallback inside _match_service_retry when stag is not an AppriseTag instance and the name matches (lines 497-498). """ server = mock.Mock() server.tags = {"alerts"} # plain string, not an AppriseTag # "3:alerts:2" carries priority=3 + retry=2; plain "alerts" matches result = Apprise._match_service_retry(server, "3:alerts:2") assert result == 2 def test_match_service_retry_plain_string_tag_no_match(self): """Plain string in server.tags that does not match returns None.""" server = mock.Mock() server.tags = {"other"} # plain string -- does not match "alerts" result = Apprise._match_service_retry(server, "3:alerts:2") assert result is None def test_extract_retry_none_for_match_all(self): assert Apprise._extract_filter_retry(MATCH_ALL_TAG) is None assert Apprise._extract_filter_retry(None) is None def test_has_priority_nested_list(self): """Priority prefix found inside a nested list (apprise.py:438).""" assert Apprise._filter_has_explicit_priority([["2:alerts"]]) def test_has_priority_list_of_strings(self): """Priority prefix found in a plain-string list (apprise.py:441).""" assert Apprise._filter_has_explicit_priority(["2:alerts"]) def test_has_priority_false_no_prefix(self): assert not Apprise._filter_has_explicit_priority("alerts") assert not Apprise._filter_has_explicit_priority(["alerts"]) def test_has_priority_false_for_match_all(self): assert not Apprise._filter_has_explicit_priority(MATCH_ALL_TAG) assert not Apprise._filter_has_explicit_priority(None) # _notify_parallel_threadpool / _notify_parallel_asyncio zero-length ----- def test_threadpool_zero_servers_returns_true(self): """Empty server list returns True immediately (apprise.py:940).""" assert Apprise._notify_parallel_threadpool() is True def test_asyncio_zero_servers_returns_true(self): """Empty server list returns True immediately (apprise.py:1031).""" result = asyncio.run(Apprise._notify_parallel_asyncio()) assert result is True class TestDispatchIntegration: """End-to-end notify() tests covering the new priority/retry dispatch.""" def _make_server(self, host, priority, asset, fail_times=0): """Create a _FailThenSucceedNotify with an AppriseTag in its tags.""" s = _FailThenSucceedNotify( host=host, asset=asset, fail_times=fail_times ) # Assign a fresh instance-level set rather than mutating the # class-level tags set. URLBase.tags is a class attribute (set()); # .add() would mutate it and share state across all instances. # Assignment shadows the class attribute and gives each server its # own set with the correct priority stored in the AppriseTag object. s.tags = {AppriseTag("alerts", priority=priority, has_priority=True)} return s def test_filter_retry_override_via_tag_suffix(self): """tag suffix ':N' overrides per-server retry for this call only.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # Server's own retry=0; tag suffix of 2 should allow 3 attempts server = _FailThenSucceedNotify( host="localhost", asset=asset, retry=0, wait=0.0, fail_times=2, ) # Tag the server so the filter "failpass:2" (tag name "failpass", # retry override 2) can match it via is_exclusive_match. server.tags = {"failpass"} a = Apprise(asset=asset) a.add(server) # "failpass:2" -> retry override of 2 (3 total attempts) result = a.notify(body="test", tag="failpass:2") assert result is True assert server._calls == 3 finally: N_MGR.unload_modules() def test_exclusive_priority_dispatch_skips_other_priorities(self): """Explicit priority prefix dispatches only matching-priority services. Covers apprise.py lines 556-572.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s1 = self._make_server("host1", priority=1, asset=asset) s2 = self._make_server("host2", priority=2, asset=asset) a = Apprise(asset=asset) a.add(s1) a.add(s2) # "2:alerts" -> only priority-2 service fires result = a.notify(body="test", tag="2:alerts") assert result is True assert s1._calls == 0 # priority-1, not contacted assert s2._calls == 1 # priority-2, contacted once finally: N_MGR.unload_modules() def test_escalation_stops_when_highest_priority_group_succeeds(self): """If priority-1 group all succeed, priority-2 group is not run.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s1 = self._make_server("host1", priority=1, asset=asset) s2 = self._make_server("host2", priority=2, asset=asset) a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test", tag="alerts") assert result is True assert s1._calls == 1 # priority 1 succeeded assert s2._calls == 0 # never escalated to finally: N_MGR.unload_modules() def test_escalation_triggers_on_priority_group_failure(self): """Priority-1 group failure escalates to priority-2 group.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # priority-1 always fails s1 = self._make_server( "host1", priority=1, asset=asset, fail_times=999 ) # priority-2 always succeeds s2 = self._make_server("host2", priority=2, asset=asset) a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test", tag="alerts") assert result is True assert s1._calls == 1 assert s2._calls == 1 finally: N_MGR.unload_modules() def test_async_notify_filter_retry_and_explicit_priority(self): """async_notify() covers the same escalation/exclusive paths. Covers apprise.py lines 630, 637-649.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() s1 = self._make_server("host1", priority=1, asset=asset) s2 = self._make_server("host2", priority=2, asset=asset) a = Apprise(asset=asset) a.add(s1) a.add(s2) async def run_exclusive(): return await a.async_notify(body="test", tag="2:alerts") async def run_escalation(): return await a.async_notify(body="test", tag="alerts") # exclusive: only priority-2 fires r = asyncio.run(run_exclusive()) assert r is True assert s1._calls == 0 assert s2._calls == 1 # escalation: priority-1 succeeds, priority-2 skipped s1._calls = 0 s2._calls = 0 r = asyncio.run(run_escalation()) assert r is True assert s1._calls == 1 assert s2._calls == 0 finally: N_MGR.unload_modules() def test_plain_and_zero_prefix_are_equivalent(self): """'family' and '0:family' are identical at priority 0. A filter of '0:family' must match services tagged with plain 'family', and both forms must land in the same escalation group.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # s1: tagged with plain "family" (priority=0, has_priority=False) s1 = _FailThenSucceedNotify( host="host1", asset=asset, fail_times=0 ) s1.tags = {AppriseTag.parse("family")} # s2: tagged with explicit "0:family" (priority=0, # has_priority=True) s2 = _FailThenSucceedNotify( host="host2", asset=asset, fail_times=0 ) s2.tags = {AppriseTag.parse("0:family")} # s3: lower-urgency fallback -- should never fire when p-0 succeeds s3 = _FailThenSucceedNotify( host="host3", asset=asset, fail_times=0 ) s3.tags = {AppriseTag.parse("1:family")} a = Apprise(asset=asset) a.add(s1) a.add(s2) a.add(s3) # Exclusive "0:family": plain "family" and "0:family" servers are # both priority 0 and must both fire; priority-1 server is skipped. result = a.notify(body="test", tag="0:family") assert result is True assert s1._calls == 1 # plain "family" matched by "0:family" assert s2._calls == 1 # explicit "0:family" matched assert s3._calls == 0 # priority 1, not priority 0 -- skipped # Escalation "family": s1 and s2 are both in the priority-0 # group. Both succeed, so s3 (priority 1) is never escalated to. s1._calls = s2._calls = s3._calls = 0 result = a.notify(body="test", tag="family") assert result is True assert s1._calls == 1 # priority-0 group assert s2._calls == 1 # same priority-0 group assert s3._calls == 0 # not escalated to finally: N_MGR.unload_modules() class TestExceptionHandling: """Verify that plugin exceptions are caught and retried, not propagated.""" def test_sequential_exception_treated_as_failure(self): """A plugin raising Exception is caught; delivery is marked failed.""" N_MGR["raise"] = _RaisingNotify try: asset = AppriseAsset(async_mode=False) server = _RaisingNotify(host="localhost", asset=asset, retry=0) a = Apprise(asset=asset) a.add(server) result = a.notify(body="test") assert result is False finally: N_MGR.unload_modules() def test_sequential_exception_retried(self): """Exception on first attempt is retried; later success counts.""" N_MGR["failpass"] = _FailThenSucceedNotify N_MGR["raise"] = _RaisingNotify try: asset = AppriseAsset(async_mode=False) # Use a plugin that raises on the first attempt then succeeds server = _FailThenSucceedNotify( host="localhost", asset=asset, retry=1, wait=0.0, fail_times=1 ) # Patch notify to raise on the first call then succeed call_count = [0] def raising_then_ok(**kwargs): call_count[0] += 1 if call_count[0] == 1: raise RuntimeError("transient error") return True server.send = raising_then_ok a = Apprise(asset=asset) a.add(server) result = a.notify(body="test") assert result is True assert call_count[0] == 2 finally: N_MGR.unload_modules() def test_threadpool_future_exception_caught(self): """An exception escaping a thread future is caught gracefully. Covers apprise.py lines 1004-1006.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) # Need 2 servers to trigger the thread pool path s1 = _FailThenSucceedNotify( host="host1", asset=asset, fail_times=0 ) s2 = _FailThenSucceedNotify( host="host2", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(s1) a.add(s2) # Patch as_completed so one future raises when .result() is called import concurrent.futures as cf real_as_completed = cf.as_completed def patched_as_completed(futures, **kw): for i, f in enumerate(real_as_completed(futures, **kw)): if i == 0: # Wrap first future to raise on .result() m = mock.Mock() m.result.side_effect = RuntimeError("future boom") yield m else: yield f with mock.patch( "apprise.apprise.cf.as_completed", patched_as_completed ): result = a.notify(body="test") # One future raised -> overall result is False assert result is False finally: N_MGR.unload_modules() def test_asyncio_exception_in_do_call_treated_as_failure(self): """An exception inside do_call's retry loop is caught and the service is marked as failed (covers the new try/except in do_call).""" N_MGR["raise"] = _RaisingNotify try: asset = AppriseAsset() server = _RaisingNotify(host="localhost", asset=asset, retry=0) a = Apprise(asset=asset) a.add(server) async def run(): return await a.async_notify(body="test") result = asyncio.run(run()) assert result is False finally: N_MGR.unload_modules() def test_asyncio_gather_unhandled_exception(self): """An Exception escaping the outer gather() round is caught as a safety net and treated as a batch failure.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() server = _FailThenSucceedNotify( host="localhost", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(server) # Mock asyncio.gather to return an escaped exception object. # Close any unawaited coroutines passed to the mock to avoid # "coroutine was never awaited" RuntimeWarnings. async def fake_gather(*args, **kw): for arg in args: if asyncio.iscoroutine(arg): arg.close() return [RuntimeError("escaped")] async def run(): with mock.patch("apprise.apprise.asyncio.gather", fake_gather): return await a.async_notify(body="test") result = asyncio.run(run()) assert result is False finally: N_MGR.unload_modules() def test_asyncio_gather_type_error(self): """A TypeError in gather results is caught and returns False.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset() server = _FailThenSucceedNotify( host="localhost", asset=asset, fail_times=0 ) a = Apprise(asset=asset) a.add(server) async def fake_gather(*args, **kw): for arg in args: if asyncio.iscoroutine(arg): arg.close() return [TypeError("validation")] async def run(): with mock.patch("apprise.apprise.asyncio.gather", fake_gather): return await a.async_notify(body="test") result = asyncio.run(run()) assert result is False finally: N_MGR.unload_modules() class TestConfigTagRetry: """Config parsers must reject a retry suffix in service tag definitions. A tag like 'alerts:3' is only valid at call-time (notify() filter / CLI). Putting it in a config file creates ambiguity with the URL ?retry= param and the YAML retry: key, so both parsers must warn and skip the entry. """ def test_text_rejects_retry_suffix(self): """'alerts:3=json://...' is rejected; no server loaded.""" servers, _ = ConfigBase.config_parse_text("alerts:3=json://localhost") assert servers == [] def test_text_rejects_priority_and_retry_in_tag(self): """'1:alerts:3=json://...' is rejected (priority + retry suffix).""" servers, _ = ConfigBase.config_parse_text( "1:alerts:3=json://localhost" ) assert servers == [] def test_text_accepts_priority_tag_without_retry(self): """'1:alerts=json://...' loads normally (priority prefix, no retry).""" servers, _ = ConfigBase.config_parse_text("1:alerts=json://localhost") assert len(servers) == 1 def test_text_accepts_plain_tag(self): """'alerts=json://...' loads normally (no prefix, no retry).""" servers, _ = ConfigBase.config_parse_text("alerts=json://localhost") assert len(servers) == 1 def test_yaml_rejects_retry_suffix(self): """YAML 'tag: alerts:3' is rejected; no server loaded.""" servers, _ = ConfigBase.config_parse_yaml( "version: 1\nurls:\n - json://localhost:\n tag: alerts:3\n" ) assert servers == [] def test_yaml_rejects_priority_and_retry_in_tag(self): """YAML 'tag: 1:alerts:3' is rejected (priority + retry suffix).""" servers, _ = ConfigBase.config_parse_yaml( "version: 1\nurls:\n - json://localhost:\n tag: 1:alerts:3\n" ) assert servers == [] def test_yaml_accepts_priority_tag_without_retry(self): """YAML 'tag: 1:alerts' loads normally (priority prefix, no retry).""" servers, _ = ConfigBase.config_parse_yaml( "version: 1\nurls:\n - json://localhost:\n tag: 1:alerts\n" ) assert len(servers) == 1 def test_yaml_accepts_plain_tag(self): """YAML 'tag: alerts' loads normally (no prefix, no retry).""" servers, _ = ConfigBase.config_parse_yaml( "version: 1\nurls:\n - json://localhost:\n tag: alerts\n" ) assert len(servers) == 1 def test_text_valid_entry_after_rejected_one_still_loads(self): """A valid entry that follows a rejected one is still loaded.""" servers, _ = ConfigBase.config_parse_text( "alerts:3=json://localhost\nalerts=json://localhost\n" ) assert len(servers) == 1 def test_yaml_valid_entry_after_rejected_one_still_loads(self): """A valid entry that follows a rejected one is still loaded.""" servers, _ = ConfigBase.config_parse_yaml( "version: 1\n" "urls:\n" " - json://localhost:\n" " tag: alerts:3\n" " - json://localhost:\n" " tag: alerts\n" ) assert len(servers) == 1 class TestMultiTagDispatch: """Per-tag independent escalation and per-service retry for multi-tag OR filters. When notify() receives a filter with multiple OR tokens (e.g. 'devops management' or ['devops:3', 'management:2']), each token forms an independent escalation chain and each service gets the retry value from the token that matched it -- not a single global retry applied to all services. """ def _make_tagged(self, tag_str, asset, fail_times=0): """Create a _FailThenSucceedNotify tagged with a single AppriseTag.""" s = _FailThenSucceedNotify( host=tag_str, asset=asset, fail_times=fail_times ) # Assign instance-level set to avoid class-level mutation. s.tags = {AppriseTag.parse(tag_str)} return s def _make_priority_tagged(self, tag_str, asset, fail_times=0): """Create a server whose tag is an explicit-priority AppriseTag.""" ft = AppriseTag.parse(tag_str) s = _FailThenSucceedNotify( host=str(ft), asset=asset, fail_times=fail_times ) s.tags = {ft} return s # ------------------------------------------------------------------ # Per-service retry via space-separated string # ------------------------------------------------------------------ def test_per_service_retry_space_separated_string(self): """'devops:3 management:2' applies retry=3 to devops services and retry=2 to management services independently.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # devops service: fails 3 times; needs retry=3 to succeed s_devops = _FailThenSucceedNotify( host="devops", asset=asset, fail_times=3 ) s_devops.tags = {"devops"} # management service: fails 2 times; needs retry=2 to succeed s_mgmt = _FailThenSucceedNotify( host="mgmt", asset=asset, fail_times=2 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) result = a.notify(body="test", tag="devops:3 management:2") assert result is True # devops: 1 initial + 3 retries = 4 total calls assert s_devops._calls == 4 # management: 1 initial + 2 retries = 3 total calls assert s_mgmt._calls == 3 finally: N_MGR.unload_modules() def test_per_service_retry_list_of_tokens(self): """['devops:3', 'management:2'] gives the same per-service retry result as a space-separated string.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_devops = _FailThenSucceedNotify( host="devops", asset=asset, fail_times=3 ) s_devops.tags = {"devops"} s_mgmt = _FailThenSucceedNotify( host="mgmt", asset=asset, fail_times=2 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) result = a.notify(body="test", tag=["devops:3", "management:2"]) assert result is True assert s_devops._calls == 4 assert s_mgmt._calls == 3 finally: N_MGR.unload_modules() # ------------------------------------------------------------------ # Independent escalation chains # ------------------------------------------------------------------ def test_independent_chains_devops_success_does_not_skip_management(self): """When devops chain succeeds at priority-1, management chain still dispatches independently -- its priority-1 failure escalates to its priority-2 server.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # devops chain: priority-1 succeeds; priority-2 must not fire s_devops_p1 = _FailThenSucceedNotify( host="d1", asset=asset, fail_times=0 ) s_devops_p1.tags = { AppriseTag("devops", priority=1, has_priority=True) } s_devops_p2 = _FailThenSucceedNotify( host="d2", asset=asset, fail_times=0 ) s_devops_p2.tags = { AppriseTag("devops", priority=2, has_priority=True) } # management chain: priority-1 always fails; must escalate to # priority-2 s_mgmt_p1 = _FailThenSucceedNotify( host="m1", asset=asset, fail_times=999 ) s_mgmt_p1.tags = { AppriseTag("management", priority=1, has_priority=True) } s_mgmt_p2 = _FailThenSucceedNotify( host="m2", asset=asset, fail_times=0 ) s_mgmt_p2.tags = { AppriseTag("management", priority=2, has_priority=True) } a = Apprise(asset=asset) a.add(s_devops_p1) a.add(s_devops_p2) a.add(s_mgmt_p1) a.add(s_mgmt_p2) result = a.notify(body="test", tag="devops management") assert result is True assert s_devops_p1._calls == 1 # devops chain: p1 succeeded assert s_devops_p2._calls == 0 # never escalated to assert s_mgmt_p1._calls == 1 # management chain: p1 failed assert s_mgmt_p2._calls == 1 # escalated to p2, succeeded finally: N_MGR.unload_modules() def test_both_chains_must_succeed_for_true(self): """notify() returns False when one chain exhausts all priority groups without any group fully succeeding.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # devops chain: always succeeds s_devops = _FailThenSucceedNotify( host="d", asset=asset, fail_times=0 ) s_devops.tags = {"devops"} # management chain: always fails s_mgmt = _FailThenSucceedNotify( host="m", asset=asset, fail_times=999 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) result = a.notify(body="test", tag="devops management") assert result is False assert s_devops._calls == 1 # its chain succeeded assert s_mgmt._calls == 1 # its chain failed finally: N_MGR.unload_modules() # ------------------------------------------------------------------ # Explicit-priority list with per-service retry (flat dispatch) # ------------------------------------------------------------------ def test_explicit_priority_list_per_service_retry(self): """['1:tag:2', '2:tag:0'] flat-dispatches both servers; each gets its own retry: the priority-1 server gets retry=2 and the priority-2 server gets retry=0 (one attempt only).""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # priority-1 server: fails twice; needs retry=2 to succeed s1 = _FailThenSucceedNotify(host="h1", asset=asset, fail_times=2) s1.tags = {AppriseTag("tag", priority=1, has_priority=True)} # priority-2 server: always succeeds; retry=0 means one attempt s2 = _FailThenSucceedNotify(host="h2", asset=asset, fail_times=0) s2.tags = {AppriseTag("tag", priority=2, has_priority=True)} a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test", tag=["1:tag:2", "2:tag:0"]) assert result is True # s1: 1 initial + 2 retries = 3 total assert s1._calls == 3 # s2: retry=0 -> exactly 1 attempt assert s2._calls == 1 finally: N_MGR.unload_modules() # ------------------------------------------------------------------ # CLI OR format (list of single-element lists) # ------------------------------------------------------------------ def test_cli_or_format_independent_chains(self): """CLI --tag 'devops:3' --tag 'management:2' generates [['devops:3'], ['management:2']] -- single-element inner lists that must be treated as independent OR chains, not AND groups.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_devops = _FailThenSucceedNotify( host="devops", asset=asset, fail_times=3 ) s_devops.tags = {"devops"} s_mgmt = _FailThenSucceedNotify( host="mgmt", asset=asset, fail_times=2 ) s_mgmt.tags = {"management"} a = Apprise(asset=asset) a.add(s_devops) a.add(s_mgmt) # Simulate CLI: --tag "devops:3" --tag "management:2" cli_tag = [["devops:3"], ["management:2"]] result = a.notify(body="test", tag=cli_tag) assert result is True assert s_devops._calls == 4 # 1 initial + 3 retries assert s_mgmt._calls == 3 # 1 initial + 2 retries finally: N_MGR.unload_modules() def test_cli_and_format_shared_chain(self): """CLI --tag 'devops, management' generates [['devops', 'management']] -- a multi-element inner list that is an AND condition and uses a single shared escalation chain.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # Server must carry BOTH tags to match s_both = _FailThenSucceedNotify( host="both", asset=asset, fail_times=0 ) s_both.tags = {"devops", "management"} # Server with only "devops" -- must NOT match AND filter s_devonly = _FailThenSucceedNotify( host="devonly", asset=asset, fail_times=0 ) s_devonly.tags = {"devops"} a = Apprise(asset=asset) a.add(s_both) a.add(s_devonly) # Simulate CLI: --tag "devops, management" (AND condition) cli_tag = [["devops", "management"]] result = a.notify(body="test", tag=cli_tag) assert result is True assert s_both._calls == 1 # has both tags -- matched assert s_devonly._calls == 0 # missing "management" -- not matched finally: N_MGR.unload_modules() # ------------------------------------------------------------------ # Edge cases: explicit priority + AND, 3-chain OR, chain exhaustion # ------------------------------------------------------------------ def test_and_filter_explicit_priority_no_match_returns_none(self): """AND filter with an explicit priority that no server satisfies returns None (no services dispatched at all).""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # Server is tagged 'alerts' at priority 2 s = _FailThenSucceedNotify(host="s", asset=asset, fail_times=0) s.tags = {AppriseTag("alerts", priority=2, has_priority=True)} a = Apprise(asset=asset) a.add(s) # Request 'alerts' at priority 1 AND 'backup' -- server has # 'alerts' but at priority 2, not 1; nothing matches. result = a.notify(body="test", tag=[["1:alerts", "backup"]]) assert result is None assert s._calls == 0 finally: N_MGR.unload_modules() def test_three_or_chains_all_succeed(self): """Three independent OR chains each succeed at their first priority group; notify() returns True and each service is called once.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_a = _FailThenSucceedNotify(host="a", asset=asset, fail_times=0) s_a.tags = {"alpha"} s_b = _FailThenSucceedNotify(host="b", asset=asset, fail_times=0) s_b.tags = {"beta"} s_c = _FailThenSucceedNotify(host="c", asset=asset, fail_times=0) s_c.tags = {"gamma"} a = Apprise(asset=asset) a.add(s_a) a.add(s_b) a.add(s_c) result = a.notify(body="test", tag=["alpha", "beta", "gamma"]) assert result is True assert s_a._calls == 1 assert s_b._calls == 1 assert s_c._calls == 1 finally: N_MGR.unload_modules() def test_three_or_chains_one_fails_overall_false(self): """Three OR chains: two succeed, one exhausts all priority groups. notify() returns False even though the other chains succeeded.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_a = _FailThenSucceedNotify(host="a", asset=asset, fail_times=0) s_a.tags = {"alpha"} # beta always fails (fail_times > any possible retry) s_b = _FailThenSucceedNotify(host="b", asset=asset, fail_times=99) s_b.tags = {"beta"} s_c = _FailThenSucceedNotify(host="c", asset=asset, fail_times=0) s_c.tags = {"gamma"} a = Apprise(asset=asset) a.add(s_a) a.add(s_b) a.add(s_c) result = a.notify(body="test", tag=["alpha", "beta", "gamma"]) assert result is False assert s_a._calls == 1 # alpha succeeded assert s_c._calls == 1 # gamma succeeded finally: N_MGR.unload_modules() def test_chain_escalation_to_second_priority_group(self): """A chain whose priority-0 group fails escalates to the priority-1 group; the second group succeeds so notify() returns True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # priority-0 server: always fails (no retry configured) s_p0 = _FailThenSucceedNotify( host="p0", asset=asset, fail_times=99 ) s_p0.tags = {AppriseTag("alerts", priority=0, has_priority=False)} # priority-1 server: succeeds on first attempt s_p1 = _FailThenSucceedNotify(host="p1", asset=asset, fail_times=0) s_p1.tags = {AppriseTag("alerts", priority=1, has_priority=True)} a = Apprise(asset=asset) a.add(s_p0) a.add(s_p1) result = a.notify(body="test", tag="alerts") assert result is True # priority-0 group was attempted once and failed assert s_p0._calls == 1 # priority-1 group was then tried as fallback assert s_p1._calls == 1 finally: N_MGR.unload_modules() def test_all_priority_groups_exhausted_returns_false(self): """When every priority group in a chain fails, notify() returns False and all groups were attempted in order.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # priority-0 server: always fails s_p0 = _FailThenSucceedNotify( host="p0", asset=asset, fail_times=99 ) s_p0.tags = {AppriseTag("alerts", priority=0, has_priority=False)} # priority-1 server: also always fails s_p1 = _FailThenSucceedNotify( host="p1", asset=asset, fail_times=99 ) s_p1.tags = {AppriseTag("alerts", priority=1, has_priority=True)} a = Apprise(asset=asset) a.add(s_p0) a.add(s_p1) result = a.notify(body="test", tag="alerts") assert result is False # Both groups were attempted assert s_p0._calls == 1 assert s_p1._calls == 1 finally: N_MGR.unload_modules() def test_chain_dispatch_future_exception_treated_as_failure(self): """An exception escaping _run_batch is caught in the chain-dispatch ThreadPoolExecutor loop and treated as a delivery failure. Covers the 'except Exception: ok = False' branch (apprise.py:788-792) that is only reachable when multiple chains are active simultaneously (triggering the ThreadPoolExecutor path) and _run_batch raises. Uses _ExplodingAsset (raises on async_mode access) instead of patching a @staticmethod, which is unreliable across Python versions when the patched method runs inside a ThreadPoolExecutor worker. """ N_MGR["failpass"] = _FailThenSucceedNotify try: # _ExplodingAsset.async_mode raises RuntimeError. _run_batch # reads s.asset.async_mode to split seq/par, so a batch with # one of these servers raises and the future captures it. class _ExplodingAsset(AppriseAsset): @property def async_mode(self): raise RuntimeError("injected async_mode access") # Two servers with distinct tags -> two independent chains so # the ThreadPoolExecutor path (len(active) > 1) is taken. s_a = _FailThenSucceedNotify( host="a", asset=_ExplodingAsset(), fail_times=0 ) s_a.tags = {"alpha"} s_b = _FailThenSucceedNotify( host="b", asset=_ExplodingAsset(), fail_times=0 ) s_b.tags = {"beta"} a = Apprise() a.add(s_a) a.add(s_b) result = a.notify(body="test", tag=["alpha", "beta"]) # Both chains raised instead of returning True -- overall False. assert result is False finally: N_MGR.unload_modules() class TestAbortOnChainFailure: """AppriseAsset.abort_on_chain_failure controls early-abort behaviour. When False (default): all chains are allowed to complete even if one has already failed, so every configured URL gets at least one attempt. When True: as soon as any chain exhausts all its priority groups without success, notify() returns False immediately without running further escalation rounds for the other chains. """ def _make_tagged(self, tag_str, asset, fail_times=0): s = _FailThenSucceedNotify( host=tag_str, asset=asset, fail_times=fail_times ) s.tags = {tag_str} return s def test_default_false_all_chains_complete(self): """With abort_on_chain_failure=False (default), all chains run to completion even if one chain has already failed.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) # alpha always succeeds s_a = self._make_tagged("alpha", asset, fail_times=0) # beta always fails (exhausts its only group immediately) s_b = self._make_tagged("beta", asset, fail_times=99) # gamma always succeeds s_c = self._make_tagged("gamma", asset, fail_times=0) a = Apprise(asset=asset) a.add(s_a) a.add(s_b) a.add(s_c) result = a.notify(body="test", tag=["alpha", "beta", "gamma"]) assert result is False # alpha and gamma were still dispatched despite beta failing assert s_a._calls == 1 assert s_b._calls == 1 assert s_c._calls == 1 finally: N_MGR.unload_modules() def test_abort_on_chain_failure_true_stops_early(self): """With abort_on_chain_failure=True, once beta exhausts all priority groups without success, further escalation rounds for other chains are skipped.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False, abort_on_chain_failure=True) # alpha: two priority levels; priority-0 always fails s_a_p0 = _FailThenSucceedNotify( host="a0", asset=asset, fail_times=99 ) s_a_p0.tags = {AppriseTag("alpha", priority=0, has_priority=False)} s_a_p1 = _FailThenSucceedNotify( host="a1", asset=asset, fail_times=0 ) s_a_p1.tags = {AppriseTag("alpha", priority=1, has_priority=True)} # beta: one priority level; always fails s_b = _FailThenSucceedNotify(host="b", asset=asset, fail_times=99) s_b.tags = {"beta"} a = Apprise(asset=asset) a.add(s_a_p0) a.add(s_a_p1) a.add(s_b) result = a.notify(body="test", tag=["alpha", "beta"]) assert result is False # beta failed in round 1 and was exhausted -- abort triggered. # alpha's p1 (escalation) should NOT have been attempted. assert s_b._calls == 1 assert s_a_p0._calls == 1 assert s_a_p1._calls == 0 # skipped due to early abort finally: N_MGR.unload_modules() def test_abort_on_chain_failure_true_async(self): """abort_on_chain_failure=True triggers the same early-abort in async_notify()""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True, abort_on_chain_failure=True) # alpha has two priority levels; priority-0 always fails s_a_p0 = _FailThenSucceedNotify( host="a0", asset=asset, fail_times=99 ) s_a_p0.tags = {AppriseTag("alpha", priority=0, has_priority=False)} s_a_p1 = _FailThenSucceedNotify( host="a1", asset=asset, fail_times=0 ) s_a_p1.tags = {AppriseTag("alpha", priority=1, has_priority=True)} # beta has one priority level; always fails s_b = _FailThenSucceedNotify(host="b", asset=asset, fail_times=99) s_b.tags = {"beta"} a = Apprise(asset=asset) a.add(s_a_p0) a.add(s_a_p1) a.add(s_b) result = asyncio.run( a.async_notify(body="test", tag=["alpha", "beta"]) ) assert result is False # beta was exhausted in round 1 -- abort fires before alpha's p1 assert s_b._calls == 1 assert s_a_p0._calls == 1 assert s_a_p1._calls == 0 finally: N_MGR.unload_modules() class TestOptionalService: """optional=True silently absorbs per-service delivery failures. The overall notify()/async_notify() result is True even when an optional service cannot be reached, so callers are not penalised for "nice to have" endpoints (e.g. home screens, debug logging). """ def test_optional_default_false(self): """optional defaults to False on a freshly constructed plugin.""" nb = _TestNotify(host="localhost") assert nb.optional is False def test_optional_url_includes_flag(self): """url_parameters() always emits optional=yes or optional=no.""" nb = _TestNotify(host="localhost") params = nb.url_parameters() assert "optional" in params assert params["optional"] == "no" nb_opt = _TestNotify(host="localhost", optional=True) params_opt = nb_opt.url_parameters() assert params_opt["optional"] == "yes" def test_optional_parse_url_yes(self): """parse_url propagates optional=yes into the results dict.""" results = _TestNotify.parse_url("test://localhost?optional=yes") assert results is not None assert results.get("optional") is True def test_optional_parse_url_no(self): """parse_url propagates optional=no into the results dict.""" results = _TestNotify.parse_url("test://localhost?optional=no") assert results is not None assert results.get("optional") is False def test_optional_kwarg_sets_attribute(self): """Passing optional=True/False to __init__ sets .optional.""" nb_yes = _TestNotify(host="localhost", optional=True) assert nb_yes.optional is True nb_no = _TestNotify(host="localhost", optional=False) assert nb_no.optional is False def test_optional_sequential_failure_absorbed(self): """Sequential dispatch: failed optional server -> overall True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=99) s.optional = True a = Apprise(asset=asset) a.add(s) result = a.notify(body="test") assert result is True assert s._calls == 1 finally: N_MGR.unload_modules() def test_optional_threadpool_failure_absorbed(self): """Thread-pool dispatch: failed optional servers -> overall True. Two async_mode=True servers ensure _notify_parallel_threadpool uses the actual thread pool (n_calls==1 falls back to sequential). """ N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) s1 = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=99) s1.optional = True s2 = _FailThenSucceedNotify(host="s2", asset=asset, fail_times=99) s2.optional = True a = Apprise(asset=asset) a.add(s1) a.add(s2) result = a.notify(body="test") assert result is True finally: N_MGR.unload_modules() def test_optional_asyncio_failure_absorbed(self): """Asyncio dispatch: failed optional server -> overall True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=True) s = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=99) s.optional = True a = Apprise(asset=asset) a.add(s) result = asyncio.run(a.async_notify(body="test")) assert result is True finally: N_MGR.unload_modules() def test_optional_all_fail_all_optional_returns_true(self): """When every server is optional and every server fails -> True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) a = Apprise(asset=asset) for i in range(3): s = _FailThenSucceedNotify( host=f"s{i}", asset=asset, fail_times=99 ) s.optional = True a.add(s) result = a.notify(body="test") assert result is True finally: N_MGR.unload_modules() def test_optional_mixed_required_fails_returns_false(self): """Required server failure taints result even with optional peers.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s_opt = _FailThenSucceedNotify( host="opt", asset=asset, fail_times=99 ) s_opt.optional = True s_req = _FailThenSucceedNotify( host="req", asset=asset, fail_times=99 ) # s_req.optional is False (default) a = Apprise(asset=asset) a.add(s_opt) a.add(s_req) result = a.notify(body="test") assert result is False finally: N_MGR.unload_modules() def test_optional_success_unaffected(self): """An optional server that succeeds is still counted as True.""" N_MGR["failpass"] = _FailThenSucceedNotify try: asset = AppriseAsset(async_mode=False) s = _FailThenSucceedNotify(host="s1", asset=asset, fail_times=0) s.optional = True a = Apprise(asset=asset) a.add(s) result = a.notify(body="test") assert result is True assert s._calls == 1 finally: N_MGR.unload_modules() class TestCreateNotifyCalls: """Cover Apprise._create_notify_calls() which splits servers into sequential and parallel buckets based on their asset.async_mode flag.""" def test_splits_sequential_and_parallel(self): """Sequential and parallel servers are split into separate lists.""" N_MGR["failpass"] = _FailThenSucceedNotify try: # With async_mode=False all loaded servers go to the sequential # bucket; the parallel bucket remains empty. asset_seq = AppriseAsset(async_mode=False) s_seq = _FailThenSucceedNotify( host="s1", asset=asset_seq, fail_times=0 ) a = Apprise(asset=asset_seq) a.add(s_seq) seq, par = a._create_notify_calls(body="test") assert len(seq) == 1 assert len(par) == 0 # With async_mode=True the same server is placed in the parallel # bucket instead. asset_par = AppriseAsset(async_mode=True) s_par = _FailThenSucceedNotify( host="s2", asset=asset_par, fail_times=0 ) b = Apprise(asset=asset_par) b.add(s_par) seq2, par2 = b._create_notify_calls(body="test") assert len(seq2) == 0 assert len(par2) == 1 finally: N_MGR.unload_modules() class TestAsyncioSafetyNet: """Cover the safety-net branch in _notify_parallel_asyncio that handles exceptions which escape do_call's own try/except block.""" def test_exception_outside_try_block_triggers_safety_net(self): """An exception raised before the per-attempt try block (e.g. from a misbehaving server.retry property) escapes do_call, is captured by asyncio.gather(return_exceptions=True), and causes the batch to return False via the safety-net handler. A second well-behaved server is included so that results contains both an Exception and a bool value, exercising both branches of the inner ``if isinstance(status, Exception)`` check in the loop.""" class _RaisingRetryServer: """Server whose retry property raises to simulate a broken plugin that fails before the per-attempt try/except in do_call.""" service_name = "raiser" asset = AppriseAsset(async_mode=True) optional = False @property def retry(self): # This exception is raised when do_call evaluates # getattr(server, "retry", 0), which happens OUTSIDE the # per-attempt try/except. It therefore escapes do_call # entirely and is captured by asyncio.gather as a value. raise RuntimeError("injected: property raised outside try") async def async_notify(self, **kwargs): return True # pragma: no cover -- never reached class _GoodServer: """Minimal well-behaved server that succeeds immediately.""" service_name = "good" async def async_notify(self, **kwargs): return True raiser = _RaisingRetryServer() good = _GoodServer() # Two servers: raiser -> Exception in results; good -> True in results. # The safety-net loop therefore hits both branches of the inner # ``if isinstance(status, Exception)`` check. result = asyncio.run( Apprise._notify_parallel_asyncio((raiser, {}), (good, {})) ) assert result is False caronc-apprise-182f859/tests/test_utils_format.py000066400000000000000000000447521524161175200222130ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from apprise import NotifyFormat from apprise.utils.format import html_adjust, markdown_adjust, smart_split def test_smart_split_prefers_newlines_over_spaces_and_punctuation(): """ Newlines should win even if there are spaces and punctuation before the limit. """ text = "line1\nline2 line3. line4" # Long enough to include the newline and some of the next line limit = 12 chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT) # First chunk should end immediately after the newline assert chunks[0] == "line1\n" # Nothing lost assert "".join(chunks) == text def test_smart_split_prefers_spaces_over_hard_split(): """ When there are no newlines, split on the last space/tab before falling back to a hard character-limit split. """ text = "word1 word2 word3" # Force a split between word2 and word3 limit = 12 # "word1 word2 " is 12 characters chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT) assert chunks == ["word1 word2 ", "word3"] assert "".join(chunks) == text def test_smart_split_can_split_after_punctuation_plus_whitespace(): """ Exercise the punctuation+whitespace pattern. In practice this collapses to the same split point as the last space, but we verify the behaviour. """ text = "Hello world. Again" # Force the split around ". " # "Hello world. " is 13 characters limit = 13 chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT) # First chunk should end at the space after the period assert chunks[0] == "Hello world. " assert chunks[1] == "Again" assert "".join(chunks) == text def test_smart_split_avoids_splitting_inside_html_entity() -> None: """ In HTML mode we must not end a chunk in the middle of '&...;'. We do NOT assert exact chunk values. Instead we assert: - TEXT mode can split inside the entity. - HTML mode never has a chunk that contains '&' without a matching ';' after it in the same chunk. """ text = "1234 5678" limit = 8 # without adjustment, we would cut inside ' ' # Plain text mode: allowed to split anywhere chunks_text = smart_split(text, limit, body_format=NotifyFormat.TEXT) assert "".join(chunks_text) == text # Sanity: in TEXT mode we *do* split inside the entity assert any( "&" in chunk and ";" not in chunk[chunk.find("&") :] for chunk in chunks_text ) # HTML mode: entity-aware chunks_html = smart_split(text, limit, body_format=NotifyFormat.HTML) assert "".join(chunks_html) == text # If a chunk contains '&', it must also contain the terminating ';' # for that entity within the same chunk. for chunk in chunks_html: idx = chunk.find("&") if idx == -1: continue semi = chunk.find(";", idx + 1) assert semi != -1, f"Chunk ends inside HTML entity: {chunk!r}" def test_smart_split_avoids_splitting_inside_markdown_link() -> None: """ In MARKDOWN mode, do not split inside [text](url). We only require that the full [link](...) lies in a single chunk and that any '[' appearing in a chunk has a matching ')' in that same chunk. """ link = "[link](https://example.com)" text = "AAAA" + link limit = len(link) chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN) assert "".join(chunks) == text # Entire link must be contained in one chunk assert any(link in chunk for chunk in chunks) # If a chunk has '[', it must also contain its closing ')' for chunk in chunks: idx = chunk.find("[") if idx == -1: continue semi = chunk.find(")", idx + 1) assert semi != -1, f"Markdown link was split inside chunk: {chunk!r}" def test_smart_split_avoids_splitting_inside_markdown_image() -> None: """ In MARKDOWN mode, do not split inside the [alt](url) of an image. The implementation currently splits "AAAA![alt](...)" as: - "AAAA!" - "[alt](...)" which is acceptable, as the [alt](url) part is kept intact. """ image = "![alt](https://example.com/image.png)" text = "AAAA" + image limit = len(image) chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN) assert "".join(chunks) == text inner = "[alt](https://example.com/image.png)" # The [alt](...) portion must appear fully within a single chunk assert any(inner in chunk for chunk in chunks) # As with links, any '[' in a chunk must have its matching ')' within # the same chunk so we never split inside the [alt](url) part. for chunk in chunks: idx = chunk.find("[") if idx == -1: continue semi = chunk.find(")", idx + 1) assert semi != -1, f"Markdown image was split inside chunk: {chunk!r}" def test_smart_split_empty_and_none_input() -> None: """ Empty / None input should be returned as a single-element list unchanged. """ assert smart_split("", 10, body_format=NotifyFormat.TEXT) == [""] assert smart_split("", 0, body_format=NotifyFormat.TEXT) == [""] assert smart_split("content", 0, body_format=NotifyFormat.TEXT) == [""] # None short-circuits before len() is called assert smart_split(None, 10, body_format=NotifyFormat.TEXT) == [""] def test_smart_split_html_entity_exact_boundary() -> None: """ Splitting exactly at an HTML entity boundary should not shift the split point (no need to "fix up" a perfectly aligned boundary). """ text = "AAAA BBBB" limit = len("AAAA ") # split exactly after the entity chunks = smart_split(text, limit, body_format=NotifyFormat.HTML) # We expect the entity to remain whole in the first chunk assert chunks == ["AAAA ", "BBBB"] assert "".join(chunks) == text def test_smart_split_markdown_link_exact_boundary() -> None: """ Splitting exactly after a Markdown link should not cause any adjustment. """ link = "[link](https://example.com)" tail = " TAIL" text = link + tail limit = len(link) # split immediately after ')' chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN) # First chunk is exactly the link, second is the remainder assert chunks[0] == link assert "".join(chunks) == text # Sanity: the link itself is not split across chunks assert any(link in chunk for chunk in chunks) def test_smart_split_whitespace_priority_with_tabs_and_newlines() -> None: """ Exercise newline vs space/tab priority with a mix of whitespace. """ text = "word1\tword2\nword3" # Case 1: window ends just before the newline, so only tab is visible. limit_without_newline = text.index("\n") # position of '\n' chunks_no_nl = smart_split( text, limit_without_newline, body_format=NotifyFormat.TEXT ) # First chunk should end after the tab, since that is the last space/tab assert chunks_no_nl[0] == "word1\t" assert "".join(chunks_no_nl) == text # Case 2: window includes the newline; newline should win over tab. limit_with_newline = text.index("\n") + 1 chunks_with_nl = smart_split( text, limit_with_newline, body_format=NotifyFormat.TEXT ) # First chunk should now end after the newline assert chunks_with_nl[0] == "word1\tword2\n" assert "".join(chunks_with_nl) == text def test_smart_split_very_short_limit() -> None: """ Very small limits should still split deterministically without loss. """ text = "ABC" chunks = smart_split(text, 1, body_format=NotifyFormat.TEXT) # One character per chunk assert chunks == ["A", "B", "C"] assert "".join(chunks) == text def test_smart_split_very_long_limit() -> None: """ Very large limits (>= len(text)) should return a single chunk. """ text = "A short message for testing" chunks = smart_split(text, 10_000, body_format=NotifyFormat.TEXT) assert chunks == [text] assert "".join(chunks) == text def test_html_adjust_guard_paths_and_no_entity() -> None: """ Cover the early-return guard in html_adjust and the path where there is no '&' at all in the search window. """ text = "abcdef" # split_at <= window_start -> early-return unchanged assert html_adjust(text, window_start=2, split_at=2) == 2 # split_at beyond the end of the text -> early-return unchanged assert ( html_adjust(text, window_start=0, split_at=len(text) + 5) == len(text) + 5 ) # No '&' in window, nothing to adjust assert html_adjust(text, window_start=0, split_at=3) == 3 def test_html_adjust_inside_and_at_boundary_of_entity() -> None: """ Exercise the path where html_adjust moves the split back to '&' when the split falls inside an entity, and the path where the split is exactly at the entity boundary and should not move. """ text = "1234 5678" # indexes: 0..3 '1234', 4 '&', 5 'n', 6 'b', 7 's', 8 'p', 9 ';', 10 '5'... # Split inside ' ' (at index 8) -> move back to '&' (index 4) assert html_adjust(text, window_start=0, split_at=8) == 4 # Split exactly after the ';' (index 10) -> already outside entity assert html_adjust(text, window_start=0, split_at=10) == 10 def test_markdown_adjust_guard_and_no_construct() -> None: """ Cover the guard in markdown_adjust and the case where there is no '[' or '!' in the window. """ text = "plain text" # split_at <= window_start -> early-return unchanged assert markdown_adjust(text, window_start=4, split_at=4) == 4 # split_at past the end -> early-return unchanged assert ( markdown_adjust(text, window_start=0, split_at=len(text) + 3) == len(text) + 3 ) # No markdown constructs -> nothing to adjust assert markdown_adjust(text, window_start=0, split_at=5) == 5 def test_markdown_adjust_inside_construct_moves_to_start() -> None: """ Exercise the positive path in markdown_adjust where the split lands inside a [text](url) construct and the function moves the split back to the start of the construct. The forward scan is capped at split_at + (split_at - window_start) + 1 to prevent O(n^2) work on adversarial input (see test_markdown_adjust_dos_cap). This means split_at must be at least half the link length so the cap reaches the closing ')'. Realistic splits from smart_split() always satisfy this because the following applies for any link that fits in a single chunk: split_at - window_start >= limit >= link_length // 2 """ link = "[link](https://example.com)" # Choose a split point in the latter half of the link so the symmetric # forward-scan cap (split_at + window_size) reaches the closing ')'. split_at = len(link) // 2 # index 13, inside "ps://example.com" adjusted = markdown_adjust(link, window_start=0, split_at=split_at) # Should move back to the '[' at index 0 assert adjusted == 0 def test_markdown_adjust_inside_angle_bracket_construct() -> None: """Exercise the angle-bracket path in markdown_adjust. When a split point lands ANYWHERE inside a '' construct (as used by Slack mrkdwn and Google Chat), the function must move it back to the opening '<'. This covers three sub-cases: (a) Split in the label portion (after the pipe). (b) Split exactly on the pipe character. (c) Split in the URL portion (before the pipe) -- previously the pipe search stopped at split_at so no pipe was found; the fix extends the search to forward_end so this case is now also protected. """ # A Slack/Google Chat anchor -- '<' at 0, '|' at 21, '>' at 31. text = "" pipe_pos = text.index("|") # == 20 # (a) Split a few characters after the pipe, inside the label portion. split_at = pipe_pos + 3 adjusted = markdown_adjust(text, window_start=0, split_at=split_at) # Should move back to the '<' at index 0. assert adjusted == 0 # Split right at the closing '>' -- still inside the construct. split_at2 = text.index(">") adjusted2 = markdown_adjust(text, window_start=0, split_at=split_at2) # Also moves back to '<'. assert adjusted2 == 0 # (b) Split exactly ON the '|' separator. split_on_pipe = pipe_pos assert markdown_adjust(text, 0, split_on_pipe) == 0 # (c) Split inside the URL portion (before '|'). # The pipe search now extends to forward_end, not just to split_at. # forward_end = min(32, 17 + 17 + 1) = 32, which covers '>' at 31. # Both pipe (20) and '>' (31) are within range; the construct is detected # and the split moves to '<' at 0. split_in_url = pipe_pos - 3 # index 17, inside "example.com" assert markdown_adjust(text, 0, split_in_url) == 0 # Same scenario with a preamble before '<' so the move is actually # useful -- '<' is no longer at window_start so smart_split would # keep the adjusted position (not reset it). # "<" at 4, "|" at 24, ">" at 35 in the prefixed string. prefixed = "pre " + text split_in_url2 = 20 # index 20, inside "example.com", before '|' at 24 # forward_end = min(36, 20+20+1) = 36 > 35 ('>'). assert markdown_adjust(prefixed, 0, split_in_url2) == 4 # '<' position # When split_at is so small that forward_end doesn't reach the pipe, # the construct is undetectable and the split falls through unchanged. # forward_end = min(32, 5 + 5 + 1) = 11 < pipe_pos(20), so no pipe found. # Even if we detected the '<' at 0, moving the split there would equal # window_start so smart_split() would reset it anyway -- no net change. split_too_small = 5 assert markdown_adjust(text, 0, split_too_small) == split_too_small # '<' and '|' are both found, but split_at is past the closing '>' # so the condition angle_start_idx < split_at <= angle_end_idx is # False -- the function returns the split point unchanged. split_past_end = len(text) assert markdown_adjust(text, 0, split_past_end) == split_past_end def test_markdown_adjust_dos_cap() -> None: """markdown_adjust forward scan is capped to O(window_size). An adversarial body of repeated unterminated '[' openers must not cause O(n^2) work. We verify the cap by checking that a large body produces the same split position as a small one (the cap does not change the output for well-formed constructs within the window). """ from timeit import default_timer # 1 MB of repeated '[broken ' with no closing ')'. body = "[broken " * 128_000 limit = 80 start = default_timer() chunks = smart_split(body, limit, body_format=NotifyFormat.MARKDOWN) elapsed = default_timer() - start # Must complete well under 1 second (the DoS took ~14 s before the cap). assert elapsed < 1.0 # All chunks must be non-empty and re-join to the original. assert all(chunks) assert "".join(chunks) == body def test_smart_split_markdown_guard_split_at_start_is_reset() -> None: """Cover smart_split guard 'if split_at <= start: split_at = orig_split'. We force markdown_adjust to move the split back to the window start (link_start_idx == start == 0), then verify smart_split resets to the original split so progress is still made and chunks join back to the original text. The link "[link](url)" is 11 chars. With limit=7 the first hard split lands at index 7 (inside the URL). The forward-scan cap is 7 + (7 - 0) + 1 = 15, which is > 10 (position of ")"), so markdown_adjust finds the closer and moves split to link_start_idx=0. Since 0 == start, smart_split resets to orig_split=7 and emits the next chunk from there. """ text = "[link](url)" # 12 chars; ")" at index 11 limit = 7 # hard split at 7 (inside url); cap = 15 > 11 so ")" is found chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN) # We should never get stuck; all chunks must be non-empty. assert len(chunks) >= 2 assert all(chunks) # Re-joining all chunks must restore the original text. assert "".join(chunks) == text def test_markdown_adjust_image_bang_opener_path() -> None: """Cover the '!' opener path in markdown_adjust.""" # Bang found, but no '[' after it, so the fallback path is taken. text = "AAAAAA![b](c)" assert markdown_adjust(text, window_start=0, split_at=7) == 6 # Stray '!' not followed by '[' -- bang found but condition is False; # no link construct detected, split point returned unchanged. text2 = "hello!world" assert markdown_adjust(text2, window_start=0, split_at=6) == 6 def test_smart_split_uses_punctuation_branch_on_rare_whitespace() -> None: """ When punctuation is followed by rare whitespace (vertical tab / form feed) and there are no spaces/tabs/newlines, we should use the punctuation + whitespace split branch. """ vt = "\x0b" # vertical tab text = f"Hello.{vt}World" # Window includes 'Hello.' and the VT limit = len("Hello.") + 1 chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT) assert "".join(chunks) == text # We expect the first chunk to end after the rare whitespace assert chunks[0] == f"Hello.{vt}" assert chunks[1] == "World" caronc-apprise-182f859/tests/test_utils_pem.py000066400000000000000000000474611524161175200215040ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import os import sys from unittest import mock import pytest from apprise import AppriseAsset, PersistentStoreMode, utils # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_utils_pem_general(tmpdir): """Utils:PEM.""" # string to manipulate/work with unencrypted_str = "message" tmpdir0 = tmpdir.mkdir("tmp00") # Currently no files here assert os.listdir(str(tmpdir0)) == [] asset = AppriseAsset( storage_mode=PersistentStoreMode.MEMORY, storage_path=str(tmpdir0), pem_autogen=False, ) # Create a PEM Controller pem_c = utils.pem.ApprisePEMController(path=None, asset=asset) # Nothing to lookup assert pem_c.public_keyfile() is None assert pem_c.public_key() is None assert pem_c.x962_str == "" assert pem_c.decrypt(b"data") is None assert pem_c.encrypt(unencrypted_str) is None # Keys can not be generated in memory mode assert pem_c.keygen() is False assert pem_c.sign(b"data") is None asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=False, ) # No new files assert os.listdir(str(tmpdir0)) == [] # Our asset is now write mode, so we will be able to generate a key pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) # Nothing to lookup assert pem_c.public_keyfile() is None assert pem_c.public_key() is None assert pem_c.x962_str == "" assert pem_c.encrypt(unencrypted_str) is None # Generate our keys assert bool(pem_c) is False assert pem_c.keygen() is True assert bool(pem_c) is True # We have 2 new key files generated pub_keyfile = os.path.join(str(tmpdir0), "public_key.pem") prv_keyfile = os.path.join(str(tmpdir0), "private_key.pem") assert os.path.isfile(pub_keyfile) assert os.path.isfile(prv_keyfile) assert pem_c.public_keyfile() is not None assert pem_c.decrypt("garbage") is None assert pem_c.public_key() is not None # Keys used later on as ref pubkey_ref = pem_c.public_key() prvkey_ref = pem_c.private_key() assert isinstance(pem_c.x962_str, str) assert len(pem_c.x962_str) > 20 content = pem_c.encrypt(unencrypted_str) assert pem_c.decrypt( pem_c.encrypt(unencrypted_str.encode("utf-8")) ) == pem_c.decrypt(pem_c.encrypt(unencrypted_str)) assert pem_c.decrypt( pem_c.encrypt(unencrypted_str, public_key=pem_c.public_key()) ) == pem_c.decrypt(pem_c.encrypt(unencrypted_str)) assert pem_c.decrypt(content) == unencrypted_str assert isinstance(content, str) assert pem_c.decrypt(content) == unencrypted_str # support str as well assert pem_c.decrypt(content) == unencrypted_str assert pem_c.decrypt(content.encode("utf-8")) == unencrypted_str # Sign test assert isinstance(pem_c.sign(content.encode("utf-8")), bytes) # Web Push handling webpush_content = pem_c.encrypt_webpush( unencrypted_str, public_key=pem_c.public_key(), auth_secret=b"secret" ) assert isinstance(webpush_content, bytes) webpush_content = pem_c.encrypt_webpush( unencrypted_str.encode("utf-8"), public_key=pem_c.public_key(), auth_secret=b"secret", ) assert isinstance(webpush_content, bytes) # Non Bytes (garbage basically) with pytest.raises(TypeError): assert pem_c.decrypt(None) is None with pytest.raises(TypeError): assert pem_c.decrypt(5) is None with pytest.raises(TypeError): assert pem_c.decrypt(False) is None with pytest.raises(TypeError): assert pem_c.decrypt(object) is None # Test our initialization pem_c = utils.pem.ApprisePEMController( path=None, prv_keyfile="invalid", asset=asset ) assert pem_c.private_keyfile() is False assert pem_c.public_keyfile() is None assert pem_c.prv_keyfile is False assert pem_c.pub_keyfile is None assert pem_c.private_key() is None assert pem_c.public_key() is None assert pem_c.decrypt(content) is None pem_c = utils.pem.ApprisePEMController( path=None, pub_keyfile="invalid", asset=asset ) assert pem_c.private_keyfile() is None assert pem_c.public_keyfile() is False assert pem_c.prv_keyfile is None assert pem_c.pub_keyfile is False assert pem_c.private_key() is None assert pem_c.public_key() is None assert pem_c.decrypt(content) is None pem_c = utils.pem.ApprisePEMController( path=None, prv_keyfile=prv_keyfile, asset=asset ) assert pem_c.private_keyfile() == prv_keyfile assert pem_c.public_keyfile() is None assert pem_c.private_key() is not None assert pem_c.prv_keyfile == prv_keyfile assert pem_c.pub_keyfile is None assert pem_c.public_key() is not None assert pem_c.decrypt(content) == unencrypted_str pem_c = utils.pem.ApprisePEMController( path=None, pub_keyfile=pub_keyfile, asset=asset ) assert pem_c.private_keyfile() is None assert pem_c.public_keyfile() == pub_keyfile assert pem_c.prv_keyfile is None assert pem_c.pub_keyfile == pub_keyfile assert pem_c.private_key() is None assert pem_c.public_key() is not None assert pem_c.decrypt(content) is None # Test our path references pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_private_key(path=None) is True assert pem_c.private_keyfile() == prv_keyfile assert pem_c.prv_keyfile is None assert pem_c.pub_keyfile is None assert pem_c.decrypt(content) == unencrypted_str # Generate a new key referencing another location pem_c = utils.pem.ApprisePEMController( name="keygen-tests", path=str(tmpdir0), asset=asset ) # generate ourselves some keys assert pem_c.keygen() is True keygen_prv_file = pem_c.prv_keyfile keygen_pub_file = pem_c.pub_keyfile # Remove 1 (but not both) os.unlink(keygen_pub_file) pem_c = utils.pem.ApprisePEMController( name="keygen-tests", path=str(tmpdir0), asset=asset ) # Private key was found, so this does not work assert pem_c.keygen() is False os.unlink(keygen_prv_file) pem_c = utils.pem.ApprisePEMController( name="keygen-tests", path=str(tmpdir0), asset=asset ) # It works now assert pem_c.keygen() is True # Tests public_key generation failure only with mock.patch("builtins.open", side_effect=OSError()): assert pem_c.keygen(force=True) is False with mock.patch("os.unlink", side_effect=OSError()): assert pem_c.keygen(force=True) is False with mock.patch("os.unlink", return_value=True): assert pem_c.keygen(force=True) is False # Tests private key generation side_effect = [mock.mock_open(read_data="file contents").return_value] + [ OSError() for _ in range(10) ] with mock.patch("builtins.open", side_effect=side_effect): assert pem_c.keygen(force=True) is False with ( mock.patch("builtins.open", side_effect=side_effect), mock.patch("os.unlink", side_effect=OSError()), ): assert pem_c.keygen(force=True) is False with ( mock.patch("builtins.open", side_effect=side_effect), mock.patch("os.unlink", return_value=True), ): assert pem_c.keygen(force=True) is False # Generate a new key referencing another location pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) # We can't re-generate keys if ones already exist assert pem_c.keygen() is False # the keygen is the big difference here assert pem_c.keygen(name="test") is True # under the hood, a key is not regenerated (as one already exists) assert pem_c.keygen(name="test") is False # Generate it a second time by force assert pem_c.keygen(name="test", force=True) is True assert pem_c.private_keyfile() == os.path.join( str(tmpdir0), "test-private_key.pem" ) assert pem_c.public_keyfile() == os.path.join( str(tmpdir0), "test-public_key.pem" ) assert pem_c.private_key() is not None assert pem_c.public_key() is not None assert pem_c.prv_keyfile == os.path.join( str(tmpdir0), "test-private_key.pem" ) assert pem_c.pub_keyfile == os.path.join( str(tmpdir0), "test-public_key.pem" ) # 'content' was generated using a different key and can not be # decrypted assert pem_c.decrypt(content) is None # Test Decryption files pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) # Calling decrypt triggers underlining code to auto-load assert pem_c.decrypt(content) == unencrypted_str # Using a private key by path assert ( pem_c.decrypt(content, private_key=pem_c.private_key()) == unencrypted_str ) # Test different edge cases of load_private_key() pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_private_key() is True pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_private_key(path=prv_keyfile) is True pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) with mock.patch("builtins.open", side_effect=TypeError()): assert pem_c.load_private_key(path=prv_keyfile) is False with mock.patch("builtins.open", side_effect=OSError()): assert pem_c.load_private_key(path=prv_keyfile) is False with mock.patch("builtins.open", side_effect=FileNotFoundError()): assert pem_c.load_private_key(path=prv_keyfile) is False # Test different edge cases of load_public_key() pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_public_key() is True pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_public_key(path=pub_keyfile) is True pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) with mock.patch("builtins.open", side_effect=TypeError()): assert pem_c.load_public_key(path=pub_keyfile) is False with mock.patch("builtins.open", side_effect=OSError()): assert pem_c.load_public_key(path=pub_keyfile) is False with mock.patch("builtins.open", side_effect=FileNotFoundError()): assert pem_c.load_public_key(path=pub_keyfile) is False pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.public_keyfile("test1", "test2") == pub_keyfile assert pem_c.private_keyfile("test1", "test2") == prv_keyfile pem_c = utils.pem.ApprisePEMController( path=str(tmpdir0), name="pub1", asset=asset ) assert pem_c.public_key(autogen=True) pem_c = utils.pem.ApprisePEMController( path=str(tmpdir0), name="pub2", asset=asset ) assert pem_c.private_key(autogen=True) # # Auto key generation turned on # asset = AppriseAsset( storage_mode=PersistentStoreMode.MEMORY, storage_path=str(tmpdir0), pem_autogen=True, ) pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) assert pem_c.load_public_key(path=pub_keyfile) is True pem_c = utils.pem.ApprisePEMController(path=None, asset=asset) assert pem_c.load_public_key(path=pub_keyfile) is True tmpdir1 = tmpdir.mkdir("tmp01") # Currently no files here assert os.listdir(str(tmpdir1)) == [] asset = AppriseAsset( storage_mode=PersistentStoreMode.MEMORY, storage_path=str(tmpdir1), pem_autogen=False, ) # Auto-Gen is turned off, so weare not successful here pem_c = utils.pem.ApprisePEMController(path=None, asset=asset) assert pem_c.public_key() is None assert pem_c.private_key() is None pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset) assert pem_c.public_key() is None assert pem_c.private_key() is None asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir1), pem_autogen=True, ) pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset) # Generate ourselves a private key assert pem_c.public_key() is not None assert pem_c.private_key() is not None pub_keyfile = os.path.join(str(tmpdir1), "public_key.pem") prv_keyfile = os.path.join(str(tmpdir1), "private_key.pem") assert os.path.isfile(pub_keyfile) assert os.path.isfile(prv_keyfile) with open(pub_keyfile, "w") as f: f.write("garbage") pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset) # we can still load our data as the public key is generated # from the private assert pem_c.public_key() is not None assert pem_c.private_key() is not None tmpdir2 = tmpdir.mkdir("tmp02") pem_c = utils.pem.ApprisePEMController(path=str(tmpdir2), asset=asset) pub_keyfile = os.path.join(str(tmpdir2), "public_key.pem") prv_keyfile = os.path.join(str(tmpdir2), "private_key.pem") assert not os.path.isfile(pub_keyfile) assert not os.path.isfile(prv_keyfile) # # Public Key Edge Case Tests # with ( mock.patch.object( pem_c, "public_keyfile", side_effect=[None, pub_keyfile] ) as mock_keyfile, mock.patch.object( pem_c, "keygen", side_effect=lambda *_, **__: ( setattr(pem_c, "_ApprisePEMController__public_key", pubkey_ref) or True ), ) as mock_keygen, mock.patch.object(pem_c, "load_public_key", return_value=True), ): result = pem_c.public_key() assert result is pubkey_ref assert mock_keyfile.call_count == 2 mock_keygen.assert_called_once() # - First call: None -> triggers keygen # - Second call (recursive): None -> causes fallback public_keyfile_side_effect = [None, None] with ( mock.patch.object( pem_c, "public_keyfile", side_effect=public_keyfile_side_effect ) as mock_keyfile, mock.patch.object(pem_c, "keygen", return_value=True) as mock_keygen, mock.patch.object( pem_c, "load_public_key", return_value=False ) as mock_load, ): # Ensure no key is preset initially pem_c._ApprisePEMController__public_key = None result = pem_c.public_key() assert result is None # Once in outer call, once in recursive assert mock_keyfile.call_count == 2 mock_keygen.assert_called_once() mock_load.assert_not_called() # # Private Key Edge Case Tests # with ( mock.patch.object( pem_c, "private_keyfile", side_effect=[None, prv_keyfile] ) as mock_keyfile, mock.patch.object( pem_c, "keygen", side_effect=lambda *_, **__: ( setattr( pem_c, "_ApprisePEMController__private_key", prvkey_ref ) or True ), ) as mock_keygen, mock.patch.object(pem_c, "load_private_key", return_value=True), ): result = pem_c.private_key() assert result is prvkey_ref assert mock_keyfile.call_count == 2 mock_keygen.assert_called_once() # - First call: None -> triggers keygen # - Second call (recursive): None -> causes fallback private_keyfile_side_effect = [None, None] with ( mock.patch.object( pem_c, "private_keyfile", side_effect=private_keyfile_side_effect ) as mock_keyfile, mock.patch.object(pem_c, "keygen", return_value=True) as mock_keygen, mock.patch.object( pem_c, "load_private_key", return_value=False ) as mock_load, ): # Ensure no key is preset initially pem_c._ApprisePEMController__private_key = None result = pem_c.private_key() assert result is None # Once in outer call, once in recursive assert mock_keyfile.call_count == 2 mock_keygen.assert_called_once() mock_load.assert_not_called() @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_utils_pem_general_without_c(tmpdir): """Utils:PEM Without cryptography.""" tmpdir0 = tmpdir.mkdir("tmp00") # Currently no files here assert os.listdir(str(tmpdir0)) == [] asset = AppriseAsset( storage_mode=PersistentStoreMode.MEMORY, storage_path=str(tmpdir0), pem_autogen=False, ) # Create a PEM Controller pem_c = utils.pem.ApprisePEMController(path=None, asset=asset) # cryptography library missing poses issues with library useage with pytest.raises(utils.pem.ApprisePEMException): pem_c.public_keyfile() with pytest.raises(utils.pem.ApprisePEMException): pem_c.public_key() with pytest.raises(utils.pem.ApprisePEMException): _ = pem_c.x962_str with pytest.raises(utils.pem.ApprisePEMException): pem_c.encrypt("message") with pytest.raises(utils.pem.ApprisePEMException): pem_c.keygen() asset = AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), pem_autogen=False, ) # No new files assert os.listdir(str(tmpdir0)) == [] # Our asset is now write mode, so we will be able to generate a key pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset) # Nothing to lookup with pytest.raises(utils.pem.ApprisePEMException): pem_c.private_keyfile() with pytest.raises(utils.pem.ApprisePEMException): pem_c.private_key() with pytest.raises(utils.pem.ApprisePEMException): _ = pem_c.x962_str with pytest.raises(utils.pem.ApprisePEMException): pem_c.encrypt("message") # Keys can not be generated in memory mode with pytest.raises(utils.pem.ApprisePEMException): pem_c.keygen() # No files loaded assert os.listdir(str(tmpdir0)) == [] with pytest.raises(utils.pem.ApprisePEMException): pem_c.public_keyfile() with pytest.raises(utils.pem.ApprisePEMException): pem_c.public_key() with pytest.raises(utils.pem.ApprisePEMException): _ = pem_c.x962_str with pytest.raises(utils.pem.ApprisePEMException): pem_c.encrypt("message") with pytest.raises(utils.pem.ApprisePEMException): pem_c.decrypt("abcd==") caronc-apprise-182f859/tests/test_utils_pgp.py000066400000000000000000000772161524161175200215120ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output import logging import os import sys from unittest import mock import pytest from apprise.utils import pgp as pgp_module from apprise.utils.pgp import ApprisePGPController, _ensure_imghdr_shim logging.disable(logging.CRITICAL) # Path to the pre-generated private key fixture used by signing tests _VAR_DIR = os.path.join(os.path.dirname(__file__), "var", "pgp") _VALID_PRV_ASC = os.path.join(_VAR_DIR, "valid-prv.asc") # --------------------------------------------------------------------------- # imghdr shim # --------------------------------------------------------------------------- def test_imghdr_shim_when_present(): """_ensure_imghdr_shim() leaves sys.modules intact when imghdr imports.""" # Guarantee imghdr is importable for this test real_imghdr = sys.modules.get("imghdr") try: import imghdr as _real # If we got here, imghdr is available on this Python version _ensure_imghdr_shim() # sys.modules entry must not have been replaced by our shim assert sys.modules.get("imghdr") is _real except ImportError: # Python 3.13+ -- imghdr is gone; skip this branch pytest.skip("imghdr not present on this Python version") finally: # Restore whatever was there before if real_imghdr is None: sys.modules.pop("imghdr", None) else: sys.modules["imghdr"] = real_imghdr def test_imghdr_shim_when_absent(): """_ensure_imghdr_shim() installs a working shim when imghdr is missing.""" # Remove any real imghdr from sys.modules and make import fail saved = sys.modules.pop("imghdr", None) try: # Setting the key to None causes 'import imghdr' to raise ImportError sys.modules["imghdr"] = None _ensure_imghdr_shim() shim = sys.modules.get("imghdr") assert shim is not None assert callable(shim.what) # The shim must return None (safe fallback for all inputs) assert shim.what() is None assert shim.what(file=None, h=b"\xff\xd8\xff") is None assert shim.what(None, h=b"some data") is None finally: # Restore original state if saved is None: sys.modules.pop("imghdr", None) else: sys.modules["imghdr"] = saved # --------------------------------------------------------------------------- # ApprisePGPController -- WKD integration # --------------------------------------------------------------------------- def test_pgp_controller_wkd_none_by_default(tmpdir): """ApprisePGPController has wkd=None unless explicitly provided.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.wkd is None def test_pgp_controller_accepts_wkd(tmpdir): """ApprisePGPController stores the WKD controller it is given.""" mock_wkd = mock.Mock() ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) assert ctrl.wkd is mock_wkd def test_fetch_wkd_key_no_controller(tmpdir): """_fetch_wkd_key() returns None when no WKD controller is set.""" ctrl = ApprisePGPController(path=str(tmpdir)) result = ctrl._fetch_wkd_key("user@example.com") assert result is None def test_fetch_wkd_key_empty_emails(tmpdir): """_fetch_wkd_key() returns None when no email candidates exist.""" mock_wkd = mock.Mock() ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # No self.email set and no positional emails result = ctrl._fetch_wkd_key() assert result is None mock_wkd.fetch.assert_not_called() def test_fetch_wkd_key_skips_none_in_candidate_list(tmpdir): """_fetch_wkd_key() skips None/empty entries in the email candidates.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = None ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # None is a valid positional argument; it must be skipped, not crash result = ctrl._fetch_wkd_key(None, "user@example.com") assert result is None # fetch must only be called for the valid address, not for None mock_wkd.fetch.assert_called_once_with("user@example.com") def test_fetch_wkd_key_wkd_returns_none(tmpdir): """_fetch_wkd_key() returns None when WKD fetch yields nothing.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = None ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) result = ctrl._fetch_wkd_key("user@example.com") assert result is None @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_fetch_wkd_key_success(tmpdir): """_fetch_wkd_key() parses and caches a key returned by WKD.""" import pgpy # Generate a fresh key pair to use as our fake WKD payload key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Test", email="user@example.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() mock_wkd = mock.Mock() mock_wkd.fetch.return_value = pub_bytes ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) result = ctrl._fetch_wkd_key("user@example.com") assert result is not None assert isinstance(result, pgpy.PGPKey) mock_wkd.fetch.assert_called_once_with("user@example.com") # Second call must use the parsed-key cache -- wkd.fetch must NOT be # called again (the cache check now happens before wkd.fetch()) mock_wkd.fetch.reset_mock() result2 = ctrl._fetch_wkd_key("user@example.com") assert result2 is not None mock_wkd.fetch.assert_not_called() @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_fetch_wkd_key_expired_cache_refetches(tmpdir): """_fetch_wkd_key() drops an expired parsed-key cache entry and re-fetches from WKD rather than returning the stale key.""" from datetime import datetime, timedelta, timezone import hashlib import pgpy # Generate a key to serve as the fresh WKD result key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Fresh", email="user@example.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() mock_wkd = mock.Mock() mock_wkd.fetch.return_value = pub_bytes ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # Seed the parsed-key cache with an already-expired entry cache_key = hashlib.sha1(b"wkd:user@example.com").hexdigest() # Use the private name-mangled attribute ctrl._ApprisePGPController__key_lookup[cache_key] = { "public_key": None, "expires": datetime.now(timezone.utc) - timedelta(seconds=1), } # The expired entry is discarded; WKD is fetched and result returned result = ctrl._fetch_wkd_key("user@example.com") assert result is not None mock_wkd.fetch.assert_called_once_with("user@example.com") @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_fetch_wkd_key_prefers_recipient_over_sender(tmpdir): """_fetch_wkd_key() tries recipient emails before self.email so that the recipient's public key (not the sender's) is used for encryption.""" import pgpy # Generate a key to represent the recipient's WKD result key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Recipient", email="recipient@example.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() # WKD returns a key only for the recipient address def fake_fetch(email): if email == "recipient@example.com": return pub_bytes return None mock_wkd = mock.Mock(side_effect=fake_fetch) mock_wkd.fetch.side_effect = fake_fetch ctrl = ApprisePGPController( path=str(tmpdir), email="sender@example.com", wkd=mock_wkd, ) # The recipient's email is passed as a positional argument result = ctrl._fetch_wkd_key("recipient@example.com") assert result is not None # sender@example.com must NOT have been tried before the recipient hit calls = [call.args[0] for call in mock_wkd.fetch.call_args_list] assert calls[0] == "recipient@example.com" assert "sender@example.com" not in calls @pytest.mark.skipif( pgp_module.PGP_SUPPORT, reason="Tests behavior when pgpy is absent" ) def test_fetch_wkd_key_pgpy_not_installed(tmpdir): """_fetch_wkd_key() skips silently when pgpy raises NameError.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = b"fake-key-bytes" ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # In a minimal env pgpy is not installed, so pgpy.PGPKey.from_blob # raises NameError naturally -- no mock needed. result = ctrl._fetch_wkd_key("user@example.com") assert result is None @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_fetch_wkd_key_corrupt_data(tmpdir): """_fetch_wkd_key() skips and tries next candidate on parse failure.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = b"this-is-not-a-pgp-key" ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # Patch the canonical pgpy module directly (not the pgp module's copy) # so the mock works whether pgpy was imported by name or attribute. with mock.patch( "pgpy.PGPKey.from_blob", side_effect=Exception("bad key"), ): result = ctrl._fetch_wkd_key("user@example.com") assert result is None def test_fetch_wkd_key_uses_self_email_as_candidate(tmpdir): """_fetch_wkd_key() includes self.email in the candidate list.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = None ctrl = ApprisePGPController( path=str(tmpdir), email="sender@example.com", wkd=mock_wkd ) ctrl._fetch_wkd_key() # self.email must have been tried mock_wkd.fetch.assert_called_with("sender@example.com") @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_public_key_falls_through_to_wkd(tmpdir): """public_key() uses WKD when no local key file exists.""" import pgpy # Generate a minimal key for the mock WKD response key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("WKD User", email="wkd@example.com") key.add_uid( uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications, }, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) pub_bytes = str(key.pubkey).encode() mock_wkd = mock.Mock() mock_wkd.fetch.return_value = pub_bytes ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # No local key file exists, so WKD must be tried result = ctrl.public_key("wkd@example.com", autogen=False) assert result is not None mock_wkd.fetch.assert_called() @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_public_key_wkd_fails_falls_through_to_autogen(tmpdir): """public_key() falls through to autogen when WKD also returns None.""" mock_wkd = mock.Mock() mock_wkd.fetch.return_value = None ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) # No local key file exists; WKD fails; autogen=False => None returned result = ctrl.public_key("nobody@example.com", autogen=False) assert result is None mock_wkd.fetch.assert_called() @pytest.mark.skipif(not pgp_module.PGP_SUPPORT, reason="Requires PGPy") def test_public_key_malformed_local_file_returns_none(tmpdir): """public_key() returns None gracefully when the local .asc file is not valid PGP data (pgpy raises during parse).""" # Write a file with non-PGP content under a name that public_keyfile() # will discover automatically bad_key_path = tmpdir.join("pub.asc") bad_key_path.write("this is not a pgp key\n") ctrl = ApprisePGPController(path=str(tmpdir)) # No autogen so the only candidate is the malformed file above result = ctrl.public_key(autogen=False) assert result is None # --------------------------------------------------------------------------- # prune() -- WKD delegation # --------------------------------------------------------------------------- def test_prune_delegates_to_wkd(tmpdir): """prune() calls prune() on the WKD controller when one is set.""" mock_wkd = mock.Mock() ctrl = ApprisePGPController(path=str(tmpdir), wkd=mock_wkd) ctrl.prune() mock_wkd.prune.assert_called_once() def test_prune_without_wkd(tmpdir): """prune() runs without error when no WKD controller is set.""" ctrl = ApprisePGPController(path=str(tmpdir)) ctrl.prune() # must not raise # --------------------------------------------------------------------------- # PGP_SUPPORT flag consistency # --------------------------------------------------------------------------- def test_pgp_support_flag_present(): """PGP_SUPPORT is a boolean exported from the pgp module.""" assert isinstance(pgp_module.PGP_SUPPORT, bool) # --------------------------------------------------------------------------- # private_keyfile() -- path resolution # --------------------------------------------------------------------------- def test_private_keyfile_no_path(): """private_keyfile() returns None when neither path nor prv_keyfile set.""" ctrl = ApprisePGPController(path=None) assert ctrl.private_keyfile() is None def test_private_keyfile_explicit_valid(tmpdir): """private_keyfile() returns a path for a valid explicit prv_keyfile.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) result = ctrl.private_keyfile() # Should be an existing file path assert result and os.path.isfile(result) def test_private_keyfile_explicit_invalid(tmpdir): """private_keyfile() returns False when an explicit file is unreachable.""" ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile="/nonexistent/path/key.asc", ) # The attachment itself is invalid, so the property returns False result = ctrl.private_keyfile() assert result is False def test_private_keyfile_auto_discover_by_email(tmpdir): """private_keyfile() finds an auto-generated key named by email prefix.""" # Place a fake private key named after the email prefix key_path = str(tmpdir.join("user-prv.asc")) with open(key_path, "w") as f: f.write("dummy") ctrl = ApprisePGPController(path=str(tmpdir), email="user@example.com") result = ctrl.private_keyfile() assert result == key_path def test_private_keyfile_fallback_generic_name(tmpdir): """private_keyfile() falls back to pgp-prv.asc when no email-named key.""" key_path = str(tmpdir.join("pgp-prv.asc")) with open(key_path, "w") as f: f.write("dummy") ctrl = ApprisePGPController(path=str(tmpdir)) result = ctrl.private_keyfile() assert result == key_path def test_private_keyfile_not_found(tmpdir): """private_keyfile() returns None when no private key exists on disk.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.private_keyfile() is None def test_prv_keyfile_property_none_when_not_set(tmpdir): """prv_keyfile property returns None when no explicit key was provided.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.prv_keyfile is None def test_prv_keyfile_property_path_when_valid(tmpdir): """prv_keyfile property returns the resolved path for a valid key.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) # Property accesses the attachment; it may return None before loading # because the attachment is resolved lazily -- just confirm it is not False assert ctrl.prv_keyfile is not False def test_prv_keyfile_property_false_when_invalid(tmpdir): """prv_keyfile property returns False for an unreachable explicit file.""" ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile="/no/such/file.asc", ) assert ctrl.prv_keyfile is False # --------------------------------------------------------------------------- # private_key() -- loading the private PGP key object # --------------------------------------------------------------------------- @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_private_key_loads_valid_fixture(tmpdir): """private_key() returns a PGPKey object from a valid private key file.""" import pgpy ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) key = ctrl.private_key() assert key is not None assert isinstance(key, pgpy.PGPKey) @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_private_key_cached_on_second_call(tmpdir): """private_key() returns the same object on repeated calls (cached).""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) first = ctrl.private_key() second = ctrl.private_key() assert first is not None assert first is second def test_private_key_returns_none_when_not_found(tmpdir): """private_key() returns None when no private key file exists.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.private_key() is None @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_private_key_expired_cache_reloads(tmpdir): """private_key() drops an expired cache entry and reloads from disk.""" from datetime import datetime, timedelta, timezone import hashlib ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) # Load the key once to prime the cache first = ctrl.private_key() assert first is not None # Artificially expire the cached entry by back-dating its expiry time cache_key = hashlib.sha1( ("prv:" + os.path.abspath(_VALID_PRV_ASC)).encode("utf-8") ).hexdigest() ctrl._ApprisePGPController__key_lookup[cache_key]["expires"] = ( datetime.now(timezone.utc) - timedelta(seconds=1) ) # Second call must detect the expiry and re-load from disk second = ctrl.private_key() assert second is not None def test_private_key_returns_none_when_pgpy_missing(tmpdir): """private_key() returns None gracefully when PGPy is not installed.""" import contextlib ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) with contextlib.ExitStack() as stack: # Force PGP_SUPPORT=True so the key-loading branch is reached stack.enter_context(mock.patch.object(pgp_module, "PGP_SUPPORT", True)) # Supply any readable content for the open() call stack.enter_context( mock.patch("builtins.open", mock.mock_open(read_data="data")) ) if "pgpy" in sys.modules: # When pgpy IS installed, simulate its absence by making # from_blob raise NameError (same as if the name were undefined) stack.enter_context( mock.patch("pgpy.PGPKey.from_blob", side_effect=NameError) ) # Without pgpy installed the NameError arises naturally at the # 'pgpy.PGPKey.from_blob(...)' call in private_key(); either way # private_key() must swallow the error and return None result = ctrl.private_key() assert result is None def test_private_key_returns_none_when_keyfile_inaccessible(tmpdir): """private_key() returns None when private_keyfile() returns False.""" # Provide an invalid path so the attachment reports the file as unreachable ctrl = ApprisePGPController( path=str(tmpdir), prv_keyfile="/no/such/file.asc" ) # private_keyfile() will return False; private_key() must not raise result = ctrl.private_key() assert result is None def test_private_key_returns_none_on_io_error(tmpdir): """private_key() returns None when the file cannot be read.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) with mock.patch("builtins.open", side_effect=OSError("disk error")): result = ctrl.private_key() assert result is None def test_private_key_returns_none_on_malformed_key(tmpdir): """private_key() returns None when the file contains non-PGP data.""" bad_key = str(tmpdir.join("bad-prv.asc")) with open(bad_key, "w") as f: f.write("this is not a pgp key") ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=bad_key) result = ctrl.private_key() assert result is None def test_private_key_returns_none_when_file_disappears(tmpdir): """private_key() handles a key file that vanishes between stat and open.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) with mock.patch("builtins.open", side_effect=FileNotFoundError("gone")): result = ctrl.private_key() assert result is None @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_private_key_rejects_passphrase_protected(tmpdir): """private_key() returns None for a passphrase-protected key.""" import pgpy # Generate a fresh key and protect it with a passphrase key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048 ) uid = pgpy.PGPUID.new("Locked", email="locked@example.com") key.add_uid( uid, usage={pgpy.constants.KeyFlags.Sign}, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB], ) key.protect( "s3cr3t", pgpy.constants.SymmetricKeyAlgorithm.AES256, pgpy.constants.HashAlgorithm.SHA256, ) locked_path = str(tmpdir.join("locked-prv.asc")) with open(locked_path, "w") as f: f.write(str(key)) ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=locked_path) result = ctrl.private_key() assert result is None # --------------------------------------------------------------------------- # sign() -- creating detached PGP signatures # --------------------------------------------------------------------------- @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_sign_returns_signature_and_micalg(tmpdir): """sign() returns a (sig_str, micalg) tuple for valid message + key.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) result = ctrl.sign("Hello, World!") assert result is not None sig_str, micalg = result # The signature must be a non-empty armored block assert "BEGIN PGP SIGNATURE" in sig_str # The micalg must start with 'pgp-' assert micalg.startswith("pgp-") def test_sign_returns_none_when_no_private_key(tmpdir): """sign() returns None when no private key is available.""" ctrl = ApprisePGPController(path=str(tmpdir)) assert ctrl.sign("test message") is None @pytest.mark.skipif("pgpy" not in sys.modules, reason="Requires PGPy") def test_sign_returns_none_on_pgp_error(tmpdir): """sign() returns None when pgpy raises PGPError during signing.""" import pgpy ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) with mock.patch.object( ctrl.private_key().__class__, "sign", side_effect=pgpy.errors.PGPError("fail"), ): result = ctrl.sign("test") assert result is None def test_sign_returns_none_when_pgpy_missing(tmpdir): """sign() returns None gracefully when PGPy is not installed.""" ctrl = ApprisePGPController(path=str(tmpdir), prv_keyfile=_VALID_PRV_ASC) # Patch private_key() to return a mock that raises NameError on sign() mock_key = mock.Mock() mock_key.sign.side_effect = NameError("pgpy not installed") with mock.patch.object(ctrl, "private_key", return_value=mock_key): result = ctrl.sign("test") assert result is None # --------------------------------------------------------------------------- # _pub_key_candidates() -- candidate filename ordering # --------------------------------------------------------------------------- def test_pub_key_candidates_no_email(tmpdir): """_pub_key_candidates() returns the four generic fallbacks when no email is known and no extra addresses are supplied.""" ctrl = ApprisePGPController(path=str(tmpdir)) # No self.email and no caller-supplied email candidates = ctrl._pub_key_candidates() # Only the four generic names should appear assert candidates == [ "pgp-public.asc", "pgp-pub.asc", "public.asc", "pub.asc", ] def test_pub_key_candidates_with_email(tmpdir): """_pub_key_candidates() prepends email-specific names ahead of generics, highest priority first (full-email before localpart).""" ctrl = ApprisePGPController(path=str(tmpdir), email="Chris@Example.com") candidates = ctrl._pub_key_candidates() # Full lowercase email is highest priority (index 0) assert candidates[0] == "chris@example.com-pub.asc" # Localpart shorthand is next assert candidates[1] == "chris-pub.asc" # Generic fallbacks follow assert "pgp-pub.asc" in candidates def test_pub_key_candidates_extra_email(tmpdir): """_pub_key_candidates() gives caller-supplied addresses higher priority than self.email because a per-recipient key lookup should prefer the recipient-specific file over the controller's own identity key.""" ctrl = ApprisePGPController(path=str(tmpdir), email="sender@example.com") # Simulate a lookup for a recipient address candidates = ctrl._pub_key_candidates("recipient@example.com") # Caller-supplied (recipient) address is processed last in the loop, # so its entries end up at the front of the list (highest priority) assert candidates[0] == "recipient@example.com-pub.asc" assert candidates[1] == "recipient-pub.asc" # self.email (sender) entries appear after assert "sender@example.com-pub.asc" in candidates assert "sender-pub.asc" in candidates # --------------------------------------------------------------------------- # _prv_key_candidates() -- candidate filename ordering # --------------------------------------------------------------------------- def test_prv_key_candidates_no_email(tmpdir): """_prv_key_candidates() returns the four generic fallbacks when no sender email is configured.""" ctrl = ApprisePGPController(path=str(tmpdir)) candidates = ctrl._prv_key_candidates() assert candidates == [ "pgp-private.asc", "pgp-prv.asc", "private.asc", "prv.asc", ] def test_prv_key_candidates_with_email(tmpdir): """_prv_key_candidates() prepends email-specific names ahead of generics, highest priority first (full-email before localpart).""" ctrl = ApprisePGPController(path=str(tmpdir), email="Sender@Nuxref.com") candidates = ctrl._prv_key_candidates() # Full lowercase email is highest priority assert candidates[0] == "sender@nuxref.com-prv.asc" # Localpart shorthand is next assert candidates[1] == "sender-prv.asc" # Generic fallbacks follow assert "pgp-prv.asc" in candidates # --------------------------------------------------------------------------- # public_key() -- diagnostic warning when no key is found # --------------------------------------------------------------------------- def test_public_key_no_key_emits_path_debug(tmpdir): """public_key() emits a DEBUG message listing relative cache paths when no public key is found and autogen is disabled. The message is debug- level so the caller's warning remains the only user-visible line.""" ctrl = ApprisePGPController(path=str(tmpdir), email="user@example.com") logging.disable(logging.NOTSET) try: with mock.patch.object(pgp_module, "PGP_SUPPORT", True): result = ctrl.public_key("user@example.com", autogen=False) finally: logging.disable(logging.CRITICAL) # Key not found; result must be None assert result is None def test_public_key_no_key_no_path_emits_generic_debug(): """public_key() emits a generic 'No PGP public key found' debug message in memory-only mode (no path to search, so no filenames to list).""" ctrl = ApprisePGPController(path=None) logging.disable(logging.NOTSET) try: with mock.patch.object(pgp_module, "PGP_SUPPORT", True): result = ctrl.public_key("user@example.com", autogen=False) finally: logging.disable(logging.CRITICAL) assert result is None # --------------------------------------------------------------------------- # private_key() -- diagnostic debug message when no key is found # --------------------------------------------------------------------------- def test_private_key_not_found_emits_path_debug(tmpdir): """private_key() emits a DEBUG message listing searched relative paths when no private key file exists on disk. The message is debug-level so the caller's warning remains the only user-visible line.""" ctrl = ApprisePGPController(path=str(tmpdir), email="sender@example.com") logging.disable(logging.NOTSET) try: result = ctrl.private_key() finally: logging.disable(logging.CRITICAL) assert result is None def test_private_key_not_found_no_path_emits_generic_debug(): """private_key() emits a generic 'No PGP private key found' debug message in memory-only mode (no storage path, nothing to search).""" ctrl = ApprisePGPController(path=None) logging.disable(logging.NOTSET) try: result = ctrl.private_key() finally: logging.disable(logging.CRITICAL) assert result is None caronc-apprise-182f859/tests/test_utils_sanitize.py000066400000000000000000000172411524161175200225420ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Unit tests for :mod:`apprise.utils.sanitize`.""" from __future__ import annotations from hashlib import sha256 import logging import sys import pytest from apprise.utils.sanitize import SanitizeOptions, sanitize_payload # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) sys.dont_write_bytecode = True class _ReprOnly: """Helper type that only supports repr(), used to cover fallback paths.""" def __repr__(self) -> str: return "" def test_sanitize_payload_passthrough_primitives() -> None: """Primitives should pass through with minimal transformation.""" assert sanitize_payload(None) is None assert sanitize_payload(True) is True assert sanitize_payload(False) is False assert sanitize_payload(123) == 123 assert sanitize_payload(3.14) == pytest.approx(3.14) def test_sanitize_payload_small_string_passthrough() -> None: """Small strings are not altered, preserving debugging usefulness.""" s = "hello world" assert sanitize_payload(s) == s def test_sanitize_payload_large_string_is_summarized() -> None: """Strings beyond max_str_len are summarized with head/tail previews.""" opts = SanitizeOptions(max_str_len=64, preview=8) s = "x" * 200 out = sanitize_payload(s, options=opts) assert isinstance(out, str) assert out.startswith(" None: """Bytes keys become readable string markers rather than raw bytes.""" opts = SanitizeOptions(hash_sample_size=16) bkey = b"abc" payload = { bkey: "value1", "k": "v", } out = sanitize_payload(payload, options=opts) assert isinstance(out, dict) expected_b_digest = sha256(bkey[: opts.hash_sample_size]).hexdigest()[:12] bkey_s = f"" assert bkey_s in out assert out[bkey_s] == "value1" assert out["k"] == "v" def test_sanitize_payload_sequence_types() -> None: """Lists, tuples, sets, and frozensets are walked safely.""" payload = [1, "x" * 200, b"xyz"] opts = SanitizeOptions(max_str_len=64, preview=8) out = sanitize_payload(payload, options=opts) assert isinstance(out, list) assert out[0] == 1 assert isinstance(out[1], str) and out[1].startswith(" None: """Self-referential objects should not trigger infinite recursion.""" payload: dict[str, object] = {} payload["self"] = payload out = sanitize_payload(payload) assert isinstance(out, dict) assert out["self"] == "" def test_sanitize_payload_max_depth_truncation() -> None: """Depth limits protect against overly deep nested structures.""" opts = SanitizeOptions(max_depth=2) payload = {"a": {"b": {"c": "d"}}} out = sanitize_payload(payload, options=opts) assert isinstance(out, dict) assert isinstance(out["a"], dict) assert isinstance(out["a"]["b"], dict) assert out["a"]["b"]["c"] == "" def test_sanitize_payload_max_items_truncation_in_list_branch() -> None: """Item limits protect against very large sequences.""" opts = SanitizeOptions(max_items=2) payload = [1, 2, 3, 4] out = sanitize_payload(payload, options=opts) assert isinstance(out, list) assert out[-1] == "" def test_sanitize_payload_global_item_limit_guard_message() -> None: """The global max_items guard stops work consistently.""" opts = SanitizeOptions(max_items=1) payload = {"a": "x", "b": "y"} out = sanitize_payload(payload, options=opts) assert isinstance(out, dict) assert any( v == "" or v == "..." for v in out.values() ) def test_sanitize_payload_falls_back_to_repr_for_unknown_objects() -> None: """Unknown objects are converted with repr() for logging.""" assert sanitize_payload(_ReprOnly()) == "" def test_sanitize_payload_blob_key_enables_blob_mode_and_summarizes() -> None: """Blob-like keys enable blob_mode and trigger .""" opts = SanitizeOptions( aggressive_blob_keys=True, # ensure normal summarization would NOT trigger max_str_len=999999, preview=8, ) payload = { # small string, but blob_mode forces summary "base64_attachments": "ABCDEF", } out = sanitize_payload(payload, options=opts) assert isinstance(out, dict) assert "base64_attachments" in out assert isinstance(out["base64_attachments"], str) assert out["base64_attachments"].startswith(" None: """ Verify non-string, non-bytes dictionary keys are preserved unchanged. This exercises the fallback path in _summarize_key(), ensuring unexpected key types (such as integers) are passed through safely during sanitisation. """ payload = { # int key triggers the "return k" branch 42: "value", # keep a normal key too "k": "v", } out = sanitize_payload(payload) assert isinstance(out, dict) assert 42 in out assert out[42] == "value" assert out["k"] == "v" caronc-apprise-182f859/tests/test_utils_socket.py000066400000000000000000001024571524161175200222100ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import ssl from unittest import mock import pytest from apprise.exception import AppriseException, AppriseInvalidData from apprise.utils.socket import AppriseSocketError, SocketTransport # Disable logging for a cleaner testing output logging.disable(logging.CRITICAL) class _DummyFile: def __init__(self) -> None: self.flushed = 0 self.closed = 0 def flush(self) -> None: self.flushed += 1 def close(self) -> None: self.closed += 1 class _DummySocket: """ Minimal fake socket object, supports the subset used by SocketTransport. """ def __init__(self) -> None: self.blocking = True self.timeout = None self.closed = False self.shutdown_called = 0 self._recv_side_effect = None self._send_side_effect = None def setsockopt(self, *_args, **_kwargs) -> None: return None def bind(self, *_args, **_kwargs) -> None: return None def fileno(self) -> int: return 1 def settimeout(self, value): self.timeout = value def setblocking(self, value): self.blocking = bool(value) def connect(self, *_args, **_kwargs) -> None: return None def shutdown(self, *_args, **_kwargs) -> None: self.shutdown_called += 1 def close(self) -> None: self.closed = True def getsockname(self): return ("127.0.0.1", 12345) def getpeername(self): return ("203.0.113.10", 6667) def makefile(self, *_args, **_kwargs): return _DummyFile() def recv(self, *_args, **_kwargs): if self._recv_side_effect is not None: raise self._recv_side_effect return b"data" def send(self, *_args, **_kwargs): if self._send_side_effect is not None: raise self._send_side_effect return 4 def test_utils_socket_timeout_coerce(): """SocketTransport() Timeout Coercion.""" # None => None, None s = SocketTransport("example.com", 1, timeout=None) assert s._connect_timeout is None assert s._read_timeout is None # float => both s = SocketTransport("example.com", 1, timeout=2.5) assert s._connect_timeout == pytest.approx(2.5) assert s._read_timeout == pytest.approx(2.5) # tuple => (connect, read) s = SocketTransport("example.com", 1, timeout=(1.0, 3.0)) assert s._connect_timeout == pytest.approx(1.0) assert s._read_timeout == pytest.approx(3.0) # tuple with None is allowed s = SocketTransport("example.com", 1, timeout=(None, 3.0)) assert s._connect_timeout is None assert s._read_timeout == pytest.approx(3.0) # invalid types with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout="bad") with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout=(1.0, 2.0, 3.0)) with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout=-1.0) def test_utils_socket_properties_and_close_paths(): """SocketTransport() Properties And Close Paths.""" s = SocketTransport("example.com", 1) assert s.connected is False assert s.is_tls is False # Seed with dummy socket + wrappers s._sock = _DummySocket() s._rfile = _DummyFile() s._wfile = _DummyFile() s._is_tls = True s.local_addr = ("1.1.1.1", 1) s.remote_addr = ("2.2.2.2", 2) s.close() assert s._sock is None assert s._rfile is None assert s._wfile is None assert s._is_tls is False assert s.local_addr is None assert s.remote_addr is None def test_utils_socket_read_write_no_socket(): """SocketTransport() Can Read/Write - No Socket.""" s = SocketTransport("example.com", 1) assert s.can_read() is None assert s.can_write() is None def test_utils_socket_can_read_write_select_error(): """SocketTransport() Can Read/Write - select() Error.""" s = SocketTransport("example.com", 1) s._sock = _DummySocket() with mock.patch("select.select", side_effect=OSError()): assert s.can_read() is None assert s._sock is None s._sock = _DummySocket() with mock.patch("select.select", side_effect=OSError()): assert s.can_write() is None assert s._sock is None def test_utils_socket_can_read_write_socket_close(): """SocketTransport() Can Read/Write - Socket Close.""" s = SocketTransport("example.com", 1) s._sock = _DummySocket() # Exceptional socket list returned triggers close() with mock.patch("select.select", return_value=([], [], [s._sock])): assert s.can_read() is None assert s._sock is None s._sock = _DummySocket() with mock.patch("select.select", return_value=([], [], [s._sock])): assert s.can_write() is None assert s._sock is None def test_utils_socket_server_hostname_for_tls(): """SocketTransport() Server Hostname For TLS.""" s = SocketTransport("irc.example.com", 1, verify=True) assert s._server_hostname_for_tls() == "irc.example.com" s = SocketTransport("irc.example.com", 1, verify=False) assert s._server_hostname_for_tls() == "irc.example.com" def test_utils_socket_server_hostname_for_tls_ip_reverse(): """SocketTransport() Server Hostname For TLS IP Reverse DNS""" s = SocketTransport("203.0.113.10", 1, verify=True) with mock.patch( "socket.gethostbyaddr", return_value=("irc.example.com.", [], []) ): assert s._server_hostname_for_tls() == "irc.example.com" with mock.patch("socket.gethostbyaddr", side_effect=OSError()): assert s._server_hostname_for_tls() == "203.0.113.10" # verify=False => no lookup s = SocketTransport("203.0.113.10", 1, verify=False) with mock.patch("socket.gethostbyaddr") as m: assert s._server_hostname_for_tls() == "203.0.113.10" m.assert_not_called() def test_utils_socket_build_ssl_context(): """SocketTransport() Build SSL Context.""" class _DummySSLContext: def __init__(self, *, raise_on_minimum: bool) -> None: self.raise_on_minimum = raise_on_minimum self.options = 0 self.check_hostname = None self.verify_mode = None self._minimum_version = None @property def minimum_version(self): return self._minimum_version @minimum_version.setter def minimum_version(self, value): if self.raise_on_minimum: raise RuntimeError("minimum_version not supported") self._minimum_version = value # verify=True path with minimum_version support s = SocketTransport("example.com", 1, verify=True) ctx = _DummySSLContext(raise_on_minimum=False) with ( mock.patch("certifi.where", return_value="/tmp/ca.pem"), mock.patch("ssl.create_default_context", return_value=ctx) as m, ): result = s._build_ssl_context() assert result is ctx m.assert_called_once() assert ctx.check_hostname is True assert ctx.verify_mode == ssl.CERT_REQUIRED # TLS 1.2+ preferred path assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2 # Compression disabled when supported if hasattr(ssl, "OP_NO_COMPRESSION"): assert (ctx.options & ssl.OP_NO_COMPRESSION) != 0 # verify=False path still enforces TLS 1.2+ s = SocketTransport("example.com", 1, verify=False) ctx = _DummySSLContext(raise_on_minimum=False) with mock.patch("ssl.create_default_context", return_value=ctx): result = s._build_ssl_context() assert result is ctx assert ctx.check_hostname is False assert ctx.verify_mode == ssl.CERT_NONE assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2 # Fallback path: minimum_version setter fails, options are used instead s = SocketTransport("example.com", 1, verify=True) ctx = _DummySSLContext(raise_on_minimum=True) with ( mock.patch("certifi.where", return_value="/tmp/ca.pem"), mock.patch("ssl.create_default_context", return_value=ctx), ): result = s._build_ssl_context() assert result is ctx # minimum_version was not set assert ctx.minimum_version is None # Fallback disables older protocols when options are present if hasattr(ssl, "OP_NO_TLSv1"): assert (ctx.options & ssl.OP_NO_TLSv1) != 0 if hasattr(ssl, "OP_NO_TLSv1_1"): assert (ctx.options & ssl.OP_NO_TLSv1_1) != 0 def test_utils_socket_start_tls_no_socket_raises(): """SocketTransport() Start TLS No Socket Raises.""" s = SocketTransport("example.com", 1, secure=True) with pytest.raises(AppriseSocketError): s.start_tls() def test_utils_socket_start_tls_already_tls_noop(): """SocketTransport() Start TLS Already TLS Noop.""" s = SocketTransport("example.com", 1, secure=True) s._sock = _DummySocket() s._is_tls = True s.start_tls() assert s._sock is not None assert s._is_tls is True def test_utils_socket_tls_ssl_init_errors(): """SocketTransport() Start TLS/SSL Initialization Errors.""" s = SocketTransport("example.com", 1, secure=True) s._sock = _DummySocket() with mock.patch.object(s, "_build_ssl_context") as mctx: ctx = mock.Mock() ctx.wrap_socket.side_effect = ssl.SSLError("boom") mctx.return_value = ctx with pytest.raises(AppriseSocketError): s.start_tls() assert s._sock is None s._sock = _DummySocket() with mock.patch.object(s, "_build_ssl_context") as mctx: ctx = mock.Mock() ctx.wrap_socket.side_effect = OSError("boom") mctx.return_value = ctx with pytest.raises(AppriseSocketError): s.start_tls() assert s._sock is None def test_utils_socket_tls_tests(): """SocketTransport() TLS Coverage""" s = SocketTransport("example.com", 1, secure=True) base_sock = _DummySocket() s._sock = base_sock tls_sock = _DummySocket() ctx = mock.Mock() ctx.wrap_socket.return_value = tls_sock with ( mock.patch.object(s, "_build_ssl_context", return_value=ctx), mock.patch.object( s, "_server_hostname_for_tls", return_value="example.com" ), ): s.start_tls() assert s._sock is tls_sock assert s.is_tls is True assert s.local_addr == ("127.0.0.1", 12345) assert s.remote_addr == ("203.0.113.10", 6667) assert s._rfile is not None assert s._wfile is not None def test_utils_socket_connect(): """SocketTransport() Connect() Coverage.""" s = SocketTransport("example.com", 6667, secure=False, timeout=(1.0, 2.0)) dummy = _DummySocket() with mock.patch("socket.socket", return_value=dummy): s.connect() assert s.connected is True assert s.is_tls is False assert s.local_addr == ("127.0.0.1", 12345) assert s.remote_addr == ("203.0.113.10", 6667) # Failure path: connect raises s = SocketTransport("example.com", 6667, secure=False, timeout=(1.0, 2.0)) dummy = _DummySocket() dummy.connect = mock.Mock(side_effect=OSError("fail")) with mock.patch("socket.socket", return_value=dummy): with pytest.raises(AppriseSocketError): s.connect() assert s.connected is False def test_utils_socket_read_nonblocking(): """SocketTransport() Read Nonblocking.""" s = SocketTransport("example.com", 1) sock = _DummySocket() s._sock = sock # Non-blocking success assert s.read(blocking=False) == b"data" # Non-blocking no data sock._recv_side_effect = BlockingIOError() assert s.read(blocking=False) == b"" # Non-blocking OSError -> close + raise sock._recv_side_effect = OSError("boom") with pytest.raises(AppriseSocketError): s.read(blocking=False) assert s._sock is None def test_utils_socket_read_blocking_timeout(): """SocketTransport() Read Blocking Timeout.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.1)) sock = _DummySocket() s._sock = sock # Timeout path: can_read returns False => b"" with mock.patch.object(s, "can_read", return_value=False): assert s.read(blocking=True) == b"" # Indefinite path: wait_timeout=None, loop until can_read True s = SocketTransport("example.com", 1, timeout=None) sock = _DummySocket() s._sock = sock side_effect = [False, False, True] with mock.patch.object(s, "can_read", side_effect=side_effect): assert s.read(blocking=True) == b"data" def test_utils_socket_read_blocking_edge_cases() -> None: """SocketTransport() Read Blocking Edge Cases""" s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() good_sock = _DummySocket() good_sock.recv = mock.Mock(return_value=b"data") s._sock = bad_sock s._had_io = True def _close_side_effect() -> None: # emulate closing the socket if s._sock is not None: s._sock = None s._rfile = None s._wfile = None s._is_tls = False s.local_addr = None s.remote_addr = None def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() with ( mock.patch.object(s, "close", side_effect=_close_side_effect), mock.patch.object(s, "connect", side_effect=_connect_side_effect), mock.patch.object(s, "can_read", return_value=None), ): # can_read(None) triggers reconnect branch, then recv() on new socket assert s.read(blocking=True, retries=1) == b"data" def test_utils_socket_write_with_no_socket(): """SocketTransport() write() - No Socket.""" s = SocketTransport("example.com", 1) with pytest.raises(AppriseSocketError): s.write(b"hi") s._sock = _DummySocket() with pytest.raises(AppriseInvalidData): s.write("hi") # type: ignore[arg-type] def test_utils_socket_write_flush(): """SocketTransport() write() - Flush.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) sock = _DummySocket() s._sock = sock s._wfile = _DummyFile() # Normal send, flush=True with mock.patch.object(s, "can_write", return_value=True): assert s.write(b"test", flush=True) == 4 assert s._wfile.flushed >= 1 # send returning 0 triggers connection lost sock = _DummySocket() sock.send = mock.Mock(return_value=0) s._sock = sock with pytest.raises(AppriseSocketError): s.write(b"test", timeout=0.1) # Timeout path: can_write returns False sock = _DummySocket() s._sock = sock with ( mock.patch.object(s, "can_write", return_value=False), pytest.raises(AppriseSocketError), ): s.write(b"test", timeout=0.1) # OSError triggers close sock = _DummySocket() sock._send_side_effect = OSError("boom") s._sock = sock with pytest.raises(AppriseSocketError): s.write(b"test", timeout=0.1) assert s._sock is None def test_utils_socket_read_timeouts(): """SocketTransport() Read Timeouts""" # Negative connect timeout with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout=(-0.1, 1.0)) # Negative read timeout with pytest.raises(AppriseInvalidData): SocketTransport("example.com", 1, timeout=(1.0, -0.1)) # Force the read_t is None branch s = SocketTransport("example.com", 1, timeout=(1.0, None)) assert s._connect_timeout == pytest.approx(1.0) assert s._read_timeout is None def test_utils_socket_exceptions(): """SocketTransport() Exception Testing.""" assert issubclass(AppriseSocketError, AppriseException) def test_utils_socket_close_exceptions(): """SocketTransport() Close Exceptions.""" class _BadShutdownSocket(_DummySocket): def shutdown(self, *_args, **_kwargs) -> None: raise Exception("shutdown fail") class _BadFile(_DummyFile): def flush(self) -> None: raise Exception("flush fail") def close(self) -> None: raise Exception("close fail") s = SocketTransport("example.com", 1) s._sock = _BadShutdownSocket() s._wfile = _BadFile() s._rfile = _BadFile() # Should not raise, despite flush/close/shutdown throwing s.close() assert s._sock is None assert s._wfile is None assert s._rfile is None def test_utils_socket_refresh(): """SocketTransport() Refresh.""" s = SocketTransport("example.com", 1) s._sock = None s._rfile = _DummyFile() s._wfile = _DummyFile() s._refresh_wrappers() assert s._rfile is None assert s._wfile is None def test_utils_socket_can_read_returns_bool(): """SocketTransport() Can Read Returns Bool.""" s = SocketTransport("example.com", 1) s._sock = _DummySocket() with mock.patch("select.select", return_value=([s._sock], [], [])): assert s.can_read(0.01) is True with mock.patch("select.select", return_value=([], [], [])): assert s.can_read(0.01) is False def test_utils_socket_connect_bind(): """SocketTransport() bind() tests""" s = SocketTransport( "example.com", 6667, bind_addr="127.0.0.1", bind_port=0 ) dummy = _DummySocket() dummy.bind = mock.Mock() with mock.patch("socket.socket", return_value=dummy): s.connect() dummy.bind.assert_called_once() def test_utils_socket_connect_settimeout_handling(): """SocketTransport() Connect settimeout() handling.""" s = SocketTransport("example.com", 6667, timeout=None) dummy = _DummySocket() dummy.settimeout = mock.Mock() with mock.patch("socket.socket", return_value=dummy): s.connect() # called once with None after connect() (sock.settimeout(None)) # but NOT called with a float connect timeout prior to connect. assert dummy.settimeout.call_args_list == [mock.call(None)] def test_utils_socket_secure_connect(): """SocketTransport() Secure connect().""" s = SocketTransport("example.com", 6667, secure=True) dummy = _DummySocket() with ( mock.patch("socket.socket", return_value=dummy), mock.patch.object(s, "start_tls", autospec=True) as m, ): s.connect() m.assert_called_once() def test_utils_socket_connect_exceptions(): """SocketTransport() Connect Exceptions.""" class _BadCloseSocket(_DummySocket): def connect(self, *_args, **_kwargs) -> None: raise OSError("connect fail") def close(self) -> None: raise Exception("close fail") s = SocketTransport("example.com", 6667, secure=False) dummy = _BadCloseSocket() with ( mock.patch("socket.socket", return_value=dummy), pytest.raises(AppriseSocketError), ): s.connect() # transport should not keep the socket assert s.connected is False def test_utils_socket_write_deadline(): """SocketTransport() Write Deadline.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) sock = _DummySocket() s._sock = sock with mock.patch.object(s, "can_write", return_value=True) as m_can_write: written = s.write(b"test", timeout=0.1, flush=False) assert written == 4 assert m_can_write.called def test_utils_socket_write_timeout(): """SocketTransport() write() timeout.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) s._sock = _DummySocket() # First monotonic call sets deadline, second call makes remaining <= 0 with mock.patch("time.monotonic", side_effect=[1000.0, 1000.5]): with pytest.raises(AppriseSocketError) as e: s.write(b"test", timeout=0.1, flush=False) assert "Timed out during write" in str(e.value) s = SocketTransport("example.com", 1, timeout=None) sock = _DummySocket() s._sock = sock s._wfile = _DummyFile() # If deadline is None, can_write should never be called with mock.patch.object(s, "can_write") as m_can_write: result = s.write(b"test", flush=True, timeout=None) assert result == 4 m_can_write.assert_not_called() def test_utils_socket_write_flush_edge_cases(): """SocketTransport() write() flush() edge cases.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) s._sock = _DummySocket() s._wfile = _DummyFile() with mock.patch.object(s._wfile, "flush") as m: with mock.patch.object(s, "can_write", return_value=True): written = s.write(b"test", flush=False, timeout=0.1) assert written == 4 m.assert_not_called() s = SocketTransport("example.com", 1, timeout=(1.0, 0.2)) s._sock = _DummySocket() s._wfile = None # Should succeed and simply skip flush with mock.patch.object(s, "can_write", return_value=True): assert s.write(b"test", flush=True, timeout=0.1) == 4 def test_utils_socket_recv_error(): """SocketTransport() recv() errors Covers blocking read() OSError handling after readiness check. """ s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() sock._recv_side_effect = OSError("recv failed") s._sock = sock with ( mock.patch.object(s, "can_read", return_value=True), pytest.raises(AppriseSocketError) as e, ): s.read(blocking=True) assert "recv failed" in str(e.value) assert s._sock is None def test_utils_socket_write_empty_payload_does_not_set_had_io() -> None: """SocketTransport() Write Empty Payload Does Not Set Had Io. Covers the branch where total_sent == 0, so _had_io is not updated. """ s = SocketTransport("example.com", 1, timeout=None) s._sock = _DummySocket() s._wfile = _DummyFile() # Empty payload performs no send() calls sent = s.write(b"", flush=False, timeout=None) assert sent == 0 assert s._had_io is False def test_utils_socket_write_handling() -> None: """SocketTransport() Write handling""" s = SocketTransport("example.com", 1, timeout=None) s._sock = _DummySocket() # Mark prior I/O so reconnect is considered eligible in real logic, but we # patch _attempt_reconnect to force the continue path deterministically. s._had_io = True # Force the send to fail with an AppriseSocketError s._sock.send = mock.Mock(side_effect=AppriseSocketError("boom")) # retries=0 => attempts == 1; we force _attempt_reconnect to return True, # which triggers `continue` and immediately exhausts the loop. with ( mock.patch.object(s, "_attempt_reconnect", return_value=True), pytest.raises(AppriseSocketError) as e, ): s.write(b"test", timeout=None, retries=0) assert "Socket write failed" in str(e.value) def test_utils_socket_write_reconnect_continue_path_is_reached() -> None: """SocketTransport() Write Reconnect Continue Path Is Reached. Covers the `continue` path in write() when _attempt_reconnect returns True. Trigger AppriseSocketError (not OSError) so we do not reset _had_io via. close() before reconnect eligibility is checked. """ s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() # send returning 0 triggers "Connection lost during write" bad_sock.send = mock.Mock(return_value=0) good_sock = _DummySocket() good_sock.send = mock.Mock(return_value=4) s._sock = bad_sock s._had_io = True def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() with mock.patch.object(s, "connect", side_effect=_connect_side_effect): assert s.write(b"test", timeout=None, retries=1, flush=False) == 4 def test_utils_socket_read_reconnect_continue_path_is_reached() -> None: """SocketTransport() Read Reconnect Continue Path Is Reached. Covers the `continue` path in read() when _attempt_reconnect returns True. Trigger AppriseSocketError (not OSError) so we do not reset _had_io via. close() before reconnect eligibility is checked. """ s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() # recv returning b"" triggers "Connection lost during read" bad_sock.recv = mock.Mock(return_value=b"") good_sock = _DummySocket() good_sock.recv = mock.Mock(return_value=b"data") s._sock = bad_sock s._had_io = True def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() with mock.patch.object(s, "connect", side_effect=_connect_side_effect): assert s.read(blocking=False, retries=1) == b"data" def test_utils_socket_attempt_reconnect_retries_zero_returns_false() -> None: """SocketTransport() Attempt Reconnect Retries Zero Returns False. Covers _attempt_reconnect() early return when retries <= 0. """ s = SocketTransport("example.com", 1, timeout=None) s._had_io = True assert ( s._attempt_reconnect( retries=0, action="read", exc=Exception("boom"), ) is False ) def test_utils_socket_read_blocking_connection() -> None: """SocketTransport() Read Blocking Connection.""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() sock.recv = mock.Mock(return_value=b"") s._sock = sock # Mark prior I/O, but retries=0 means reconnect is not allowed anyway s._had_io = True with ( mock.patch.object(s, "can_read", return_value=True), pytest.raises(AppriseSocketError) as e, ): s.read(blocking=True, retries=0) assert "Connection lost during read" in str(e.value) def test_utils_socket_read_blocking() -> None: """SocketTransport() Read Blocking""" s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) bad_sock = _DummySocket() bad_sock.recv = mock.Mock(return_value=b"") good_sock = _DummySocket() good_sock.recv = mock.Mock(return_value=b"data") s._sock = bad_sock s._had_io = True def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() with ( mock.patch.object(s, "can_read", return_value=True), mock.patch.object(s, "connect", side_effect=_connect_side_effect), ): assert s.read(blocking=True, retries=1) == b"data" s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() s._sock = sock with mock.patch.object(s, "can_read", return_value=True): assert s.read(blocking=True) == b"data" s = SocketTransport("example.com", 1, timeout=None) s._sock = _DummySocket() with mock.patch.object(s, "can_read", return_value=None): with pytest.raises(AppriseSocketError) as e: s.read(blocking=True) assert "Socket closed" in str(e.value) s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) s._sock = _DummySocket() with mock.patch.object(s, "can_read", return_value=False): assert s.read(blocking=True) == b"" def test_utils_socket_read_edge_cases(): """SocketTransport() read() edge case tests.""" s = SocketTransport("example.com", 1) s._sock = None assert s.read() == b"" s = SocketTransport("example.com", 1, timeout=None) sock = _DummySocket() sock.recv = mock.Mock(return_value=b"") # triggers AppriseSocketError path s._sock = sock s._had_io = True with ( mock.patch.object(s, "_attempt_reconnect", return_value=True), pytest.raises(AppriseSocketError) as e, ): # retries=0 => attempts starts at 1, decremented to 0 in-loop s.read(blocking=False, retries=0) assert "Socket read failed" in str(e.value) def test_utils_socket_attempt_reconnect_no_io() -> None: """SocketTransport() _attempt_reconnect() returns False""" s = SocketTransport("example.com", 1, timeout=None) # Explicitly not eligible for reconnect s._had_io = False with ( mock.patch.object(s, "close") as m_close, mock.patch.object(s, "connect") as m_connect, ): assert ( s._attempt_reconnect( retries=1, action="read", exc=Exception("boom"), ) is False ) # Should not attempt any reconnect work m_close.assert_not_called() m_connect.assert_not_called() def test_utils_socket_attempt_reconnect_connect_exception() -> None: """SocketTransport() _attempt_reconnect() handles exception.""" s = SocketTransport("example.com", 1, timeout=None) # Eligible for reconnect s._had_io = True with ( mock.patch.object(s, "close") as m_close, mock.patch.object( s, "connect", side_effect=Exception("nope") ) as m_connect, ): assert ( s._attempt_reconnect( retries=1, action="write", exc=Exception("boom"), ) is False ) m_close.assert_called_once() m_connect.assert_called_once() def test_utils_socket_ssl_read_blocking() -> None: """SocketTransport() read() blocking""" s = SocketTransport("example.com", 1, timeout=None) sock = _DummySocket() # First recv indicates TLS wants more reads, second returns data sock.recv = mock.Mock(side_effect=[ssl.SSLWantReadError(), b"data"]) s._sock = sock # Indefinite blocking, so we must reach recv() and then loop on WANT_READ with mock.patch.object(s, "can_read", return_value=True): assert s.read(blocking=True, timeout=None) == b"data" # Coverage for read blocking, WANT_WRITE s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() # Trigger the WANT_WRITE/BlockingIOError handling inside the recv loop sock.recv = mock.Mock(side_effect=ssl.SSLWantWriteError()) s._sock = sock # 1) initial readiness check returns True (enter recv loop) # 2) inner loop calls can_read(min(...)) and gets False, returning b"" with mock.patch.object(s, "can_read", side_effect=[True, False]): assert s.read(blocking=True, timeout=0.5) == b"" s = SocketTransport("example.com", 1, timeout=(1.0, 0.5)) sock = _DummySocket() # WANT_WRITE first, then a successful read sock.recv = mock.Mock(side_effect=[ssl.SSLWantWriteError(), b"data"]) s._sock = sock # can_read used twice: # - readiness check (True) # - busy-loop avoidance check (True) with mock.patch.object(s, "can_read", side_effect=[True, True]): assert s.read(blocking=True, timeout=0.5) == b"data" s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() # In blocking read, b"" triggers "Connection lost during read" bad_sock.recv = mock.Mock(return_value=b"") good_sock = _DummySocket() # This targets the post-reconnect immediate recv path and forces b"" there good_sock.recv = mock.Mock(return_value=b"") s._sock = bad_sock s._had_io = True def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() # Ensure we enter the blocking path and do the initial recv that fails, # then reconnect, then attempt immediate recv on the new socket. with ( mock.patch.object(s, "can_read", return_value=True), mock.patch.object(s, "connect", side_effect=_connect_side_effect), ): with pytest.raises(AppriseSocketError) as e: s.read(blocking=True, retries=1) assert "Connection lost during read" in str(e.value) s = SocketTransport("example.com", 1, timeout=None) bad_sock = _DummySocket() bad_sock.recv = mock.Mock(return_value=b"") s._sock = bad_sock s._had_io = True good_sock = _DummySocket() # - First call is the post-reconnect immediate recv: it raises # BlockingIOError # - Second call occurs in the next loop iteration after can_read says True good_sock.recv = mock.Mock(side_effect=[BlockingIOError(), b"data"]) def _connect_side_effect() -> None: s._sock = good_sock s._refresh_wrappers() # can_read must return True so we get to recv() on the next iteration with ( mock.patch.object(s, "can_read", return_value=True), mock.patch.object(s, "connect", side_effect=_connect_side_effect), ): assert s.read(blocking=True, retries=1) == b"data" caronc-apprise-182f859/tests/test_utils_wkd.py000066400000000000000000000632031524161175200215000ustar00rootroot00000000000000# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output from datetime import datetime, timedelta, timezone import logging from unittest import mock from urllib.parse import urlparse import requests from apprise.utils.wkd import AppriseWKDController, AppriseWKDException logging.disable(logging.CRITICAL) # --------------------------------------------------------------------------- # AppriseWKDException # --------------------------------------------------------------------------- def test_wkd_exception_default_error_code(): """AppriseWKDException uses error_code 610 by default.""" exc = AppriseWKDException("something went wrong") assert exc.error_code == 610 # --------------------------------------------------------------------------- # zb32_encode # --------------------------------------------------------------------------- def test_zb32_encode_all_zeros(): """20 zero bytes encode to 32 'y' characters (index 0 of the alphabet).""" result = AppriseWKDController.zb32_encode(b"\x00" * 20) assert result == "y" * 32 def test_zb32_encode_all_ones(): """20 0xFF bytes encode to 32 '9' characters (index 31 of the alphabet).""" result = AppriseWKDController.zb32_encode(b"\xff" * 20) assert result == "9" * 32 def test_zb32_encode_sha1_length(): """SHA-1 digest (20 bytes) always produces exactly 32 z-base32 chars.""" import hashlib digest = hashlib.sha1(b"test@example.com").digest() result = AppriseWKDController.zb32_encode(digest) assert len(result) == 32 # All characters must belong to the z-base32 alphabet assert all(c in AppriseWKDController._ZB32 for c in result) def test_zb32_encode_empty(): """Empty input produces an empty string.""" assert AppriseWKDController.zb32_encode(b"") == "" def test_zb32_encode_single_byte(): """Single byte (5 bits + 3 leftover bits) produces 2 characters.""" # 0x00 = 00000 000 -> 'y' then 0x00 << 2 = 00000 -> 'y' result = AppriseWKDController.zb32_encode(b"\x00") assert len(result) == 2 assert result[0] == "y" def test_zb32_encode_known_vector(): """Verify z-base32 encoding against independently computed single-byte reference values. These expected strings are derived from first principles: each 5-bit group maps directly to _ZB32[value], and leftover bits are left-aligned. They do NOT call zb32_encode() to generate the expected string, so a regression in the alphabet or bit-shifting logic will cause at least one assertion to fail. 0xf8 = 11111000: group1 (bits 0-4): 11111 = 31 -> '9' leftover (bits 5-7) << 2: 00000 = 0 -> 'y' 0xa8 = 10101000: group1 (bits 0-4): 10101 = 21 -> 'i' leftover (bits 5-7) << 2: 00000 = 0 -> 'y' 0x08 = 00001000: group1 (bits 0-4): 00001 = 1 -> 'b' leftover (bits 5-7) << 2: 00000 = 0 -> 'y' """ assert AppriseWKDController.zb32_encode(b"\xf8") == "9y" assert AppriseWKDController.zb32_encode(b"\xa8") == "iy" assert AppriseWKDController.zb32_encode(b"\x08") == "by" # --------------------------------------------------------------------------- # wkd_urls # --------------------------------------------------------------------------- def test_wkd_urls_structure(): """wkd_urls() returns properly formed subdomain and direct URLs.""" sub, direct = AppriseWKDController.wkd_urls("user@example.com") assert sub.startswith("https://openpgpkey.example.com/") assert "/.well-known/openpgpkey/example.com/hu/" in sub assert "?l=user" in sub assert direct.startswith("https://example.com/") assert "/.well-known/openpgpkey/hu/" in direct assert "?l=user" in direct def test_wkd_urls_case_normalised(): """wkd_urls() produces the same hash and domain for differently-cased inputs, but preserves the original local-part case in ?l= (RFC 9080). The hash component uses the lower-cased local part (spec requirement), the domain is always lower-cased (URL canonicalisation), and the ?l= query parameter carries the unchanged original local part -- so two addresses that differ only in local-part capitalisation will share the same hash/path but differ in their ?l= value. """ sub_upper, dir_upper = AppriseWKDController.wkd_urls("User@Example.COM") sub_lower, dir_lower = AppriseWKDController.wkd_urls("user@example.com") # Hash and domain parts must be identical assert sub_upper.split("?")[0] == sub_lower.split("?")[0] assert dir_upper.split("?")[0] == dir_lower.split("?")[0] # ?l= must carry the original (unchanged) local part assert "?l=User" in sub_upper assert "?l=user" in sub_lower # Domain is always lower-cased regardless of input assert urlparse(sub_lower).hostname == "openpgpkey.example.com" def test_wkd_urls_l_param_preserves_original_case(): """?l= carries the unchanged local-part per RFC 9080. The spec example: Joe.Doe@Example.ORG -> ?l=Joe.Doe (original case, not lowercased) The hash is computed from the lowercased local part; only ?l= keeps the original capitalisation. """ sub, direct = AppriseWKDController.wkd_urls("Joe.Doe@Example.ORG") assert "?l=Joe.Doe" in sub assert "?l=Joe.Doe" in direct # Domain part is always lowercased in the URL host assert urlparse(sub).hostname == "openpgpkey.example.org" assert urlparse(direct).hostname == "example.org" def test_wkd_urls_local_part_encoded(): """wkd_urls() percent-encodes special characters in the local part.""" sub, direct = AppriseWKDController.wkd_urls("first+last@example.com") assert "first%2Blast" in sub assert "first%2Blast" in direct def test_wkd_urls_no_at_sign(): """wkd_urls() returns (None, None) for an address without '@'.""" sub, direct = AppriseWKDController.wkd_urls("notanemail") assert sub is None assert direct is None def test_wkd_urls_empty_string(): """wkd_urls() returns (None, None) for an empty string.""" sub, direct = AppriseWKDController.wkd_urls("") assert sub is None assert direct is None def test_wkd_urls_empty_local(): """wkd_urls() returns (None, None) when the local part is empty.""" sub, direct = AppriseWKDController.wkd_urls("@example.com") assert sub is None assert direct is None def test_wkd_urls_empty_domain(): """wkd_urls() returns (None, None) when the domain is empty.""" sub, direct = AppriseWKDController.wkd_urls("user@") assert sub is None assert direct is None def test_wkd_urls_none_input(): """wkd_urls() returns (None, None) for None input.""" sub, direct = AppriseWKDController.wkd_urls(None) assert sub is None assert direct is None def test_wkd_urls_non_string_input(): """wkd_urls() returns (None, None) for non-string truthy inputs. Without the isinstance guard, ``"@" not in 123`` raises TypeError before the try/except block can handle it. """ for bad in (123, 12.5, object(), ["user@example.com"]): sub, direct = AppriseWKDController.wkd_urls(bad) assert sub is None assert direct is None def test_wkd_urls_ipv4_domain_rejected(): """wkd_urls() returns (None, None) for emails with an IPv4 domain. WKD is defined for DNS hostnames only (RFC 9080). Allowing IP literals as the domain creates an SSRF vector because WKD discovery turns recipient domains into outbound HTTPS requests. """ for addr in ( "user@127.0.0.1", "user@192.168.1.1", "user@10.0.0.1", ): sub, direct = AppriseWKDController.wkd_urls(addr) assert sub is None, f"Expected None for IP domain: {addr}" assert direct is None, f"Expected None for IP domain: {addr}" def test_wkd_urls_lower_raises_attribute_error(): """wkd_urls() returns (None, None) when email.split() raises. Covers the defensive except branch for string-like objects that pass the '@' membership check but fail during splitting. (After the RFC 9080 case-preservation fix, split() is called before lower(), so breaking split() is what exercises this path.) """ class BrokenStr(str): def split(self, *args, **kwargs): raise AttributeError("broken split") sub, direct = AppriseWKDController.wkd_urls(BrokenStr("user@example.com")) assert sub is None assert direct is None def test_wkd_urls_domain_injection_rejected(): """wkd_urls() returns (None, None) for emails whose domain contains characters that would rewrite the URL host. user@legit.com@attacker.test splits to domain='legit.com@attacker.test' which, when embedded in an HTTPS URL, sends the request to attacker.test with legit.com as URL credentials. The is_hostname() guard rejects it. """ for bad_email in ( "user@example.com@attacker.test", "user@example.com/path", "user@example.com:8080", "user@example.com space", ): sub, direct = AppriseWKDController.wkd_urls(bad_email) assert sub is None, f"Expected None for domain injection: {bad_email}" assert direct is None, ( f"Expected None for domain injection: {bad_email}" ) # --------------------------------------------------------------------------- # AppriseWKDController.fetch -- happy path # --------------------------------------------------------------------------- def _make_resp(status_code, chunks=None, url=None): """Build a context-manager-compatible requests.Response mock. ``chunks`` is a list of byte strings that iter_content() will yield. Pass an empty list for an empty body. Leave as None when the status code is non-200 and the body will never be consumed. ``url`` sets r.url (the final URL after any redirects). When omitted the attribute is a MagicMock, which passes the HTTPS check because MagicMock().lower().startswith() is truthy. """ resp = mock.MagicMock(status_code=status_code) resp.__enter__ = mock.Mock(return_value=resp) resp.__exit__ = mock.Mock(return_value=False) # Default to an empty headers dict so r.headers.get("Content-Length") # returns None; tests that need a specific header override this resp.headers = {} if url is not None: resp.url = url if chunks is not None: resp.iter_content.return_value = iter(chunks) return resp @mock.patch("requests.get") def test_fetch_subdomain_success(mock_get): """fetch() returns key bytes when the subdomain URL responds with 200.""" # 0x99 is a valid old-format OpenPGP packet header byte (bit 7 set) key_bytes = b"\x99fake-openpgp-key-data" # Subdomain call succeeds; iter_content yields the key in one chunk mock_get.return_value = _make_resp(requests.codes.ok, chunks=[key_bytes]) ctrl = AppriseWKDController() result = ctrl.fetch("user@example.com") assert result == key_bytes assert mock_get.call_count == 1 @mock.patch("requests.get") def test_fetch_direct_fallback(mock_get): """fetch() tries the direct URL when the subdomain URL fails.""" key_bytes = b"\x99another-fake-key" # Subdomain -> 404 (body never consumed), direct -> 200 with key bytes mock_get.side_effect = [ _make_resp(404), _make_resp(requests.codes.ok, chunks=[key_bytes]), ] ctrl = AppriseWKDController() result = ctrl.fetch("user@example.com") assert result == key_bytes assert mock_get.call_count == 2 @mock.patch("requests.get") def test_fetch_both_fail(mock_get): """fetch() returns None when both WKD URLs fail.""" mock_get.return_value = _make_resp(404) ctrl = AppriseWKDController() result = ctrl.fetch("user@example.com") assert result is None assert mock_get.call_count == 2 # --------------------------------------------------------------------------- # fetch -- cache behaviour # --------------------------------------------------------------------------- @mock.patch("requests.get") def test_fetch_caches_result(mock_get): """A successful fetch is cached; subsequent calls skip the network.""" key_bytes = b"\x99cached-key" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[key_bytes]) ctrl = AppriseWKDController() result1 = ctrl.fetch("user@example.com") result2 = ctrl.fetch("user@example.com") assert result1 == key_bytes assert result2 == key_bytes # Network only hit once assert mock_get.call_count == 1 @mock.patch("requests.get") def test_fetch_cache_case_insensitive(mock_get): """Cache lookup normalises the email address.""" key_bytes = b"\x99normalised-key" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[key_bytes]) ctrl = AppriseWKDController() ctrl.fetch("User@Example.COM") result = ctrl.fetch("user@example.com") assert result == key_bytes assert mock_get.call_count == 1 @mock.patch("requests.get") def test_fetch_expired_cache_refetches(mock_get): """An expired cache entry triggers a new network request.""" key_bytes = b"\x99refreshed-key" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[key_bytes]) ctrl = AppriseWKDController() # Seed the cache with an already-expired entry ctrl._cache["user@example.com"] = { "data": b"stale-key", "expires": datetime.now(timezone.utc) - timedelta(seconds=1), } result = ctrl.fetch("user@example.com") assert result == key_bytes assert mock_get.call_count == 1 # --------------------------------------------------------------------------- # fetch -- invalid input # --------------------------------------------------------------------------- def test_fetch_no_at_sign(): """fetch() returns None for an address without '@'.""" ctrl = AppriseWKDController() assert ctrl.fetch("notanemail") is None def test_fetch_empty_string(): """fetch() returns None for an empty string.""" ctrl = AppriseWKDController() assert ctrl.fetch("") is None def test_fetch_none(): """fetch() returns None when called with None.""" ctrl = AppriseWKDController() assert ctrl.fetch(None) is None def test_fetch_non_string_input(): """fetch() returns None for non-string truthy inputs. Without the isinstance guard, ``"@" not in 123`` raises TypeError before the method can return None for invalid input. """ ctrl = AppriseWKDController() for bad in (123, 12.5, object(), ["user@example.com"]): assert ctrl.fetch(bad) is None def test_fetch_at_only(): """fetch() returns None for '@' (empty local and empty domain). '@' passes the '@' membership check but wkd_urls() returns (None, None) because both local and domain parts are empty -- covers the guard at the second validation point inside fetch(). """ ctrl = AppriseWKDController() assert ctrl.fetch("@") is None # --------------------------------------------------------------------------- # _get -- response validation # --------------------------------------------------------------------------- @mock.patch("requests.get") def test_get_non_200_returns_none(mock_get): """_get() returns None for any non-200 HTTP status.""" for code in (301, 400, 403, 404, 500): mock_get.return_value = _make_resp(code) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("requests.get") def test_get_empty_body_returns_none(mock_get): """_get() returns None when the response body is empty.""" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("requests.get") def test_get_empty_chunk_is_skipped(mock_get): """_get() skips empty bytestrings yielded by iter_content(). Some HTTP adapters yield b"" sentinel chunks between real chunks; the in-loop ``if not chunk: continue`` guard must skip them so they do not corrupt the accumulated content or trigger a false empty-body result. """ # Interleave an empty sentinel before a valid PGP binary chunk key_bytes = b"\x99" + b"\x00" * 8 mock_get.return_value = _make_resp( requests.codes.ok, chunks=[b"", key_bytes] ) ctrl = AppriseWKDController() result = ctrl._get("https://example.com/key") assert result == key_bytes @mock.patch("requests.get") def test_get_oversized_via_content_length_returns_none(mock_get): """_get() rejects early when Content-Length already exceeds the limit. iter_content() must not be called at all -- we detect the oversize from the header before reading any bytes. """ ctrl = AppriseWKDController() resp = _make_resp(requests.codes.ok) resp.headers = {"Content-Length": str(ctrl.max_response_size + 1)} mock_get.return_value = resp assert ctrl._get("https://example.com/key") is None resp.iter_content.assert_not_called() @mock.patch("requests.get") def test_get_valid_content_length_within_limit_proceeds(mock_get): """_get() proceeds to read chunks when Content-Length is within the limit. This exercises the branch where content_length is not None and the parsed value does not exceed max_response_size. """ key_bytes = b"\x99valid-key" resp = _make_resp(requests.codes.ok, chunks=[key_bytes]) resp.headers = {"Content-Length": str(len(key_bytes))} mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == key_bytes @mock.patch("requests.get") def test_get_malformed_content_length_proceeds(mock_get): """_get() ignores a malformed Content-Length and reads chunks normally.""" key_bytes = b"\x99valid-key" resp = _make_resp(requests.codes.ok, chunks=[key_bytes]) resp.headers = {"Content-Length": "not-a-number"} mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == key_bytes @mock.patch("requests.get") def test_get_oversized_body_returns_none(mock_get): """_get() returns None when accumulated chunks exceed max_response_size.""" ctrl = AppriseWKDController() # Single chunk one byte over the limit triggers the in-loop guard oversized = b"\x99" + b"x" * ctrl.max_response_size mock_get.return_value = _make_resp(requests.codes.ok, chunks=[oversized]) assert ctrl._get("https://example.com/key") is None @mock.patch("requests.get") def test_get_non_pgp_body_returns_none(mock_get): """_get() returns None when the response body is not PGP data. A subdomain endpoint that is a parked domain or CDN may return HTTP 200 with HTML content. Without this guard, fetch() would cache the HTML bytes and never try the direct-method fallback URL. """ for non_pgp in ( b"Not a key", b"", b"{}", b"Not PGP data", ): mock_get.return_value = _make_resp(requests.codes.ok, chunks=[non_pgp]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None, ( f"Expected None for non-PGP content: {non_pgp[:20]}" ) @mock.patch("requests.get") def test_get_binary_pgp_packet_accepted(mock_get): """_get() accepts a response whose first byte has bit 7 set (OpenPGP binary packet format).""" # 0x99 is the old-format public-key packet header pgp_binary = b"\x99\x01\xd6" + b"\x00" * 100 mock_get.return_value = _make_resp(requests.codes.ok, chunks=[pgp_binary]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == pgp_binary @mock.patch("requests.get") def test_get_ascii_armoured_key_accepted(mock_get): """_get() accepts ASCII-armoured PGP key material (starts with '-----').""" armoured = b"-----BEGIN PGP PUBLIC KEY BLOCK-----\n..." mock_get.return_value = _make_resp(requests.codes.ok, chunks=[armoured]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == armoured @mock.patch("requests.get", side_effect=requests.RequestException("timeout")) def test_get_request_exception_returns_none(mock_get): """_get() returns None when requests raises any RequestException.""" ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("requests.get") def test_get_stream_oserror_returns_none(mock_get): """_get() returns None when iter_content() raises OSError mid-stream. Covers disk-full, connection-reset, and other IO-level failures that surface after the HTTP headers have been received successfully. """ resp = _make_resp(requests.codes.ok) resp.iter_content.side_effect = OSError("connection reset by peer") mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("requests.get") def test_get_stream_generic_exception_returns_none(mock_get): """_get() returns None when iter_content() raises an unexpected exception. Covers urllib3 IncompleteRead, read-timeout mid-stream, and any other exception not otherwise categorised. """ resp = _make_resp(requests.codes.ok) resp.iter_content.side_effect = Exception("unexpected streaming error") mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("requests.get") def test_get_http_redirect_rejected(mock_get): """_get() returns None when a redirect lands on a non-HTTPS URL. A redirect from HTTPS to HTTP would expose key material to interception and break the WKD trust model. """ resp = _make_resp( requests.codes.ok, chunks=[b"\x99key"], url="http://attacker.example.com/hu/abc", ) mock_get.return_value = resp ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") is None @mock.patch("requests.get") def test_get_success_returns_bytes(mock_get): """_get() returns the response bytes on HTTP 200 with content.""" payload = b"\x99\xaa\xbb\xcc" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[payload]) ctrl = AppriseWKDController() assert ctrl._get("https://example.com/key") == payload # --------------------------------------------------------------------------- # prune # --------------------------------------------------------------------------- def test_prune_removes_expired_entries(): """prune() discards entries whose expiry has passed.""" ctrl = AppriseWKDController() now = datetime.now(timezone.utc) ctrl._cache["expired@example.com"] = { "data": b"old", "expires": now - timedelta(seconds=1), } ctrl._cache["fresh@example.com"] = { "data": b"new", "expires": now + timedelta(seconds=3600), } ctrl.prune() assert "expired@example.com" not in ctrl._cache assert "fresh@example.com" in ctrl._cache def test_prune_empty_cache(): """prune() does not raise when the cache is empty.""" ctrl = AppriseWKDController() ctrl.prune() # must not raise assert ctrl._cache == {} # --------------------------------------------------------------------------- # Constructor defaults # --------------------------------------------------------------------------- def test_defaults(): """AppriseWKDController initialises with expected default values.""" ctrl = AppriseWKDController() assert ctrl.verify_certificate is True assert ctrl.request_timeout == (4, 4) assert ctrl.allow_redirects is True assert ctrl._cache == {} def test_custom_timeout_and_verify(): """Constructor stores custom timeout and verify_certificate values.""" ctrl = AppriseWKDController( verify_certificate=False, request_timeout=(2, 10) ) assert ctrl.verify_certificate is False assert ctrl.request_timeout == (2, 10) def test_custom_allow_redirects(): """Constructor stores a custom allow_redirects value.""" ctrl = AppriseWKDController(allow_redirects=False) assert ctrl.allow_redirects is False @mock.patch("requests.get") def test_get_passes_verify_and_timeout(mock_get): """_get() forwards verify_certificate, request_timeout, allow_redirects, and stream=True to requests.""" mock_get.return_value = _make_resp(requests.codes.ok, chunks=[b"\x99key"]) ctrl = AppriseWKDController( verify_certificate=False, request_timeout=(1, 5), allow_redirects=False ) ctrl._get("https://example.com/key") _, kwargs = mock_get.call_args assert kwargs["verify"] is False assert kwargs["timeout"] == (1, 5) assert kwargs["allow_redirects"] is False assert kwargs["stream"] is True caronc-apprise-182f859/tests/var/000077500000000000000000000000001524161175200166465ustar00rootroot00000000000000caronc-apprise-182f859/tests/var/01_test_example.html000066400000000000000000000036401524161175200225310ustar00rootroot00000000000000 Bootstrap 101 Template

    My Title

    Heading 1

    • Bullet 1
    • Bullet 2
    • Bullet 3
    1. Bullet 1
    2. Bullet 2
    3. Bullet 3

    Heading 2

    A div entry

    A div entry

    print('hello')

    Heading 3

    Heading 4

    Heading 5
    Heading 6

    A set of text
    Another line after the set of text


    More text