pax_global_header00006660000000000000000000000064151734166570014531gustar00rootroot0000000000000052 comment=343c960969477b60df580f70294ce966bb4b0ce2 apprise-1.10.0/000077500000000000000000000000001517341665700132535ustar00rootroot00000000000000apprise-1.10.0/.codecov.yml000066400000000000000000000005221517341665700154750ustar00rootroot00000000000000codecov: 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% apprise-1.10.0/.env000066400000000000000000000000641517341665700140440ustar00rootroot00000000000000LANG=C.UTF-8 PYTHONPATH=. PYTHONDONTWRITEBYTECODE=1 apprise-1.10.0/.github/000077500000000000000000000000001517341665700146135ustar00rootroot00000000000000apprise-1.10.0/.github/FUNDING.yml000066400000000000000000000001331517341665700164250ustar00rootroot00000000000000github: caronc custom: - 'https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E' apprise-1.10.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001517341665700167765ustar00rootroot00000000000000apprise-1.10.0/.github/ISSUE_TEMPLATE/1_bug_report.md000066400000000000000000000017601517341665700217140ustar00rootroot00000000000000--- 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 ``` apprise-1.10.0/.github/ISSUE_TEMPLATE/2_enhancement_request.md000066400000000000000000000014051517341665700235760ustar00rootroot00000000000000--- 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 apprise-1.10.0/.github/ISSUE_TEMPLATE/3_new-notification-request.md000066400000000000000000000023471517341665700245130ustar00rootroot00000000000000--- 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 apprise-1.10.0/.github/ISSUE_TEMPLATE/4_question.md000066400000000000000000000006631517341665700214170ustar00rootroot00000000000000--- 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: apprise-1.10.0/.github/ISSUE_TEMPLATE/config.yaml000066400000000000000000000007201517341665700211260ustar00rootroot00000000000000blank_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. apprise-1.10.0/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000046041517341665700204200ustar00rootroot00000000000000## 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" \ ``` apprise-1.10.0/.github/badges/000077500000000000000000000000001517341665700160405ustar00rootroot00000000000000apprise-1.10.0/.github/badges/loc.svg000066400000000000000000000016351517341665700173430ustar00rootroot00000000000000 Lines of Code Lines of Code 54,713 54,713 apprise-1.10.0/.github/workflows/000077500000000000000000000000001517341665700166505ustar00rootroot00000000000000apprise-1.10.0/.github/workflows/codeql-analysis.yml000066400000000000000000000016521517341665700224670ustar00rootroot00000000000000name: "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@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Autobuild uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 apprise-1.10.0/.github/workflows/lint.yml000066400000000000000000000007161517341665700203450ustar00rootroot00000000000000# .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@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install tox run: python -m pip install tox - name: Run Ruff lint check run: tox -e lint apprise-1.10.0/.github/workflows/loc-badge.yml000066400000000000000000000022351517341665700212120ustar00rootroot00000000000000# 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@v4 - 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@v7 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" apprise-1.10.0/.github/workflows/pkgbuild.yml000066400000000000000000000056401517341665700212010ustar00rootroot00000000000000# # 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@v4 - 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@v4 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@v4 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@v4 with: name: built-rpms-${{ matrix.dist }} path: ./dist - name: Download rpmlint config files uses: actions/download-artifact@v4 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 apprise-1.10.0/.github/workflows/tests.yml000066400000000000000000000071721517341665700205440ustar00rootroot00000000000000name: 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@v4 # 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@v4 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@v4 - name: Download all coverage reports uses: actions/download-artifact@v4 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@v5 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 }} apprise-1.10.0/.gitignore000066400000000000000000000015121517341665700152420ustar00rootroot00000000000000# 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 apprise-1.10.0/.vscode/000077500000000000000000000000001517341665700146145ustar00rootroot00000000000000apprise-1.10.0/.vscode/extensions.json000066400000000000000000000002771517341665700177140ustar00rootroot00000000000000{ "recommendations": [ // Ruff – linter and formatter for Python (replaces flake8, black, isort) "charliermarsh.ruff", // Python language support "ms-python.python" ] } apprise-1.10.0/.vscode/settings.json000066400000000000000000000020071517341665700173460ustar00rootroot00000000000000{ // 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}" } } apprise-1.10.0/ACKNOWLEDGEMENTS.md000066400000000000000000000021531517341665700161300ustar00rootroot00000000000000# 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:// apprise-1.10.0/CONTRIBUTING.md000066400000000000000000000055541517341665700155150ustar00rootroot00000000000000# 🀝 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. apprise-1.10.0/LICENSE000066400000000000000000000024771517341665700142720ustar00rootroot00000000000000BSD 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. apprise-1.10.0/MANIFEST.in000066400000000000000000000010531517341665700150100ustar00rootroot00000000000000include 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__ apprise-1.10.0/README.md000066400000000000000000001527401517341665700145430ustar00rootroot00000000000000![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/MMPeN2D) [![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/) # 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) Visit the [Official Documentation](https://appriseit.com/getting-started/) site for more information on Apprise. # 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 | [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 | [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 | [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 | [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 | [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" | [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/ | [Microsoft Teams](https://appriseit.com/services/msteams/) | msteams:// | (TCP) 443 | msteams://TokenA/TokenB/TokenC/ | [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/ | [NotificationAPI](https://appriseit.com/services/notificationapi/) | napi:// | (TCP) 443 | napi://ClientID/ClientSecret/Target
napi://ClientID/ClientSecret/Target1/Target2/TargetN
napi://MessageType@ClientID/ClientSecret/Target | [Notifiarr](https://appriseit.com/services/notifiarr/) | notifiarr:// | (TCP) 443 | notifiarr://apikey/#channel
notifiarr://apikey/#channel1/#channel2/#channeln | [Notifico](https://appriseit.com/services/notifico/) | notifico:// | (TCP) 443 | notifico://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 | [PopcornNotify](https://appriseit.com/services/popcornnotify/) | popcorn:// | (TCP) 443 | popcorn://ApiKey/ToPhoneNo
popcorn://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
popcorn://ApiKey/ToEmail
popcorn://ApiKey/ToEmail1/ToEmail2/ToEmailN/
popcorn://ApiKey/ToPhoneNo1/ToEmail1/ToPhoneNoN/ToEmailN | [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 | [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/ | [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 | [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 | [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 | [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/ | [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 (ARPS)](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/ | [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/ | [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/... | [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 have 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 a 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 ## 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 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. 1. `flush`: Flushes any cache information to the filesystem during every transaction. 1. `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 every 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/) apprise-1.10.0/SECURITY.md000066400000000000000000000005731517341665700150510ustar00rootroot00000000000000# 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 apprise-1.10.0/all-plugin-requirements.txt000066400000000000000000000012411517341665700205770ustar00rootroot00000000000000# # 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 # use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814 paho-mqtt != 2.0.* # Pretty Good Privacy (PGP) Provides mailto:// and deltachat:// support PGPy # Provides blink1:// support hidapi # Provides smpp:// support smpplib # For xmpp:// support slixmpp >= 1.10.0 apprise-1.10.0/apprise/000077500000000000000000000000001517341665700147165ustar00rootroot00000000000000apprise-1.10.0/apprise/__init__.py000066400000000000000000000074161517341665700170370ustar00rootroot00000000000000# 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.10.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 .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", "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", ] apprise-1.10.0/apprise/apprise.py000066400000000000000000001114361517341665700167410ustar00rootroot00000000000000# 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 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 .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 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. 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: # Process arguments and build synchronous and asynchronous calls # (this step can throw internal errors). sequential_calls, parallel_calls = self._create_notify_calls( body, title, notify_type=notify_type, body_format=body_format, tag=tag, match_always=match_always, attach=attach, interpret_escapes=interpret_escapes, ) except TypeError: # No notifications sent, and there was an internal error. return False if not sequential_calls and not parallel_calls: # Nothing to send return None sequential_result = Apprise._notify_sequential(*sequential_calls) parallel_result = Apprise._notify_parallel_threadpool(*parallel_calls) return sequential_result and parallel_result 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(). """ try: # Process arguments and build synchronous and asynchronous calls # (this step can throw internal errors). sequential_calls, parallel_calls = self._create_notify_calls( *args, **kwargs ) except TypeError: # No notifications sent, and there was an internal error. return False if not sequential_calls and not parallel_calls: # Nothing to send return None sequential_result = Apprise._notify_sequential(*sequential_calls) parallel_result = await Apprise._notify_parallel_asyncio( *parallel_calls ) return sequential_result and parallel_result 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.""" success = True for server, kwargs in servers_kwargs: try: # Send notification result = server.notify(**kwargs) success = success and result except TypeError: # These are our internally thrown notifications. success = False except Exception: # A catch all so we don't have to abort early # just because one of our plugins has a bug in it. logger.exception("Unhandled Notification Exception") success = False return success @staticmethod def _notify_parallel_threadpool(*servers_kwargs): """Process a list of notify() calls in parallel and synchronously.""" n_calls = len(servers_kwargs) # 0-length case if n_calls == 0: return True # There's no need to use a thread pool for just a single notification if n_calls == 1: return Apprise._notify_sequential(servers_kwargs[0]) # Create log entry logger.info( "Notifying %d service(s) with threads.", len(servers_kwargs) ) with cf.ThreadPoolExecutor() as executor: success = True futures = [ executor.submit(server.notify, **kwargs) for (server, kwargs) in servers_kwargs ] for future in cf.as_completed(futures): try: result = future.result() success = success and result except TypeError: # These are our internally thrown notifications. success = False except Exception: # A catch all so we don't have to abort early # just because one of our plugins has a bug in it. logger.exception("Unhandled Notification Exception") success = False return success @staticmethod async def _notify_parallel_asyncio(*servers_kwargs): """Process a list of async_notify() calls in parallel and asynchronously.""" n_calls = len(servers_kwargs) # 0-length case if n_calls == 0: return True # (Unlike with the thread pool, we don't optimize for the single- # notification case because asyncio can do useful work while waiting # for that thread to complete) # Create log entry logger.info( "Notifying %d service(s) asynchronously.", len(servers_kwargs) ) async def do_call(server, kwargs): return await server.async_notify(**kwargs) cors = (do_call(server, kwargs) for (server, kwargs) in servers_kwargs) results = await asyncio.gather(*cors, return_exceptions=True) if any( isinstance(status, Exception) and not isinstance(status, TypeError) for status in results ): # A catch all so we don't have to abort early just because # one of our plugins has a bug in it. logger.exception("Unhandled Notification Exception") return False if any(isinstance(status, TypeError) for status in results): # These are our internally thrown notifications. 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 ) apprise-1.10.0/apprise/apprise_attachment.py000066400000000000000000000325471517341665700211560ustar00rootroot00000000000000# 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) apprise-1.10.0/apprise/apprise_config.py000066400000000000000000000441511517341665700202650ustar00rootroot00000000000000# 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) apprise-1.10.0/apprise/asset.py000066400000000000000000000425021517341665700164120ustar00rootroot00000000000000# 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 # 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 # 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 apprise-1.10.0/apprise/assets/000077500000000000000000000000001517341665700162205ustar00rootroot00000000000000apprise-1.10.0/apprise/assets/NotifyXML-1.0.xsd000066400000000000000000000017321517341665700210700ustar00rootroot00000000000000 apprise-1.10.0/apprise/assets/NotifyXML-1.1.xsd000066400000000000000000000033361517341665700210730ustar00rootroot00000000000000 apprise-1.10.0/apprise/assets/themes/000077500000000000000000000000001517341665700175055ustar00rootroot00000000000000apprise-1.10.0/apprise/assets/themes/default/000077500000000000000000000000001517341665700211315ustar00rootroot00000000000000apprise-1.10.0/apprise/assets/themes/default/apprise-failure-128x128.ico000066400000000000000000002040761517341665700256610ustar00rootroot00000000000000€€ ((€ !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,€πόπΐ?ψΰΐ?όψπΐ€?ώόψπΰΐ€??ώόψψππΰΐΐ€€??ώώόόψψψππππππΰΰΰΰΰΰΐΐΐΐΐΐΐΐΐΐΐΰΰΰΰΰΰπππππππψψόόώ?ώ?€ΐΐΰππψόόώ?€ΐΰΰπψώ?€ΐπψόΐΰψΐψώ?ώ?apprise-1.10.0/apprise/assets/themes/default/apprise-failure-128x128.png000066400000000000000000000374071517341665700256750ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-failure-256x256.png000066400000000000000000001217131517341665700256730ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-failure-32x32.png000066400000000000000000000046051517341665700255130ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-failure-72x72.png000066400000000000000000000166601517341665700255270ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-info-128x128.ico000066400000000000000000002040761517341665700251650ustar00rootroot00000000000000€€ ((€ !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,€πόπΐ?ψΰΐ?όψπΐ€?ώόψπΰΐ€??ώόψψππΰΐΐ€€??ώώόόψψψππππππΰΰΰΰΰΰΐΐΐΐΐΐΐΐΐΐΐΰΰΰΰΰΰπππππππψψόόώ?ώ?€ΐΐΰππψόόώ?€ΐΰΰπψώ?€ΐπψόΐΰψΐψώ?ώ?apprise-1.10.0/apprise/assets/themes/default/apprise-info-128x128.png000066400000000000000000000404371517341665700251760ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-info-256x256.png000066400000000000000000001245031517341665700251770ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-info-72x72.png000066400000000000000000000173031517341665700250260ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-logo.png000066400000000000000000004722131517341665700242510ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-success-128x128.ico000066400000000000000000002040761517341665700257020ustar00rootroot00000000000000€€ ((€ !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,€πόπΐ?ψΰΐ?όψπΐ€?ώόψπΰΐ€??ώόψψππΰΐΐ€€??ώώόόψψψππππππΰΰΰΰΰΰΐΐΐΐΐΐΐΐΐΐΐΰΰΰΰΰΰπππππππψψόόώ?ώ?€ΐΐΰππψόόώ?€ΐΰΰπψώ?€ΐπψόΐΰψΐψώ?ώ?apprise-1.10.0/apprise/assets/themes/default/apprise-success-128x128.png000066400000000000000000000420461517341665700257110ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-success-256x256.png000066400000000000000000001371311517341665700257150ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-warning-128x128.ico000066400000000000000000002040761517341665700256770ustar00rootroot00000000000000€€ ((€ !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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-warning-256x256.png000066400000000000000000001252741517341665700257170ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/assets/themes/default/apprise-warning-72x72.png000066400000000000000000000173511517341665700255430ustar00rootroot00000000000000‰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`‚apprise-1.10.0/apprise/attachment/000077500000000000000000000000001517341665700170465ustar00rootroot00000000000000apprise-1.10.0/apprise/attachment/__init__.py000066400000000000000000000031661517341665700211650ustar00rootroot00000000000000# 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", ] apprise-1.10.0/apprise/attachment/base.py000066400000000000000000000400761517341665700203410ustar00rootroot00000000000000# 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 """ # Remove all open pointers while self.__pointers: 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"]: results["name"] = results["qsd"].get("name", "").strip().lower() # 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() apprise-1.10.0/apprise/attachment/file.py000066400000000000000000000115301517341665700203370ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/attachment/http.py000066400000000000000000000327671517341665700204160ustar00rootroot00000000000000# 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 .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, stream=True, ) as r: # Handle Errors r.raise_for_status() # 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) 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="/"), 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 apprise-1.10.0/apprise/attachment/memory.py000066400000000000000000000153771517341665700207450ustar00rootroot00000000000000# 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() apprise-1.10.0/apprise/cli.py000066400000000000000000001120051517341665700160360ustar00rootroot00000000000000# 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, 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 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 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) 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])) 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, ) ) 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 our list filter_uids = 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_ # Get our detected URL IDs uids = {} for plugin in a if not tags else a.find(tag=tags): id_ = plugin.url_id() if not id_: continue if filter_uids and next( (False for n in filter_uids if id_.startswith(n)), True ): 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: detected_uid = PersistentStore.disk_scan( # Use our asset path as it has already been properly parsed path=asset.storage_path, # Provide filter if specified namespace=filter_uids, ) 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(entry.tags)) ) else: # PersistentStorageMode.PRUNE or PersistentStorageMode.CLEAR if action == PersistentStorageMode.CLEAR: storage_prune_days = 0 # 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=filter_uids if filter_uids else None, # 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(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) apprise-1.10.0/apprise/common.py000066400000000000000000000155561517341665700165740ustar00rootroot00000000000000# 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" apprise-1.10.0/apprise/compat.py000066400000000000000000000037731517341665700165650ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/config/000077500000000000000000000000001517341665700161635ustar00rootroot00000000000000apprise-1.10.0/apprise/config/__init__.py000066400000000000000000000031671517341665700203030ustar00rootroot00000000000000# 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", ] apprise-1.10.0/apprise/config/base.py000066400000000000000000001560421517341665700174570ustar00rootroot00000000000000# 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 ..url import URLBase from ..utils.cwe312 import cwe312_url from ..utils.parse import GET_SCHEMA_RE, 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) # 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 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 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)) # 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: # Log invalid entries so that maintainer of config # config file at least has something to take action # with. ConfigBase.logger.warning( f"Ignored entry {key} found under urls, entry" f" #{no + 1}" ) 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 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)): # 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() # We are a url string with additional unescaped options if isinstance(entries, dict): url_, tokens = next(iter(url.items())) # Tags you just can't over-ride if "schema" in entries: del entries["schema"] # support our special tokens (if they're present) if schema in N_MGR: entries = ConfigBase._special_token_handler( schema, entries ) # Extend our dictionary with our new entries r.update(entries) # add our results to our global set results.append(r) elif isinstance(tokens, dict): # support our special tokens (if they're present) if schema in N_MGR: tokens = ConfigBase._special_token_handler( schema, tokens ) # 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) 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_ = 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 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 # Handle post processing of result set results_ = URLBase.post_process_parse_url_results(results_) # 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() 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) apprise-1.10.0/apprise/config/file.py000066400000000000000000000137451517341665700174660ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/config/http.py000066400000000000000000000223411517341665700175160ustar00rootroot00000000000000# 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 .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="/"), 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, stream=True, ) as r: # Handle Errors r.raise_for_status() # 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 apprise-1.10.0/apprise/config/memory.py000066400000000000000000000052551517341665700200540ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/conversion.py000066400000000000000000000143701517341665700174620ustar00rootroot00000000000000# 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 html.parser import HTMLParser import re from markdown import markdown from .common import NotifyFormat from .url import URLBase 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) """ converters = { (NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html, (NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html, (NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text, # For now; use same converter for Markdown support (NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text, } convert = converters.get((from_format, to_format)) return convert(content) if convert else content def markdown_to_html(content): """Converts specified content from markdown to HTML.""" 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.""" parser = HTMLConverter() parser.feed(content) parser.close() 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): super().__init__(**kwargs) # Shoudl 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): string = "".join(self._finalize(self._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.""" # initialize our previous flag if self._do_store: # Tidy our whitespace content = self.WS_TRIM.sub(" ", data) self._result.append(content) def handle_starttag(self, tag, attrs): """Process our starting HTML Tag.""" # Toggle initial states self._do_store = tag not in self.IGNORE_TAGS if tag in self.BLOCK_TAGS: self._result.append(self.BLOCK_END) if tag == "li": self._result.append("- ") elif tag == "br": self._result.append("\n") elif tag == "hr": if self._result and isinstance(self._result[-1], str): self._result[-1] = self._result[-1].rstrip(" ") self._result.append("\n---\n") elif tag == "blockquote": self._result.append(" >") def handle_endtag(self, tag): """Edge case handling of open/close tags.""" self._do_store = True if tag in self.BLOCK_TAGS: self._result.append(self.BLOCK_END) apprise-1.10.0/apprise/decorators/000077500000000000000000000000001517341665700170635ustar00rootroot00000000000000apprise-1.10.0/apprise/decorators/__init__.py000066400000000000000000000026601517341665700212000ustar00rootroot00000000000000# 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"] apprise-1.10.0/apprise/decorators/base.py000066400000000000000000000173331517341665700203560ustar00rootroot00000000000000# 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, ) apprise-1.10.0/apprise/decorators/notify.py000066400000000000000000000117121517341665700207470ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/emojis.py000066400000000000000000002544461517341665700165750ustar00rootroot00000000000000# 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 "" apprise-1.10.0/apprise/exception.py000066400000000000000000000047201517341665700172710ustar00rootroot00000000000000# 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) apprise-1.10.0/apprise/i18n/000077500000000000000000000000001517341665700154755ustar00rootroot00000000000000apprise-1.10.0/apprise/i18n/__init__.py000066400000000000000000000000001517341665700175740ustar00rootroot00000000000000apprise-1.10.0/apprise/i18n/en/000077500000000000000000000000001517341665700160775ustar00rootroot00000000000000apprise-1.10.0/apprise/i18n/en/LC_MESSAGES/000077500000000000000000000000001517341665700176645ustar00rootroot00000000000000apprise-1.10.0/apprise/i18n/en/LC_MESSAGES/apprise.po000066400000000000000000001441741517341665700217020ustar00rootroot00000000000000# 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.10.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-04-26 10:09-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:141 msgid "Verify SSL" msgstr "Verify SSL" #: apprise/url.py:151 #, fuzzy msgid "Socket Read Timeout" msgstr "Server Timeout" #: apprise/url.py:165 #, 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:46 apprise/config/http.py:54 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:105 #: apprise/plugins/exotel.py:213 apprise/plugins/fcm/__init__.py:143 #: 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/mailgun.py:146 #: apprise/plugins/messagebird.py:78 apprise/plugins/octopush.py:136 #: apprise/plugins/one_signal.py:113 apprise/plugins/opsgenie.py:228 #: apprise/plugins/pagerduty.py:131 apprise/plugins/popcorn_notify.py:71 #: 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 "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: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/email/base.py:139 apprise/plugins/evolution.py:134 #: 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/irc/base.py:149 apprise/plugins/join.py:172 #: apprise/plugins/kavenegar.py:135 apprise/plugins/line.py:93 #: 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:175 #: 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/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/popcorn_notify.py:89 apprise/plugins/postmark.py:133 #: apprise/plugins/pushbullet.py:105 apprise/plugins/pushed.py:107 #: apprise/plugins/pushover.py:215 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/rocketchat.py:167 apprise/plugins/sendgrid.py:133 #: apprise/plugins/sendpulse.py:139 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:251 #: apprise/plugins/smpp.py:128 apprise/plugins/smseagle.py:172 #: apprise/plugins/smsmanager.py:119 apprise/plugins/sns.py:138 #: apprise/plugins/telegram.py:359 apprise/plugins/threema.py:115 #: apprise/plugins/twilio.py:174 apprise/plugins/twist.py:123 #: apprise/plugins/twitter.py:168 apprise/plugins/vapid/__init__.py:158 #: apprise/plugins/voipms.py:108 apprise/plugins/vonage.py:108 #: apprise/plugins/whatsapp.py:132 apprise/plugins/wxpusher.py:139 #: apprise/plugins/xmpp/base.py:129 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: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/popcorn_notify.py:104 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:193 #, fuzzy msgid "Batch Mode" msgstr "Webhook Mode" #: 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:111 #: apprise/plugins/fluxer.py:159 apprise/plugins/gotify.py:129 #: apprise/plugins/growl.py:140 apprise/plugins/home_assistant.py:122 #: 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:147 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74 #: apprise/plugins/notica.py:126 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/synology.py:83 #: apprise/plugins/workflows.py:126 apprise/plugins/xmpp/base.py:96 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:128 #: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:115 #: apprise/plugins/evolution.py:116 apprise/plugins/fluxer.py:163 #: apprise/plugins/gotify.py:140 apprise/plugins/growl.py:145 #: apprise/plugins/home_assistant.py:127 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:152 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130 #: 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/synology.py:88 #: apprise/plugins/workflows.py:131 apprise/plugins/xmpp/base.py:101 msgid "Port" msgstr "Port" #: 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/ntfy.py:221 #: apprise/plugins/opsgenie.py:234 apprise/plugins/pushjet.py:88 #: apprise/plugins/rocketchat.py:131 apprise/plugins/signal_api.py:108 #: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:94 msgid "Username" msgstr "Username" #: 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:118 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/irc/base.py:132 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:277 apprise/plugins/mqtt.py:163 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:90 #: apprise/plugins/notica.py:140 apprise/plugins/ntfy.py:225 #: apprise/plugins/pushjet.py:92 apprise/plugins/reddit.py:145 #: apprise/plugins/rocketchat.py:135 apprise/plugins/signal_api.py:112 #: apprise/plugins/simplepush.py:108 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:98 apprise/plugins/twist.py:101 #: apprise/plugins/voipms.py:88 apprise/plugins/xmpp/base.py:112 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/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:294 #: 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:288 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: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 "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:114 #: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:158 #: 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:154 apprise/plugins/fcm/__init__.py:157 #: apprise/plugins/home_assistant.py:148 apprise/plugins/pushbullet.py:89 #: apprise/plugins/pushover.py:203 apprise/plugins/pushsafer.py:366 #: apprise/plugins/pushy.py:85 msgid "Target Device" msgstr "Target Device" #: apprise/plugins/bark.py:171 apprise/plugins/lametric.py:516 #: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:232 #: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110 msgid "Sound" msgstr "Sound" #: apprise/plugins/bark.py:176 msgid "Level" msgstr "" #: apprise/plugins/bark.py:181 msgid "Volume" msgstr "" #: apprise/plugins/bark.py:187 apprise/plugins/ntfy.py:270 #: apprise/plugins/pagerduty.py:172 msgid "Click" msgstr "" #: apprise/plugins/bark.py:191 apprise/plugins/pushy.py:114 msgid "Badge" msgstr "" #: apprise/plugins/bark.py:196 msgid "Category" msgstr "" #: apprise/plugins/bark.py:200 apprise/plugins/join.py:158 #: apprise/plugins/pagerduty.py:163 msgid "Group" msgstr "Group" #: apprise/plugins/bark.py:204 apprise/plugins/dbus.py:228 #: apprise/plugins/discord.py:196 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/msteams.py:200 #: apprise/plugins/notifiarr.py:125 apprise/plugins/ntfy.py:256 #: apprise/plugins/one_signal.py:164 apprise/plugins/pagerduty.py:191 #: apprise/plugins/ryver.py:124 apprise/plugins/slack.py:262 #: apprise/plugins/telegram.py:370 apprise/plugins/vapid/__init__.py:204 #: apprise/plugins/windows.py:106 apprise/plugins/workflows.py:163 msgid "Include Image" msgstr "Include Image" #: apprise/plugins/bark.py:210 apprise/plugins/mattermost.py:208 #: apprise/plugins/revolt.py:129 msgid "Icon URL" msgstr "" #: apprise/plugins/bark.py:214 apprise/plugins/streamlabs.py:119 msgid "Call" msgstr "" #: apprise/plugins/base.py:242 msgid "Overflow Mode" msgstr "Overflow Mode" #: apprise/plugins/base.py:257 msgid "Notify Format" msgstr "Notify Format" #: apprise/plugins/base.py:267 #, fuzzy msgid "Interpret Emojis" msgstr "Target Emails" #: apprise/plugins/base.py:277 msgid "Persistent Storage" msgstr "" #: apprise/plugins/base.py:287 #, fuzzy msgid "Timezone" msgstr "Server Timeout" #: 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/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:134 #: apprise/plugins/mailgun.py:152 apprise/plugins/notificationapi.py:177 #: apprise/plugins/office365.py:282 apprise/plugins/one_signal.py:124 #: apprise/plugins/popcorn_notify.py:84 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:234 #: 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:193 #, fuzzy msgid "Reply To Email" msgstr "To Email" #: apprise/plugins/brevo.py:193 apprise/plugins/email/base.py:195 #: 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:215 #: apprise/plugins/smtp2go.py:143 apprise/plugins/sparkpost.py:201 msgid "Carbon Copy" msgstr "" #: apprise/plugins/brevo.py:197 apprise/plugins/email/base.py:199 #: 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:219 #: 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/evolution.py:127 #: 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/octopush.py:146 #: apprise/plugins/plivo.py:107 apprise/plugins/popcorn_notify.py:77 #: apprise/plugins/seven.py:81 apprise/plugins/signal_api.py:124 #: apprise/plugins/sinch.py:127 apprise/plugins/smpp.py:121 #: apprise/plugins/smseagle.py:151 apprise/plugins/smsmanager.py:112 #: apprise/plugins/sns.py:125 apprise/plugins/threema.py:98 #: apprise/plugins/twilio.py:161 apprise/plugins/voipms.py:101 #: apprise/plugins/vonage.py:101 apprise/plugins/whatsapp.py:125 msgid "Target Phone No" msgstr "Target Phone No" #: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:209 #, 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/fortysixelks.py:100 apprise/plugins/httpsms.py:83 #: apprise/plugins/httpsms.py:115 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 #, 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/clickatell.py:45 msgid "Clickatell" msgstr "" #: apprise/plugins/clickatell.py:72 apprise/plugins/rocketchat.py:140 #, fuzzy msgid "API Token" msgstr "API Key" #: apprise/plugins/custom_form.py:157 apprise/plugins/custom_json.py:139 #: apprise/plugins/custom_xml.py:139 msgid "Fetch Method" msgstr "" #: apprise/plugins/custom_form.py:163 msgid "Attach File As" msgstr "" #: 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 "" #: apprise/plugins/custom_form.py:182 apprise/plugins/custom_json.py:158 #: apprise/plugins/custom_xml.py:158 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:282 #: apprise/plugins/opsgenie.py:280 apprise/plugins/prowl.py:141 #: apprise/plugins/pushover.py:226 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 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:125 apprise/plugins/flock.py:106 #: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106 #: apprise/plugins/slack.py:186 apprise/plugins/viber.py:99 #: apprise/plugins/zulip.py:124 msgid "Bot Name" msgstr "Bot Name" #: apprise/plugins/discord.py:130 apprise/plugins/fluxer.py:174 #: apprise/plugins/ifttt.py:103 msgid "Webhook ID" msgstr "Webhook ID" #: apprise/plugins/discord.py:136 apprise/plugins/fluxer.py:181 #: apprise/plugins/google_chat.py:118 apprise/plugins/webexteams.py:171 msgid "Webhook Token" msgstr "Webhook Token" #: apprise/plugins/discord.py:149 apprise/plugins/fluxer.py:201 msgid "Text To Speech" msgstr "Text To Speech" #: apprise/plugins/discord.py:154 apprise/plugins/fluxer.py:206 msgid "Avatar Image" msgstr "Avatar Image" #: apprise/plugins/discord.py:159 apprise/plugins/fluxer.py:211 #: apprise/plugins/ntfy.py:262 #, fuzzy msgid "Avatar URL" msgstr "Avatar Image" #: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:215 #: apprise/plugins/pushover.py:238 msgid "URL" msgstr "" #: apprise/plugins/discord.py:172 apprise/plugins/fluxer.py:222 msgid "Thread ID" msgstr "" #: apprise/plugins/discord.py:176 apprise/plugins/fluxer.py:230 msgid "Display Footer" msgstr "Display Footer" #: apprise/plugins/discord.py:181 apprise/plugins/fluxer.py:235 msgid "Footer Logo" msgstr "Footer Logo" #: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:240 #, fuzzy msgid "Use Fields" msgstr "To User ID" #: apprise/plugins/discord.py:191 apprise/plugins/fluxer.py:245 msgid "Discord Flags" msgstr "" #: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:256 msgid "Ping Users/Roles" msgstr "" #: 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:130 apprise/plugins/gotify.py:134 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:145 msgid "Path" msgstr "" #: apprise/plugins/enigma2.py:140 msgid "Server Timeout" msgstr "Server Timeout" #: apprise/plugins/evolution.py:122 #, 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/notificationapi.py:212 #: apprise/plugins/opsgenie.py:273 apprise/plugins/pagerduty.py:176 #: 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:150 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/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188 #: apprise/plugins/fluxer.py:195 apprise/plugins/lametric.py:510 #: apprise/plugins/mattermost.py:224 apprise/plugins/notificationapi.py:218 #: apprise/plugins/ntfy.py:296 apprise/plugins/office365.py:319 #: apprise/plugins/vapid/__init__.py:169 apprise/plugins/webexteams.py:197 #, 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:106 msgid "Workspace" msgstr "" #: apprise/plugins/google_chat.py:112 #, fuzzy msgid "Webhook Key" msgstr "Webhook Token" #: apprise/plugins/google_chat.py:124 #, fuzzy msgid "Thread Key" msgstr "Secret Key" #: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:193 #: apprise/plugins/msteams.py:206 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Version" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "" #: apprise/plugins/home_assistant.py:142 #, fuzzy msgid "Long-Lived Access Token" msgstr "Access Token" #: apprise/plugins/home_assistant.py:165 msgid "Notification ID" msgstr "" #: apprise/plugins/home_assistant.py:182 msgid "Path Prefix" msgstr "" #: 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: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:239 apprise/plugins/twitter.py:162 #: apprise/plugins/xmpp/base.py:118 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/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:344 msgid "Bot Token" msgstr "Bot Token" #: 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:197 #: apprise/plugins/smseagle.py:146 apprise/plugins/spugpush.py:68 #: apprise/plugins/streamlabs.py:105 apprise/plugins/whatsapp.py:99 msgid "Access Token" msgstr "Access 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:123 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:154 apprise/plugins/mailgun.py:168 #: apprise/plugins/postmark.py:144 apprise/plugins/resend.py:140 #: apprise/plugins/ses.py:198 apprise/plugins/smtp2go.py:135 #: apprise/plugins/sparkpost.py:186 msgid "From Name" msgstr "From Name" #: apprise/plugins/email/base.py:208 apprise/plugins/mailgun.py:204 #: apprise/plugins/smtp2go.py:161 apprise/plugins/sparkpost.py:219 #, fuzzy msgid "Email Header" msgstr "HTTP Header" #: apprise/plugins/mailgun.py:208 apprise/plugins/msteams.py:222 #: apprise/plugins/notificationapi.py:256 apprise/plugins/sparkpost.py:223 #: apprise/plugins/workflows.py:202 #, fuzzy msgid "Template Tokens" msgstr "Remove Tokens" #: 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:185 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:151 #: apprise/plugins/xmpp/base.py:107 #, fuzzy msgid "User" msgstr "Username" #: 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:245 apprise/plugins/twist.py:112 #: apprise/plugins/xmpp/base.py:123 msgid "Target Channel" msgstr "Target Channel" #: apprise/plugins/mattermost.py:183 apprise/plugins/twist.py:118 #, fuzzy msgid "Target Channel ID" msgstr "Target Channel" #: apprise/plugins/mqtt.py:169 #, fuzzy msgid "Target Queue" msgstr "Target User" #: apprise/plugins/mqtt.py:186 msgid "QOS" msgstr "" #: apprise/plugins/mqtt.py:199 apprise/plugins/notificationapi.py:166 #: apprise/plugins/office365.py:269 apprise/plugins/sendpulse.py:120 #, fuzzy msgid "Client ID" msgstr "Account SID" #: apprise/plugins/mqtt.py:203 msgid "Use Session" msgstr "" #: apprise/plugins/mqtt.py:208 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:169 msgid "Template Mapping" msgstr "" #: apprise/plugins/msteams.py:151 #, fuzzy msgid "Team Name" msgstr "Bot Name" #: apprise/plugins/msteams.py:159 apprise/plugins/slack.py:203 msgid "Token A" msgstr "Token A" #: apprise/plugins/msteams.py:168 apprise/plugins/slack.py:212 msgid "Token B" msgstr "Token B" #: apprise/plugins/msteams.py:177 apprise/plugins/slack.py:221 msgid "Token C" msgstr "Token C" #: apprise/plugins/msteams.py:186 #, fuzzy msgid "Token D" msgstr "Token C" #: apprise/plugins/msteams.py:212 apprise/plugins/workflows.py:181 msgid "Template Path" 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/matrix/base.py:352 apprise/plugins/notificationapi.py:159 msgid "Message Type" msgstr "" #: apprise/plugins/notificationapi.py:171 apprise/plugins/office365.py:276 #: apprise/plugins/sendpulse.py:127 #, fuzzy msgid "Client Secret" msgstr "Access Secret" #: apprise/plugins/notificationapi.py:182 #, fuzzy msgid "Target ID" msgstr "Target User" #: apprise/plugins/notificationapi.py:187 #, fuzzy msgid "Target SMS" msgstr "Targets" #: apprise/plugins/notificationapi.py:207 msgid "Channels" msgstr "Channels" #: apprise/plugins/email/base.py:171 apprise/plugins/notificationapi.py:223 #: apprise/plugins/office365.py:315 apprise/plugins/resend.py:150 msgid "Reply To" msgstr "" #: apprise/plugins/email/base.py:149 apprise/plugins/notificationapi.py:228 #: apprise/plugins/sendpulse.py:150 apprise/plugins/ses.py:154 msgid "From Email" msgstr "From Email" #: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:124 #, fuzzy msgid "Project ID" msgstr "Target JID" #: apprise/plugins/notifico.py:133 msgid "Message Hook" msgstr "" #: apprise/plugins/notifico.py:148 msgid "IRC Colors" msgstr "" #: apprise/plugins/notifico.py:154 msgid "Prefix" msgstr "" #: apprise/plugins/ntfy.py:235 msgid "Topic" msgstr "" #: apprise/plugins/ntfy.py:252 msgid "Attach" msgstr "" #: apprise/plugins/ntfy.py:266 msgid "Attach Filename" msgstr "" #: apprise/plugins/ntfy.py:274 msgid "Delay" msgstr "" #: apprise/plugins/ntfy.py:278 apprise/plugins/twist.py:107 #, fuzzy msgid "Email" msgstr "To Email" #: apprise/plugins/ntfy.py:292 #, fuzzy msgid "Actions" msgstr "Duration" #: apprise/plugins/ntfy.py:305 #, 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/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:119 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/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/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:191 msgid "User Key" msgstr "User Key" #: apprise/plugins/pushover.py:243 msgid "URL Title" msgstr "" #: apprise/plugins/pushover.py:248 msgid "Retry" msgstr "" #: apprise/plugins/pushover.py:254 msgid "Expire" msgstr "" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:66 #, fuzzy msgid "User Token" msgstr "User Key" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "" #: apprise/plugins/pushplus.py:241 #, fuzzy msgid "Channel" msgstr "Channels" #: apprise/plugins/pushplus.py:258 #, 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/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:131 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/rocketchat.py:146 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:295 apprise/plugins/rocketchat.py:162 msgid "Target Room ID" msgstr "Target Room ID" #: apprise/plugins/matrix/base.py:334 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/ses.py:160 apprise/plugins/sns.py:106 msgid "Access Key ID" msgstr "Access Key ID" #: apprise/plugins/ses.py:166 apprise/plugins/sns.py:112 msgid "Secret Access Key" msgstr "Secret Access Key" #: apprise/plugins/ses.py:172 apprise/plugins/sinch.py:157 #: apprise/plugins/sns.py:118 msgid "Region" msgstr "Region" #: apprise/plugins/ses.py:179 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:131 #, fuzzy msgid "Sender Name" msgstr "User Name" #: 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/signal_api.py:131 apprise/plugins/smseagle.py:158 #, fuzzy msgid "Target Group ID" msgstr "Target Room ID" #: 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:194 #, fuzzy msgid "OAuth Access Token" msgstr "Access Token" #: apprise/plugins/slack.py:228 msgid "Target Encoded ID" msgstr "Target Encoded ID" #: apprise/plugins/slack.py:268 #, fuzzy msgid "Include Footer" msgstr "Include Image" #: apprise/plugins/slack.py:276 msgid "Use Blocks" msgstr "" #: apprise/plugins/slack.py:282 #, fuzzy msgid "Include Timestamp" msgstr "Include Image" #: apprise/plugins/slack.py:288 apprise/plugins/twitter.py:179 #, 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/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/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:116 msgid "Upload" msgstr "" #: apprise/plugins/syslog.py:167 msgid "Log to STDERR" msgstr "" #: apprise/plugins/telegram.py:353 msgid "Target Chat ID" msgstr "Target Chat ID" #: apprise/plugins/telegram.py:376 msgid "Detect Bot Owner" msgstr "Detect Bot Owner" #: apprise/plugins/telegram.py:382 msgid "Silent Notification" msgstr "" #: apprise/plugins/telegram.py:387 msgid "Web Page Preview" msgstr "" #: apprise/plugins/telegram.py:392 msgid "Topic Thread ID" msgstr "" #: apprise/plugins/telegram.py:399 #, fuzzy msgid "Markdown Version" msgstr "Version" #: apprise/plugins/telegram.py:408 msgid "Content Placement" 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/twilio.py:200 msgid "Notification Method: sms or call" msgstr "" #: apprise/plugins/twitter.py:138 msgid "Consumer Key" msgstr "Consumer Key" #: apprise/plugins/twitter.py:144 msgid "Consumer Secret" msgstr "Consumer Secret" #: apprise/plugins/twitter.py:156 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/wecombot.py:99 #, fuzzy msgid "Bot Webhook Key" msgstr "Webhook Token" #: apprise/plugins/whatsapp.py:106 msgid "Template Name" msgstr "" #: apprise/plugins/whatsapp.py:112 #, 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:138 #, fuzzy msgid "Workflow ID" msgstr "Overflow Mode" #: apprise/plugins/workflows.py:146 msgid "Signature" msgstr "" #: apprise/plugins/workflows.py:169 msgid "Use Power Automate URL" msgstr "" #: apprise/plugins/workflows.py:176 msgid "Wrap Text" msgstr "" #: apprise/plugins/workflows.py:191 #, 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/zulip.py:148 #, fuzzy msgid "Target Stream" msgstr "Target User" #: apprise/plugins/email/base.py:159 msgid "SMTP Server" msgstr "SMTP Server" #: apprise/plugins/email/base.py:164 apprise/plugins/xmpp/base.py:144 msgid "Secure Mode" msgstr "Secure Mode" #: apprise/plugins/email/base.py:176 msgid "PGP Encryption" msgstr "" #: apprise/plugins/email/base.py:182 msgid "PGP Public Key Path" msgstr "" #: apprise/plugins/email/base.py:190 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:301 msgid "Target Room Alias" msgstr "Target Room Alias" #: apprise/plugins/matrix/base.py:324 #, fuzzy msgid "Server Discovery" msgstr "Server Timeout" #: apprise/plugins/matrix/base.py:329 msgid "Force Home Server on Room IDs" msgstr "" #: apprise/plugins/matrix/base.py:340 #, fuzzy msgid "Webhook Path" msgstr "Webhook" #: apprise/plugins/matrix/base.py:346 msgid "Matrix API Verion" msgstr "" #: apprise/plugins/matrix/base.py:358 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:139 #, fuzzy msgid "XMPP Server" msgstr "SMTP Server" #: apprise/plugins/xmpp/base.py:151 #, fuzzy msgid "Get Roster" msgstr "Target User" #: apprise/plugins/xmpp/base.py:156 msgid "Use Subject" msgstr "" #: apprise/plugins/xmpp/base.py:161 #, fuzzy msgid "Keep Connection Alive" msgstr "Server Timeout" #: apprise/plugins/xmpp/base.py:167 #, fuzzy msgid "MUC Nickname" msgstr "Username" apprise-1.10.0/apprise/i18n/es/000077500000000000000000000000001517341665700161045ustar00rootroot00000000000000apprise-1.10.0/apprise/i18n/es/LC_MESSAGES/000077500000000000000000000000001517341665700176715ustar00rootroot00000000000000apprise-1.10.0/apprise/i18n/es/LC_MESSAGES/apprise.po000066400000000000000000001537671517341665700217170ustar00rootroot00000000000000# 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.9.9\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-04-26 10:09-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:141 msgid "Verify SSL" msgstr "Verificar SSL" #: apprise/url.py:151 #, fuzzy msgid "Socket Read Timeout" msgstr "Tiempo de Espera de Lectura del Socket" #: apprise/url.py:165 #, 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:46 apprise/config/http.py:54 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:105 #: apprise/plugins/exotel.py:213 apprise/plugins/fcm/__init__.py:143 #: 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/mailgun.py:146 #: apprise/plugins/messagebird.py:78 apprise/plugins/octopush.py:136 #: apprise/plugins/one_signal.py:113 apprise/plugins/opsgenie.py:228 #: apprise/plugins/pagerduty.py:131 apprise/plugins/popcorn_notify.py:71 #: 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 "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: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/email/base.py:139 apprise/plugins/evolution.py:134 #: 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/irc/base.py:149 apprise/plugins/join.py:172 #: apprise/plugins/kavenegar.py:135 apprise/plugins/line.py:93 #: 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:175 #: 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/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/popcorn_notify.py:89 apprise/plugins/postmark.py:133 #: apprise/plugins/pushbullet.py:105 apprise/plugins/pushed.py:107 #: apprise/plugins/pushover.py:215 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/rocketchat.py:167 apprise/plugins/sendgrid.py:133 #: apprise/plugins/sendpulse.py:139 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:251 #: apprise/plugins/smpp.py:128 apprise/plugins/smseagle.py:172 #: apprise/plugins/smsmanager.py:119 apprise/plugins/sns.py:138 #: apprise/plugins/telegram.py:359 apprise/plugins/threema.py:115 #: apprise/plugins/twilio.py:174 apprise/plugins/twist.py:123 #: apprise/plugins/twitter.py:168 apprise/plugins/vapid/__init__.py:158 #: apprise/plugins/voipms.py:108 apprise/plugins/vonage.py:108 #: apprise/plugins/whatsapp.py:132 apprise/plugins/wxpusher.py:139 #: apprise/plugins/xmpp/base.py:129 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: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/popcorn_notify.py:104 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:193 #, fuzzy msgid "Batch Mode" msgstr "Modo por Lotes" #: 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:111 #: apprise/plugins/fluxer.py:159 apprise/plugins/gotify.py:129 #: apprise/plugins/growl.py:140 apprise/plugins/home_assistant.py:122 #: 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:147 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74 #: apprise/plugins/notica.py:126 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/synology.py:83 #: apprise/plugins/workflows.py:126 apprise/plugins/xmpp/base.py:96 msgid "Hostname" msgstr "Nombre del Host" #: 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:128 #: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:115 #: apprise/plugins/evolution.py:116 apprise/plugins/fluxer.py:163 #: apprise/plugins/gotify.py:140 apprise/plugins/growl.py:145 #: apprise/plugins/home_assistant.py:127 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:152 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130 #: 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/synology.py:88 #: apprise/plugins/workflows.py:131 apprise/plugins/xmpp/base.py:101 msgid "Port" msgstr "Puerto" #: 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/ntfy.py:221 #: apprise/plugins/opsgenie.py:234 apprise/plugins/pushjet.py:88 #: apprise/plugins/rocketchat.py:131 apprise/plugins/signal_api.py:108 #: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:94 msgid "Username" msgstr "Nombre de Usuario" #: 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:118 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/irc/base.py:132 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:277 apprise/plugins/mqtt.py:163 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:90 #: apprise/plugins/notica.py:140 apprise/plugins/ntfy.py:225 #: apprise/plugins/pushjet.py:92 apprise/plugins/reddit.py:145 #: apprise/plugins/rocketchat.py:135 apprise/plugins/signal_api.py:112 #: apprise/plugins/simplepush.py:108 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:98 apprise/plugins/twist.py:101 #: apprise/plugins/voipms.py:88 apprise/plugins/xmpp/base.py:112 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/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:294 #: 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:288 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: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 "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:114 #: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:158 #: 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:154 apprise/plugins/fcm/__init__.py:157 #: apprise/plugins/home_assistant.py:148 apprise/plugins/pushbullet.py:89 #: apprise/plugins/pushover.py:203 apprise/plugins/pushsafer.py:366 #: apprise/plugins/pushy.py:85 msgid "Target Device" msgstr "Dispositivo de Destino" #: apprise/plugins/bark.py:171 apprise/plugins/lametric.py:516 #: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:232 #: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110 msgid "Sound" msgstr "Sonido" #: apprise/plugins/bark.py:176 msgid "Level" msgstr "Nivel" #: apprise/plugins/bark.py:181 msgid "Volume" msgstr "Volumen" #: apprise/plugins/bark.py:187 apprise/plugins/ntfy.py:270 #: apprise/plugins/pagerduty.py:172 msgid "Click" msgstr "Clic" #: apprise/plugins/bark.py:191 apprise/plugins/pushy.py:114 msgid "Badge" msgstr "Insignia" #: 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 "Grupo" #: apprise/plugins/bark.py:204 apprise/plugins/dbus.py:228 #: apprise/plugins/discord.py:196 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/msteams.py:200 #: apprise/plugins/notifiarr.py:125 apprise/plugins/ntfy.py:256 #: apprise/plugins/one_signal.py:164 apprise/plugins/pagerduty.py:191 #: apprise/plugins/ryver.py:124 apprise/plugins/slack.py:262 #: apprise/plugins/telegram.py:370 apprise/plugins/vapid/__init__.py:204 #: apprise/plugins/windows.py:106 apprise/plugins/workflows.py:163 msgid "Include Image" msgstr "Incluir Imagen" #: apprise/plugins/bark.py:210 apprise/plugins/mattermost.py:208 #: apprise/plugins/revolt.py:129 msgid "Icon URL" msgstr "URL del Icono" #: apprise/plugins/bark.py:214 apprise/plugins/streamlabs.py:119 msgid "Call" msgstr "Llamada" #: apprise/plugins/base.py:242 msgid "Overflow Mode" msgstr "Modo de Desbordamiento" #: apprise/plugins/base.py:257 msgid "Notify Format" msgstr "Formato de Notificacion" #: apprise/plugins/base.py:267 #, fuzzy msgid "Interpret Emojis" msgstr "Interpretar Emojis" #: apprise/plugins/base.py:277 msgid "Persistent Storage" msgstr "Almacenamiento Persistente" #: apprise/plugins/base.py:287 #, fuzzy msgid "Timezone" msgstr "Zona Horaria" #: 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/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:134 #: apprise/plugins/mailgun.py:152 apprise/plugins/notificationapi.py:177 #: apprise/plugins/office365.py:282 apprise/plugins/one_signal.py:124 #: apprise/plugins/popcorn_notify.py:84 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:234 #: 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:193 #, fuzzy msgid "Reply To Email" msgstr "Responder a Correo" #: apprise/plugins/brevo.py:193 apprise/plugins/email/base.py:195 #: 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:215 #: 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:199 #: 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:219 #: 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/evolution.py:127 #: 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/octopush.py:146 #: apprise/plugins/plivo.py:107 apprise/plugins/popcorn_notify.py:77 #: apprise/plugins/seven.py:81 apprise/plugins/signal_api.py:124 #: apprise/plugins/sinch.py:127 apprise/plugins/smpp.py:121 #: apprise/plugins/smseagle.py:151 apprise/plugins/smsmanager.py:112 #: apprise/plugins/sns.py:125 apprise/plugins/threema.py:98 #: apprise/plugins/twilio.py:161 apprise/plugins/voipms.py:101 #: apprise/plugins/vonage.py:101 apprise/plugins/whatsapp.py:125 msgid "Target Phone No" msgstr "Numero de Telefono de Destino" #: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:209 #, 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/fortysixelks.py:100 apprise/plugins/httpsms.py:83 #: apprise/plugins/httpsms.py:115 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 #, 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/clickatell.py:45 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:72 apprise/plugins/rocketchat.py:140 #, fuzzy msgid "API Token" msgstr "Token de API" #: apprise/plugins/custom_form.py:157 apprise/plugins/custom_json.py:139 #: apprise/plugins/custom_xml.py:139 msgid "Fetch Method" msgstr "Metodo de Obtencion" #: apprise/plugins/custom_form.py:163 msgid "Attach File As" msgstr "Adjuntar Archivo Como" #: 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 "Extras del Payload" #: apprise/plugins/custom_form.py:182 apprise/plugins/custom_json.py:158 #: apprise/plugins/custom_xml.py:158 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:282 #: apprise/plugins/opsgenie.py:280 apprise/plugins/prowl.py:141 #: apprise/plugins/pushover.py:226 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 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:125 apprise/plugins/flock.py:106 #: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106 #: apprise/plugins/slack.py:186 apprise/plugins/viber.py:99 #: apprise/plugins/zulip.py:124 msgid "Bot Name" msgstr "Nombre del Bot" #: apprise/plugins/discord.py:130 apprise/plugins/fluxer.py:174 #: apprise/plugins/ifttt.py:103 msgid "Webhook ID" msgstr "ID del Webhook" #: apprise/plugins/discord.py:136 apprise/plugins/fluxer.py:181 #: apprise/plugins/google_chat.py:118 apprise/plugins/webexteams.py:171 msgid "Webhook Token" msgstr "Token del Webhook" #: apprise/plugins/discord.py:149 apprise/plugins/fluxer.py:201 msgid "Text To Speech" msgstr "Texto a Voz" #: apprise/plugins/discord.py:154 apprise/plugins/fluxer.py:206 msgid "Avatar Image" msgstr "Imagen de Avatar" #: apprise/plugins/discord.py:159 apprise/plugins/fluxer.py:211 #: apprise/plugins/ntfy.py:262 #, fuzzy msgid "Avatar URL" msgstr "URL del Avatar" #: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:215 #: apprise/plugins/pushover.py:238 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:172 apprise/plugins/fluxer.py:222 msgid "Thread ID" msgstr "ID del Hilo" #: apprise/plugins/discord.py:176 apprise/plugins/fluxer.py:230 msgid "Display Footer" msgstr "Mostrar Pie de Pagina" #: apprise/plugins/discord.py:181 apprise/plugins/fluxer.py:235 msgid "Footer Logo" msgstr "Logo del Pie de Pagina" #: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:240 #, fuzzy msgid "Use Fields" msgstr "Usar Campos" #: apprise/plugins/discord.py:191 apprise/plugins/fluxer.py:245 msgid "Discord Flags" msgstr "Banderas de Discord" #: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:256 msgid "Ping Users/Roles" msgstr "Mencionar Usuarios/Roles" #: 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:130 apprise/plugins/gotify.py:134 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:145 msgid "Path" msgstr "Ruta" #: apprise/plugins/enigma2.py:140 msgid "Server Timeout" msgstr "Tiempo de Espera del Servidor" #: apprise/plugins/evolution.py:122 #, 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/notificationapi.py:212 #: apprise/plugins/opsgenie.py:273 apprise/plugins/pagerduty.py:176 #: 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:150 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/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188 #: apprise/plugins/fluxer.py:195 apprise/plugins/lametric.py:510 #: apprise/plugins/mattermost.py:224 apprise/plugins/notificationapi.py:218 #: apprise/plugins/ntfy.py:296 apprise/plugins/office365.py:319 #: apprise/plugins/vapid/__init__.py:169 apprise/plugins/webexteams.py:197 #, 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:106 msgid "Workspace" msgstr "Espacio de Trabajo" #: apprise/plugins/google_chat.py:112 #, fuzzy msgid "Webhook Key" msgstr "Clave del Webhook" #: apprise/plugins/google_chat.py:124 #, fuzzy msgid "Thread Key" msgstr "Clave del Hilo" #: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:193 #: apprise/plugins/msteams.py:206 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Version" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Fijado" #: apprise/plugins/home_assistant.py:142 #, fuzzy msgid "Long-Lived Access Token" msgstr "Token de Acceso de Larga Duracion" #: apprise/plugins/home_assistant.py:165 msgid "Notification ID" msgstr "ID de Notificacion" #: apprise/plugins/home_assistant.py:182 #, fuzzy msgid "Path Prefix" msgstr "Prefijo" #: 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: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:239 apprise/plugins/twitter.py:162 #: apprise/plugins/xmpp/base.py:118 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/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:344 msgid "Bot Token" msgstr "Token del Bot" #: 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:197 #: apprise/plugins/smseagle.py:146 apprise/plugins/spugpush.py:68 #: apprise/plugins/streamlabs.py:105 apprise/plugins/whatsapp.py:99 msgid "Access Token" msgstr "Token de Acceso" #: 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:123 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:154 apprise/plugins/mailgun.py:168 #: apprise/plugins/postmark.py:144 apprise/plugins/resend.py:140 #: apprise/plugins/ses.py:198 apprise/plugins/smtp2go.py:135 #: apprise/plugins/sparkpost.py:186 msgid "From Name" msgstr "Nombre de Origen" #: apprise/plugins/email/base.py:208 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/mailgun.py:208 apprise/plugins/msteams.py:222 #: apprise/plugins/notificationapi.py:256 apprise/plugins/sparkpost.py:223 #: apprise/plugins/workflows.py:202 #, fuzzy msgid "Template Tokens" msgstr "Tokens de Plantilla" #: 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:185 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:151 #: apprise/plugins/xmpp/base.py:107 #, fuzzy msgid "User" msgstr "Usuario" #: 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:245 apprise/plugins/twist.py:112 #: apprise/plugins/xmpp/base.py:123 msgid "Target Channel" msgstr "Canal de Destino" #: apprise/plugins/mattermost.py:183 apprise/plugins/twist.py:118 #, fuzzy msgid "Target Channel ID" msgstr "ID del Canal de Destino" #: apprise/plugins/mqtt.py:169 msgid "Target Queue" msgstr "" #: apprise/plugins/mqtt.py:186 msgid "QOS" msgstr "QoS" #: apprise/plugins/mqtt.py:199 apprise/plugins/notificationapi.py:166 #: apprise/plugins/office365.py:269 apprise/plugins/sendpulse.py:120 #, fuzzy msgid "Client ID" msgstr "ID del Cliente" #: apprise/plugins/mqtt.py:203 msgid "Use Session" msgstr "Usar Sesion" #: apprise/plugins/mqtt.py:208 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:169 msgid "Template Mapping" msgstr "Mapeo de Plantilla" #: apprise/plugins/msteams.py:151 #, fuzzy msgid "Team Name" msgstr "Nombre del Equipo" #: apprise/plugins/msteams.py:159 apprise/plugins/slack.py:203 msgid "Token A" msgstr "Token A" #: apprise/plugins/msteams.py:168 apprise/plugins/slack.py:212 msgid "Token B" msgstr "Token B" #: apprise/plugins/msteams.py:177 apprise/plugins/slack.py:221 msgid "Token C" msgstr "Token C" #: apprise/plugins/msteams.py:186 #, fuzzy msgid "Token D" msgstr "Token D" #: apprise/plugins/msteams.py:212 apprise/plugins/workflows.py:181 msgid "Template Path" msgstr "Ruta 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/matrix/base.py:352 apprise/plugins/notificationapi.py:159 msgid "Message Type" msgstr "Tipo de Mensaje" #: apprise/plugins/notificationapi.py:171 apprise/plugins/office365.py:276 #: apprise/plugins/sendpulse.py:127 #, fuzzy msgid "Client Secret" msgstr "Secreto del Cliente" #: apprise/plugins/notificationapi.py:182 #, fuzzy msgid "Target ID" msgstr "ID de Destino" #: apprise/plugins/notificationapi.py:187 #, fuzzy msgid "Target SMS" msgstr "SMS de Destino" #: apprise/plugins/notificationapi.py:207 msgid "Channels" msgstr "Canales" #: apprise/plugins/email/base.py:171 apprise/plugins/notificationapi.py:223 #: apprise/plugins/office365.py:315 apprise/plugins/resend.py:150 msgid "Reply To" msgstr "Responder A" #: apprise/plugins/email/base.py:149 apprise/plugins/notificationapi.py:228 #: apprise/plugins/sendpulse.py:150 apprise/plugins/ses.py:154 msgid "From Email" msgstr "Correo de Origen" #: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:124 #, fuzzy msgid "Project ID" msgstr "ID del Proyecto" #: apprise/plugins/notifico.py:133 msgid "Message Hook" msgstr "Hook de Mensaje" #: apprise/plugins/notifico.py:148 msgid "IRC Colors" msgstr "Colores IRC" #: apprise/plugins/notifico.py:154 msgid "Prefix" msgstr "Prefijo" #: apprise/plugins/ntfy.py:235 msgid "Topic" msgstr "Tema" #: apprise/plugins/ntfy.py:252 msgid "Attach" msgstr "Adjuntar" #: apprise/plugins/ntfy.py:266 msgid "Attach Filename" msgstr "Nombre del Archivo Adjunto" #: apprise/plugins/ntfy.py:274 msgid "Delay" msgstr "Retraso" #: apprise/plugins/ntfy.py:278 apprise/plugins/twist.py:107 #, fuzzy msgid "Email" msgstr "Correo Electronico" #: apprise/plugins/ntfy.py:292 #, fuzzy msgid "Actions" msgstr "Acciones" #: apprise/plugins/ntfy.py:305 #, 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/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:119 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/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/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:191 msgid "User Key" msgstr "Clave de Usuario" #: apprise/plugins/pushover.py:243 msgid "URL Title" msgstr "Titulo de URL" #: apprise/plugins/pushover.py:248 msgid "Retry" msgstr "Reintentar" #: apprise/plugins/pushover.py:254 msgid "Expire" msgstr "Expirar" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:66 #, fuzzy msgid "User Token" msgstr "Token de Usuario" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "" #: apprise/plugins/pushplus.py:241 #, fuzzy msgid "Channel" msgstr "Canales" #: apprise/plugins/pushplus.py:258 #, 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/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:131 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/rocketchat.py:146 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:295 apprise/plugins/rocketchat.py:162 msgid "Target Room ID" msgstr "ID de Sala de Destino" #: apprise/plugins/matrix/base.py:334 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/ses.py:160 apprise/plugins/sns.py:106 msgid "Access Key ID" msgstr "ID de Clave de Acceso" #: apprise/plugins/ses.py:166 apprise/plugins/sns.py:112 msgid "Secret Access Key" msgstr "Clave de Acceso Secreta" #: apprise/plugins/ses.py:172 apprise/plugins/sinch.py:157 #: apprise/plugins/sns.py:118 msgid "Region" msgstr "Region" #: apprise/plugins/ses.py:179 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:131 #, fuzzy msgid "Sender Name" msgstr "Nombre del Remitente" #: 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/signal_api.py:131 apprise/plugins/smseagle.py:158 #, fuzzy msgid "Target Group ID" msgstr "ID del Grupo de Destino" #: 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:194 #, fuzzy msgid "OAuth Access Token" msgstr "Token de Acceso OAuth" #: apprise/plugins/slack.py:228 msgid "Target Encoded ID" msgstr "ID Codificado de Destino" #: apprise/plugins/slack.py:268 #, fuzzy msgid "Include Footer" msgstr "Incluir Pie de Pagina" #: apprise/plugins/slack.py:276 msgid "Use Blocks" msgstr "Usar Bloques" #: apprise/plugins/slack.py:282 #, fuzzy msgid "Include Timestamp" msgstr "Incluir Marca de Tiempo" #: apprise/plugins/slack.py:288 apprise/plugins/twitter.py:179 #, 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/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/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:116 msgid "Upload" msgstr "Subir" #: apprise/plugins/syslog.py:167 msgid "Log to STDERR" msgstr "Registrar en STDERR" #: apprise/plugins/telegram.py:353 msgid "Target Chat ID" msgstr "ID del Chat de Destino" #: apprise/plugins/telegram.py:376 msgid "Detect Bot Owner" msgstr "Detectar Propietario del Bot" #: apprise/plugins/telegram.py:382 msgid "Silent Notification" msgstr "Notificacion Silenciosa" #: apprise/plugins/telegram.py:387 msgid "Web Page Preview" msgstr "Vista Previa de Pagina Web" #: apprise/plugins/telegram.py:392 msgid "Topic Thread ID" msgstr "ID de Hilo del Tema" #: apprise/plugins/telegram.py:399 #, fuzzy msgid "Markdown Version" msgstr "Version de Markdown" #: apprise/plugins/telegram.py:408 msgid "Content Placement" msgstr "Ubicacion del Contenido" #: 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/twilio.py:200 msgid "Notification Method: sms or call" msgstr "Metodo de Notificacion: sms o llamada" #: apprise/plugins/twitter.py:138 msgid "Consumer Key" msgstr "Clave del Consumidor" #: apprise/plugins/twitter.py:144 msgid "Consumer Secret" msgstr "Secreto del Consumidor" #: apprise/plugins/twitter.py:156 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/wecombot.py:99 #, fuzzy msgid "Bot Webhook Key" msgstr "Clave Webhook del Bot" #: apprise/plugins/whatsapp.py:106 msgid "Template Name" msgstr "Nombre de Plantilla" #: apprise/plugins/whatsapp.py:112 #, 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:138 #, fuzzy msgid "Workflow ID" msgstr "ID del Flujo de Trabajo" #: apprise/plugins/workflows.py:146 msgid "Signature" msgstr "Firma" #: apprise/plugins/workflows.py:169 msgid "Use Power Automate URL" msgstr "Usar URL de Power Automate" #: apprise/plugins/workflows.py:176 msgid "Wrap Text" msgstr "Ajustar Texto" #: apprise/plugins/workflows.py:191 #, 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/zulip.py:148 #, fuzzy msgid "Target Stream" msgstr "Stream de Destino" #: apprise/plugins/email/base.py:159 msgid "SMTP Server" msgstr "Servidor SMTP" #: apprise/plugins/email/base.py:164 apprise/plugins/xmpp/base.py:144 msgid "Secure Mode" msgstr "Modo Seguro" #: apprise/plugins/email/base.py:176 msgid "PGP Encryption" msgstr "Cifrado PGP" #: apprise/plugins/email/base.py:182 msgid "PGP Public Key Path" msgstr "Ruta de Clave Publica PGP" #: apprise/plugins/email/base.py:190 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:301 msgid "Target Room Alias" msgstr "Alias de Sala de Destino" #: apprise/plugins/matrix/base.py:324 #, fuzzy msgid "Server Discovery" msgstr "Descubrimiento de Servidor" #: apprise/plugins/matrix/base.py:329 msgid "Force Home Server on Room IDs" msgstr "Forzar Servidor Home en IDs de Sala" #: apprise/plugins/matrix/base.py:340 #, fuzzy msgid "Webhook Path" msgstr "Webhook" #: apprise/plugins/matrix/base.py:346 msgid "Matrix API Verion" msgstr "Version de API de Matrix" #: apprise/plugins/matrix/base.py:358 #, 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:139 #, fuzzy msgid "XMPP Server" msgstr "Servidor SMTP" #: apprise/plugins/xmpp/base.py:151 #, fuzzy msgid "Get Roster" msgstr "Obtener Lista de Contactos" #: apprise/plugins/xmpp/base.py:156 msgid "Use Subject" msgstr "Usar Asunto" #: apprise/plugins/xmpp/base.py:161 #, fuzzy msgid "Keep Connection Alive" msgstr "Mantener Conexion Activa" #: apprise/plugins/xmpp/base.py:167 #, fuzzy msgid "MUC Nickname" msgstr "Apodo MUC" apprise-1.10.0/apprise/i18n/pt_BR/000077500000000000000000000000001517341665700165035ustar00rootroot00000000000000apprise-1.10.0/apprise/i18n/pt_BR/LC_MESSAGES/000077500000000000000000000000001517341665700202705ustar00rootroot00000000000000apprise-1.10.0/apprise/i18n/pt_BR/LC_MESSAGES/apprise.po000066400000000000000000001534521517341665700223050ustar00rootroot00000000000000# 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.9.9\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-04-26 10:09-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:141 msgid "Verify SSL" msgstr "Verificar SSL" #: apprise/url.py:151 #, fuzzy msgid "Socket Read Timeout" msgstr "Tempo Limite de Leitura do Socket" #: apprise/url.py:165 #, 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:46 apprise/config/http.py:54 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:105 #: apprise/plugins/exotel.py:213 apprise/plugins/fcm/__init__.py:143 #: 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/mailgun.py:146 #: apprise/plugins/messagebird.py:78 apprise/plugins/octopush.py:136 #: apprise/plugins/one_signal.py:113 apprise/plugins/opsgenie.py:228 #: apprise/plugins/pagerduty.py:131 apprise/plugins/popcorn_notify.py:71 #: 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 "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: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/email/base.py:139 apprise/plugins/evolution.py:134 #: 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/irc/base.py:149 apprise/plugins/join.py:172 #: apprise/plugins/kavenegar.py:135 apprise/plugins/line.py:93 #: 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:175 #: 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/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/popcorn_notify.py:89 apprise/plugins/postmark.py:133 #: apprise/plugins/pushbullet.py:105 apprise/plugins/pushed.py:107 #: apprise/plugins/pushover.py:215 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/rocketchat.py:167 apprise/plugins/sendgrid.py:133 #: apprise/plugins/sendpulse.py:139 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:251 #: apprise/plugins/smpp.py:128 apprise/plugins/smseagle.py:172 #: apprise/plugins/smsmanager.py:119 apprise/plugins/sns.py:138 #: apprise/plugins/telegram.py:359 apprise/plugins/threema.py:115 #: apprise/plugins/twilio.py:174 apprise/plugins/twist.py:123 #: apprise/plugins/twitter.py:168 apprise/plugins/vapid/__init__.py:158 #: apprise/plugins/voipms.py:108 apprise/plugins/vonage.py:108 #: apprise/plugins/whatsapp.py:132 apprise/plugins/wxpusher.py:139 #: apprise/plugins/xmpp/base.py:129 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: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/popcorn_notify.py:104 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:193 #, fuzzy msgid "Batch Mode" msgstr "Modo em Lote" #: 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:111 #: apprise/plugins/fluxer.py:159 apprise/plugins/gotify.py:129 #: apprise/plugins/growl.py:140 apprise/plugins/home_assistant.py:122 #: 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:147 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74 #: apprise/plugins/notica.py:126 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/synology.py:83 #: apprise/plugins/workflows.py:126 apprise/plugins/xmpp/base.py:96 msgid "Hostname" msgstr "Nome do Host" #: 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:128 #: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:115 #: apprise/plugins/evolution.py:116 apprise/plugins/fluxer.py:163 #: apprise/plugins/gotify.py:140 apprise/plugins/growl.py:145 #: apprise/plugins/home_assistant.py:127 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:152 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130 #: 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/synology.py:88 #: apprise/plugins/workflows.py:131 apprise/plugins/xmpp/base.py:101 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/ntfy.py:221 #: apprise/plugins/opsgenie.py:234 apprise/plugins/pushjet.py:88 #: apprise/plugins/rocketchat.py:131 apprise/plugins/signal_api.py:108 #: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:94 msgid "Username" msgstr "Nome de Usuario" #: 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:118 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/irc/base.py:132 apprise/plugins/kodi.py:111 #: apprise/plugins/matrix/base.py:277 apprise/plugins/mqtt.py:163 #: apprise/plugins/nextcloud.py:131 apprise/plugins/nextcloudtalk.py:90 #: apprise/plugins/notica.py:140 apprise/plugins/ntfy.py:225 #: apprise/plugins/pushjet.py:92 apprise/plugins/reddit.py:145 #: apprise/plugins/rocketchat.py:135 apprise/plugins/signal_api.py:112 #: apprise/plugins/simplepush.py:108 apprise/plugins/smpp.py:97 #: apprise/plugins/synology.py:98 apprise/plugins/twist.py:101 #: apprise/plugins/voipms.py:88 apprise/plugins/xmpp/base.py:112 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/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:294 #: 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:288 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: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 "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:114 #: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:158 #: 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:154 apprise/plugins/fcm/__init__.py:157 #: apprise/plugins/home_assistant.py:148 apprise/plugins/pushbullet.py:89 #: apprise/plugins/pushover.py:203 apprise/plugins/pushsafer.py:366 #: apprise/plugins/pushy.py:85 msgid "Target Device" msgstr "Dispositivo de Destino" #: apprise/plugins/bark.py:171 apprise/plugins/lametric.py:516 #: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:232 #: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110 msgid "Sound" msgstr "Som" #: apprise/plugins/bark.py:176 msgid "Level" msgstr "Nivel" #: apprise/plugins/bark.py:181 msgid "Volume" msgstr "Volume" #: apprise/plugins/bark.py:187 apprise/plugins/ntfy.py:270 #: apprise/plugins/pagerduty.py:172 msgid "Click" msgstr "Clique" #: apprise/plugins/bark.py:191 apprise/plugins/pushy.py:114 msgid "Badge" msgstr "Emblema" #: 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 "Grupo" #: apprise/plugins/bark.py:204 apprise/plugins/dbus.py:228 #: apprise/plugins/discord.py:196 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/msteams.py:200 #: apprise/plugins/notifiarr.py:125 apprise/plugins/ntfy.py:256 #: apprise/plugins/one_signal.py:164 apprise/plugins/pagerduty.py:191 #: apprise/plugins/ryver.py:124 apprise/plugins/slack.py:262 #: apprise/plugins/telegram.py:370 apprise/plugins/vapid/__init__.py:204 #: apprise/plugins/windows.py:106 apprise/plugins/workflows.py:163 msgid "Include Image" msgstr "Incluir Imagem" #: apprise/plugins/bark.py:210 apprise/plugins/mattermost.py:208 #: apprise/plugins/revolt.py:129 msgid "Icon URL" msgstr "URL do Icone" #: apprise/plugins/bark.py:214 apprise/plugins/streamlabs.py:119 msgid "Call" msgstr "Chamada" #: apprise/plugins/base.py:242 msgid "Overflow Mode" msgstr "Modo de Estouro" #: apprise/plugins/base.py:257 msgid "Notify Format" msgstr "Formato de Notificacao" #: apprise/plugins/base.py:267 #, fuzzy msgid "Interpret Emojis" msgstr "Interpretar Emojis" #: apprise/plugins/base.py:277 msgid "Persistent Storage" msgstr "Armazenamento Persistente" #: apprise/plugins/base.py:287 #, fuzzy msgid "Timezone" msgstr "Fuso Horario" #: 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/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:134 #: apprise/plugins/mailgun.py:152 apprise/plugins/notificationapi.py:177 #: apprise/plugins/office365.py:282 apprise/plugins/one_signal.py:124 #: apprise/plugins/popcorn_notify.py:84 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:234 #: 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:193 #, fuzzy msgid "Reply To Email" msgstr "Responder para E-mail" #: apprise/plugins/brevo.py:193 apprise/plugins/email/base.py:195 #: 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:215 #: 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:199 #: 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:219 #: 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/evolution.py:127 #: 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/octopush.py:146 #: apprise/plugins/plivo.py:107 apprise/plugins/popcorn_notify.py:77 #: apprise/plugins/seven.py:81 apprise/plugins/signal_api.py:124 #: apprise/plugins/sinch.py:127 apprise/plugins/smpp.py:121 #: apprise/plugins/smseagle.py:151 apprise/plugins/smsmanager.py:112 #: apprise/plugins/sns.py:125 apprise/plugins/threema.py:98 #: apprise/plugins/twilio.py:161 apprise/plugins/voipms.py:101 #: apprise/plugins/vonage.py:101 apprise/plugins/whatsapp.py:125 msgid "Target Phone No" msgstr "Numero de Telefone de Destino" #: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142 #: apprise/plugins/pushover.py:209 #, 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/fortysixelks.py:100 apprise/plugins/httpsms.py:83 #: apprise/plugins/httpsms.py:115 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 #, 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/clickatell.py:45 msgid "Clickatell" msgstr "Clickatell" #: apprise/plugins/clickatell.py:72 apprise/plugins/rocketchat.py:140 #, fuzzy msgid "API Token" msgstr "Token de API" #: apprise/plugins/custom_form.py:157 apprise/plugins/custom_json.py:139 #: apprise/plugins/custom_xml.py:139 msgid "Fetch Method" msgstr "Metodo de Busca" #: apprise/plugins/custom_form.py:163 msgid "Attach File As" msgstr "Anexar Arquivo Como" #: 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 "Extras do Payload" #: apprise/plugins/custom_form.py:182 apprise/plugins/custom_json.py:158 #: apprise/plugins/custom_xml.py:158 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:282 #: apprise/plugins/opsgenie.py:280 apprise/plugins/prowl.py:141 #: apprise/plugins/pushover.py:226 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 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:125 apprise/plugins/flock.py:106 #: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106 #: apprise/plugins/slack.py:186 apprise/plugins/viber.py:99 #: apprise/plugins/zulip.py:124 msgid "Bot Name" msgstr "Nome do Bot" #: apprise/plugins/discord.py:130 apprise/plugins/fluxer.py:174 #: apprise/plugins/ifttt.py:103 msgid "Webhook ID" msgstr "ID do Webhook" #: apprise/plugins/discord.py:136 apprise/plugins/fluxer.py:181 #: apprise/plugins/google_chat.py:118 apprise/plugins/webexteams.py:171 msgid "Webhook Token" msgstr "Token do Webhook" #: apprise/plugins/discord.py:149 apprise/plugins/fluxer.py:201 msgid "Text To Speech" msgstr "Texto para Fala" #: apprise/plugins/discord.py:154 apprise/plugins/fluxer.py:206 msgid "Avatar Image" msgstr "Imagem de Avatar" #: apprise/plugins/discord.py:159 apprise/plugins/fluxer.py:211 #: apprise/plugins/ntfy.py:262 #, fuzzy msgid "Avatar URL" msgstr "URL do Avatar" #: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:215 #: apprise/plugins/pushover.py:238 msgid "URL" msgstr "URL" #: apprise/plugins/discord.py:172 apprise/plugins/fluxer.py:222 msgid "Thread ID" msgstr "ID da Thread" #: apprise/plugins/discord.py:176 apprise/plugins/fluxer.py:230 msgid "Display Footer" msgstr "Exibir Rodape" #: apprise/plugins/discord.py:181 apprise/plugins/fluxer.py:235 msgid "Footer Logo" msgstr "Logo do Rodape" #: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:240 #, fuzzy msgid "Use Fields" msgstr "Usar Campos" #: apprise/plugins/discord.py:191 apprise/plugins/fluxer.py:245 msgid "Discord Flags" msgstr "Flags do Discord" #: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:256 msgid "Ping Users/Roles" msgstr "Mencionar Usuarios/Cargos" #: 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:130 apprise/plugins/gotify.py:134 #: apprise/plugins/mattermost.py:167 apprise/plugins/notica.py:145 msgid "Path" msgstr "Caminho" #: apprise/plugins/enigma2.py:140 msgid "Server Timeout" msgstr "Tempo Limite do Servidor" #: apprise/plugins/evolution.py:122 #, 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/notificationapi.py:212 #: apprise/plugins/opsgenie.py:273 apprise/plugins/pagerduty.py:176 #: 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:150 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/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188 #: apprise/plugins/fluxer.py:195 apprise/plugins/lametric.py:510 #: apprise/plugins/mattermost.py:224 apprise/plugins/notificationapi.py:218 #: apprise/plugins/ntfy.py:296 apprise/plugins/office365.py:319 #: apprise/plugins/vapid/__init__.py:169 apprise/plugins/webexteams.py:197 #, 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:106 msgid "Workspace" msgstr "Espaco de Trabalho" #: apprise/plugins/google_chat.py:112 #, fuzzy msgid "Webhook Key" msgstr "Chave do Webhook" #: apprise/plugins/google_chat.py:124 #, fuzzy msgid "Thread Key" msgstr "Chave da Thread" #: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:193 #: apprise/plugins/msteams.py:206 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Versao" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "Fixado" #: apprise/plugins/home_assistant.py:142 #, fuzzy msgid "Long-Lived Access Token" msgstr "Token de Acesso de Longa Duracao" #: apprise/plugins/home_assistant.py:165 msgid "Notification ID" msgstr "ID de Notificacao" #: apprise/plugins/home_assistant.py:182 #, fuzzy msgid "Path Prefix" msgstr "Prefixo" #: 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: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:239 apprise/plugins/twitter.py:162 #: apprise/plugins/xmpp/base.py:118 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/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:344 msgid "Bot Token" msgstr "Token do Bot" #: 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:197 #: apprise/plugins/smseagle.py:146 apprise/plugins/spugpush.py:68 #: apprise/plugins/streamlabs.py:105 apprise/plugins/whatsapp.py:99 msgid "Access Token" msgstr "Token de Acesso" #: 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:123 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:154 apprise/plugins/mailgun.py:168 #: apprise/plugins/postmark.py:144 apprise/plugins/resend.py:140 #: apprise/plugins/ses.py:198 apprise/plugins/smtp2go.py:135 #: apprise/plugins/sparkpost.py:186 msgid "From Name" msgstr "Nome de Origem" #: apprise/plugins/email/base.py:208 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/mailgun.py:208 apprise/plugins/msteams.py:222 #: apprise/plugins/notificationapi.py:256 apprise/plugins/sparkpost.py:223 #: apprise/plugins/workflows.py:202 #, fuzzy msgid "Template Tokens" msgstr "Tokens do Template" #: 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:185 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:151 #: apprise/plugins/xmpp/base.py:107 #, fuzzy msgid "User" msgstr "Usuario" #: 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:245 apprise/plugins/twist.py:112 #: apprise/plugins/xmpp/base.py:123 msgid "Target Channel" msgstr "Canal de Destino" #: apprise/plugins/mattermost.py:183 apprise/plugins/twist.py:118 #, fuzzy msgid "Target Channel ID" msgstr "ID do Canal de Destino" #: apprise/plugins/mqtt.py:169 msgid "Target Queue" msgstr "" #: apprise/plugins/mqtt.py:186 msgid "QOS" msgstr "QoS" #: apprise/plugins/mqtt.py:199 apprise/plugins/notificationapi.py:166 #: apprise/plugins/office365.py:269 apprise/plugins/sendpulse.py:120 #, fuzzy msgid "Client ID" msgstr "ID do Cliente" #: apprise/plugins/mqtt.py:203 msgid "Use Session" msgstr "Usar Sessao" #: apprise/plugins/mqtt.py:208 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:169 msgid "Template Mapping" msgstr "Mapeamento de Template" #: apprise/plugins/msteams.py:151 #, fuzzy msgid "Team Name" msgstr "Nome da Equipe" #: apprise/plugins/msteams.py:159 apprise/plugins/slack.py:203 msgid "Token A" msgstr "Token A" #: apprise/plugins/msteams.py:168 apprise/plugins/slack.py:212 msgid "Token B" msgstr "Token B" #: apprise/plugins/msteams.py:177 apprise/plugins/slack.py:221 msgid "Token C" msgstr "Token C" #: apprise/plugins/msteams.py:186 #, fuzzy msgid "Token D" msgstr "Token D" #: apprise/plugins/msteams.py:212 apprise/plugins/workflows.py:181 msgid "Template Path" msgstr "Caminho do 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/matrix/base.py:352 apprise/plugins/notificationapi.py:159 msgid "Message Type" msgstr "Tipo de Mensagem" #: apprise/plugins/notificationapi.py:171 apprise/plugins/office365.py:276 #: apprise/plugins/sendpulse.py:127 #, fuzzy msgid "Client Secret" msgstr "Segredo do Cliente" #: apprise/plugins/notificationapi.py:182 #, fuzzy msgid "Target ID" msgstr "ID de Destino" #: apprise/plugins/notificationapi.py:187 #, fuzzy msgid "Target SMS" msgstr "SMS de Destino" #: apprise/plugins/notificationapi.py:207 msgid "Channels" msgstr "Canais" #: apprise/plugins/email/base.py:171 apprise/plugins/notificationapi.py:223 #: apprise/plugins/office365.py:315 apprise/plugins/resend.py:150 msgid "Reply To" msgstr "Responder Para" #: apprise/plugins/email/base.py:149 apprise/plugins/notificationapi.py:228 #: apprise/plugins/sendpulse.py:150 apprise/plugins/ses.py:154 msgid "From Email" msgstr "E-mail de Origem" #: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:124 #, fuzzy msgid "Project ID" msgstr "ID do Projeto" #: apprise/plugins/notifico.py:133 msgid "Message Hook" msgstr "Hook de Mensagem" #: apprise/plugins/notifico.py:148 msgid "IRC Colors" msgstr "Cores IRC" #: apprise/plugins/notifico.py:154 msgid "Prefix" msgstr "Prefixo" #: apprise/plugins/ntfy.py:235 msgid "Topic" msgstr "Topico" #: apprise/plugins/ntfy.py:252 msgid "Attach" msgstr "Anexar" #: apprise/plugins/ntfy.py:266 msgid "Attach Filename" msgstr "Nome do Arquivo Anexado" #: apprise/plugins/ntfy.py:274 msgid "Delay" msgstr "Atraso" #: apprise/plugins/ntfy.py:278 apprise/plugins/twist.py:107 #, fuzzy msgid "Email" msgstr "E-mail" #: apprise/plugins/ntfy.py:292 #, fuzzy msgid "Actions" msgstr "Acoes" #: apprise/plugins/ntfy.py:305 #, 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/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:119 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/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/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:191 msgid "User Key" msgstr "Chave do Usuario" #: apprise/plugins/pushover.py:243 msgid "URL Title" msgstr "Titulo da URL" #: apprise/plugins/pushover.py:248 msgid "Retry" msgstr "Tentar Novamente" #: apprise/plugins/pushover.py:254 msgid "Expire" msgstr "Expirar" #: apprise/plugins/pushplus.py:173 msgid "Pushplus" msgstr "Pushplus" #: apprise/plugins/pushplus.py:211 apprise/plugins/qq.py:66 #, fuzzy msgid "User Token" msgstr "Token do Usuario" #: apprise/plugins/pushplus.py:220 msgid "Group Topics" msgstr "" #: apprise/plugins/pushplus.py:241 #, fuzzy msgid "Channel" msgstr "Canais" #: apprise/plugins/pushplus.py:258 #, 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/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:131 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/rocketchat.py:146 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/matrix/base.py:295 apprise/plugins/rocketchat.py:162 msgid "Target Room ID" msgstr "ID da Sala de Destino" #: apprise/plugins/matrix/base.py:334 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/ses.py:160 apprise/plugins/sns.py:106 msgid "Access Key ID" msgstr "ID da Chave de Acesso" #: apprise/plugins/ses.py:166 apprise/plugins/sns.py:112 msgid "Secret Access Key" msgstr "Chave de Acesso Secreta" #: apprise/plugins/ses.py:172 apprise/plugins/sinch.py:157 #: apprise/plugins/sns.py:118 msgid "Region" msgstr "Regiao" #: apprise/plugins/ses.py:179 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:131 #, fuzzy msgid "Sender Name" msgstr "Nome do Remetente" #: 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/signal_api.py:131 apprise/plugins/smseagle.py:158 #, fuzzy msgid "Target Group ID" msgstr "ID do Grupo de Destino" #: 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:194 #, fuzzy msgid "OAuth Access Token" msgstr "Token de Acesso OAuth" #: apprise/plugins/slack.py:228 msgid "Target Encoded ID" msgstr "ID Codificado de Destino" #: apprise/plugins/slack.py:268 #, fuzzy msgid "Include Footer" msgstr "Incluir Rodape" #: apprise/plugins/slack.py:276 msgid "Use Blocks" msgstr "Usar Blocos" #: apprise/plugins/slack.py:282 #, fuzzy msgid "Include Timestamp" msgstr "Incluir Timestamp" #: apprise/plugins/slack.py:288 apprise/plugins/twitter.py:179 #, 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/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/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:116 msgid "Upload" msgstr "Enviar" #: apprise/plugins/syslog.py:167 msgid "Log to STDERR" msgstr "Registrar no STDERR" #: apprise/plugins/telegram.py:353 msgid "Target Chat ID" msgstr "ID do Chat de Destino" #: apprise/plugins/telegram.py:376 msgid "Detect Bot Owner" msgstr "Detectar Proprietario do Bot" #: apprise/plugins/telegram.py:382 msgid "Silent Notification" msgstr "Notificacao Silenciosa" #: apprise/plugins/telegram.py:387 msgid "Web Page Preview" msgstr "Pre-visualizacao de Pagina Web" #: apprise/plugins/telegram.py:392 msgid "Topic Thread ID" msgstr "ID de Thread do Topico" #: apprise/plugins/telegram.py:399 #, fuzzy msgid "Markdown Version" msgstr "Versao do Markdown" #: apprise/plugins/telegram.py:408 msgid "Content Placement" msgstr "Posicionamento de Conteudo" #: 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/twilio.py:200 msgid "Notification Method: sms or call" msgstr "Metodo de Notificacao: sms ou chamada" #: apprise/plugins/twitter.py:138 msgid "Consumer Key" msgstr "Chave do Consumidor" #: apprise/plugins/twitter.py:144 msgid "Consumer Secret" msgstr "Segredo do Consumidor" #: apprise/plugins/twitter.py:156 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/wecombot.py:99 #, fuzzy msgid "Bot Webhook Key" msgstr "Chave Webhook do Bot" #: apprise/plugins/whatsapp.py:106 msgid "Template Name" msgstr "Nome do Template" #: apprise/plugins/whatsapp.py:112 #, 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:138 #, fuzzy msgid "Workflow ID" msgstr "ID do Fluxo de Trabalho" #: apprise/plugins/workflows.py:146 msgid "Signature" msgstr "Assinatura" #: apprise/plugins/workflows.py:169 msgid "Use Power Automate URL" msgstr "Usar URL do Power Automate" #: apprise/plugins/workflows.py:176 msgid "Wrap Text" msgstr "Quebrar Texto" #: apprise/plugins/workflows.py:191 #, 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/zulip.py:148 #, fuzzy msgid "Target Stream" msgstr "Stream de Destino" #: apprise/plugins/email/base.py:159 msgid "SMTP Server" msgstr "Servidor SMTP" #: apprise/plugins/email/base.py:164 apprise/plugins/xmpp/base.py:144 msgid "Secure Mode" msgstr "Modo Seguro" #: apprise/plugins/email/base.py:176 msgid "PGP Encryption" msgstr "Criptografia PGP" #: apprise/plugins/email/base.py:182 msgid "PGP Public Key Path" msgstr "Caminho da Chave Publica PGP" #: apprise/plugins/email/base.py:190 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:301 msgid "Target Room Alias" msgstr "Alias da Sala de Destino" #: apprise/plugins/matrix/base.py:324 #, fuzzy msgid "Server Discovery" msgstr "Descoberta de Servidor" #: apprise/plugins/matrix/base.py:329 msgid "Force Home Server on Room IDs" msgstr "Forcar Servidor Home nos IDs de Sala" #: apprise/plugins/matrix/base.py:340 #, fuzzy msgid "Webhook Path" msgstr "Webhook" #: apprise/plugins/matrix/base.py:346 msgid "Matrix API Verion" msgstr "Versao da API Matrix" #: apprise/plugins/matrix/base.py:358 #, 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:139 #, fuzzy msgid "XMPP Server" msgstr "Servidor SMTP" #: apprise/plugins/xmpp/base.py:151 #, fuzzy msgid "Get Roster" msgstr "Obter Lista de Contatos" #: apprise/plugins/xmpp/base.py:156 msgid "Use Subject" msgstr "Usar Assunto" #: apprise/plugins/xmpp/base.py:161 #, fuzzy msgid "Keep Connection Alive" msgstr "Manter Conexao Ativa" #: apprise/plugins/xmpp/base.py:167 #, fuzzy msgid "MUC Nickname" msgstr "Apelido MUC" apprise-1.10.0/apprise/locale.py000066400000000000000000000214271517341665700165350ustar00rootroot00000000000000# 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] apprise-1.10.0/apprise/logger.py000066400000000000000000000150731517341665700165550ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/manager.py000066400000000000000000001040571517341665700167110ustar00rootroot00000000000000# 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) apprise-1.10.0/apprise/manager_attachment.py000066400000000000000000000041261517341665700211150ustar00rootroot00000000000000# 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]+)$" ) apprise-1.10.0/apprise/manager_config.py000066400000000000000000000041331517341665700202300ustar00rootroot00000000000000# 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)[A-Za-z0-9]+)$" ) apprise-1.10.0/apprise/manager_plugins.py000066400000000000000000000041311517341665700204420ustar00rootroot00000000000000# 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|ImageSize|Type)[A-Za-z0-9]+)$" ) apprise-1.10.0/apprise/persistent_store.py000066400000000000000000001670521517341665700207170ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/plugins/000077500000000000000000000000001517341665700163775ustar00rootroot00000000000000apprise-1.10.0/apprise/plugins/__init__.py000066400000000000000000000443461517341665700205230ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/plugins/africas_talking.py000066400000000000000000000405051517341665700220760ustar00rootroot00000000000000# 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, ) # 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 apprise-1.10.0/apprise/plugins/apprise_api.py000066400000000000000000000437001517341665700212510ustar00rootroot00000000000000# 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 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="/")) 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, ) 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 apprise-1.10.0/apprise/plugins/aprs.py000066400000000000000000000610141517341665700177200ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/plugins/bark.py000066400000000000000000000450721517341665700177000ustar00rootroot00000000000000# 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 json import requests 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, ) class NotifyBark(NotifyBase): """A wrapper for Notify Bark Notifications.""" # The default descriptive name associated with the Notification service_name = "Bark" # 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", }, "targets": { "name": _("Targets"), "type": "list:string", "required": 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, }, "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, **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) # 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() payload["device_key"] = target self.logger.debug( "Bark POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Bark Payload: {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(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) 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( 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 Bark notification to {target}.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Bark " f"notification to {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 if self.secure else self.protocol, self.user, self.password, self.host, self.port, ) 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" # 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([NotifyBark.quote(f"{x}") 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)) return results apprise-1.10.0/apprise/plugins/base.py000066400000000000000000001145331517341665700176720ustar00rootroot00000000000000# 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 re from typing import Any, ClassVar, Optional, TypedDict, Union from zoneinfo import ZoneInfo from ..apprise_attachment import AppriseAttachment from ..common import ( 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 # 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", }, }, ) # # 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 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) # 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 == NotifyFormat.TEXT ): # Content is appended to body as markdown title = title.lstrip("\r\n \t\v\f#-") if title: body = f"# {title}\r\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 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" 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. 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"] 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 apprise-1.10.0/apprise/plugins/blink1.py000066400000000000000000000272111517341665700201340ustar00rootroot00000000000000# 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",) apprise-1.10.0/apprise/plugins/bluesky.py000066400000000000000000000555211517341665700204370ustar00rootroot00000000000000# 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, ) # 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 = {} # 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 apprise-1.10.0/apprise/plugins/brevo.py000066400000000000000000000464711517341665700201020ustar00rootroot00000000000000# 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, ) 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 apprise-1.10.0/apprise/plugins/bulksms.py000066400000000000000000000413071517341665700204360ustar00rootroot00000000000000# 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, ) # 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 apprise-1.10.0/apprise/plugins/bulkvs.py000066400000000000000000000331671517341665700202710ustar00rootroot00000000000000# 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, ) # 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 apprise-1.10.0/apprise/plugins/burstsms.py000066400000000000000000000374511517341665700206450ustar00rootroot00000000000000# 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, ) 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 apprise-1.10.0/apprise/plugins/chanify.py000066400000000000000000000153701517341665700204000ustar00rootroot00000000000000# 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, ) 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 apprise-1.10.0/apprise/plugins/clickatell.py000066400000000000000000000242761517341665700210730ustar00rootroot00000000000000# 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, ) 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 apprise-1.10.0/apprise/plugins/clicksend.py000066400000000000000000000274031517341665700207160ustar00rootroot00000000000000# 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, ) 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 apprise-1.10.0/apprise/plugins/custom_form.py000066400000000000000000000452101517341665700213100ustar00rootroot00000000000000# 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 .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, ) 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="/") 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 apprise-1.10.0/apprise/plugins/custom_json.py000066400000000000000000000346771517341665700213350ustar00rootroot00000000000000# 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.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, ) 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="/") 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 apprise-1.10.0/apprise/plugins/custom_xml.py000066400000000000000000000433471517341665700211560ustar00rootroot00000000000000# 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.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, ) 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="/") 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 apprise-1.10.0/apprise/plugins/d7networks.py000066400000000000000000000363531517341665700210720ustar00rootroot00000000000000# 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, ) 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. 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 apprise-1.10.0/apprise/plugins/dapnet.py000066400000000000000000000330631517341665700202310ustar00rootroot00000000000000# 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, ) 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 apprise-1.10.0/apprise/plugins/dbus.py000066400000000000000000000342421517341665700177130ustar00rootroot00000000000000# 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 if self.x_axis: params["x"] = str(self.x_axis) # y in (x,y) screen coordinates if self.y_axis: 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 apprise-1.10.0/apprise/plugins/dingtalk.py000066400000000000000000000301101517341665700205410ustar00rootroot00000000000000# 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, ) 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 apprise-1.10.0/apprise/plugins/discord.py000066400000000000000000000750341517341665700204110ustar00rootroot00000000000000# 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 import re from typing import Any import requests 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 .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 # 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", }, }, ) 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, **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) self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1.0 self.ratelimit_remaining = 1.0 return 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 # 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 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) # # Send our attachments # for attachment in attach: self.logger.info( f"Posting Discord Attachment {attachment.name}" ) if not self._send(payload, params=params, attach=attachment): # We failed to post our message return False # Otherwise return return True def _send( self, payload: dict[str, Any], attach: 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) # Perform some simple error checking if isinstance(attach, AttachBase): if not attach: # We could not access the attachment self.logger.error( f"Could not access attachment {attach.url(privacy=True)}." ) return False self.logger.debug( f"Posting Discord attachment {attach.url(privacy=True)}" ) # Our attachment path (if specified) files = None try: # Open our attachment path if required: if attach: files = { "file": ( attach.name, # file handle is safely closed in `finally`; inline # open is intentional; attach.open() dispatches to # BytesIO for memory attachments attach.open(), ) } else: headers["Content-Type"] = "application/json; charset=utf-8" r = requests.post( notify_url, params=params, data=payload if files else dumps(payload), headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, ) # 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 {}to Discord notification: " "{}{}error={}.".format( attach.name if attach else "", 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 Discord {}.".format( "attachment" if attach else "notification" ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred posting {}to Discord.".format( attach.name if attach else "" ) ) self.logger.debug(f"Socket Exception: {e!s}") return False except OSError as e: self.logger.warning( "An I/O error occurred while reading {}.".format( attach.name if attach else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: # Close our file (if it's open) stored in the second element # of our files tuple (index 1) if files: files["file"][1].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", } 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) # 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"]) 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 apprise-1.10.0/apprise/plugins/dot.py000066400000000000000000000502261517341665700175440ustar00rootroot00000000000000# 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, ) 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 apprise-1.10.0/apprise/plugins/email/000077500000000000000000000000001517341665700174665ustar00rootroot00000000000000apprise-1.10.0/apprise/plugins/email/__init__.py000066400000000000000000000035751517341665700216110ustar00rootroot00000000000000# 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", ] apprise-1.10.0/apprise/plugins/email/base.py000066400000000000000000001213621517341665700207570ustar00rootroot00000000000000# 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 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 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 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 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}/{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 Encryption"), "type": "bool", "map_to": "use_pgp", "default": False, }, "pgpkey": { "name": _("PGP Public Key Path"), "type": "string", "private": True, # By default persistent storage is referenced "default": "", "map_to": "pgp_key", }, "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, use_pgp=None, pgp_key=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 # Prepare our Pretty Good Privacy Object self.pgp = _pgp.ApprisePGPController( path=self.store.path, pub_keyfile=pgp_key, email=self.from_addr[1], asset=self.asset, ) # We store so we can generate a URL later on self.pgp_key = pgp_key self.use_pgp = ( use_pgp if not None else self.template_args["pgp"]["default"] ) if self.use_pgp and not _pgp.PGP_SUPPORT: self.logger.warning( "PGP Support is not available on this installation; " "ask admin to install PGPy" ) 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() try: self.logger.debug("Connecting to remote SMTP server...") socket_func = smtplib.SMTP if self.secure_mode == SecureMailMode.SSL: self.logger.debug("Securing connection with SSL...") socket_func = smtplib.SMTP_SSL socket = socket_func( self.smtp_host, self.port, None, timeout=self.socket_connect_timeout, ) if self.secure_mode == SecureMailMode.STARTTLS: # Handle Secure Connections self.logger.debug("Securing connection with STARTTLS...") socket.starttls() 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 if self.use_pgp else None, 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: socket.quit() # 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 = { "pgp": "yes" if self.use_pgp else "no", } # Store our public key back into your URL if self.pgp_key is not None: params["pgp_key"] = NotifyEmail.quote(self.pgp_key, safe=":\\/") # 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), ) @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 Flag results["use_pgp"] = parse_bool( results["qsd"].get( "pgp", NotifyEmail.template_args["pgp"]["default"] ) ) # Get PGP Public Key Override if "pgpkey" in results["qsd"] and results["qsd"]["pgpkey"]: results["pgp_key"] = NotifyEmail.unquote(results["qsd"]["pgpkey"]) # 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, # 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: Encrypting the message using Pretty Good Privacy support This requires that the pgp_path provided exists and keys can be referenced here to perform the encryption with. If a key isn't found, one will be generated. pgp support requires the 'PGPy' Python library to be available. Pass in an ApprisePGPController() if you wish to use this """ 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 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_))) # 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: mixed = MIMEMultipart("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 open(attachment.path, "rb") 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" ) app.add_header( "Content-Disposition", 'attachment; filename="{}"'.format( Header(filename, "utf-8") ), ) mixed.attach(app) base = mixed if pgp: 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 msg = "Unable to encrypt email via PGP" 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",) apprise-1.10.0/apprise/plugins/email/common.py000066400000000000000000000047371517341665700213430ustar00rootroot00000000000000# 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 apprise-1.10.0/apprise/plugins/email/templates.py000066400000000000000000000241541517341665700220440ustar00rootroot00000000000000# 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

test

", # "reblog":null, # "application":{ # "name":"Apprise Notifications", # "website":"https://github.com/caronc/apprise" # }, # "account":{ # "id":"109310334138718878", # "username":"caronc", # "acct":"caronc", # "display_name":"Chris", # "locked":false, # "bot":false, # "discoverable":false, # "group":false, # "created_at":"2022-11-08T00:00:00.000Z", # "note":"content", # "url":"https://host/@caronc", # "avatar":"https://host/path/file.png", # "avatar_static":"https://host/path/file.png", # "header":"https://host/headers/original/missing.png", # "header_static":"https://host/path/missing.png", # "followers_count":0, # "following_count":0, # "statuses_count":15, # "last_status_at":"2022-11-09", # "emojis":[ # # ], # "fields":[ # # ] # }, # "media_attachments":[ # { # "id":"109315796405707501", # "type":"image", # "url":"https://host/path/file.jpeg", # "preview_url":"https://host/path/file.jpeg", # "remote_url":null, # "preview_remote_url":null, # "text_url":null, # "meta":{ # "original":{ # "width":640, # "height":640, # "size":"640x640", # "aspect":1.0 # }, # "small":{ # "width":400, # "height":400, # "size":"400x400", # "aspect":1.0 # } # }, # "description":null, # "blurhash":"UmIsdJnT^mX4V@XQofnQ~Ebq%4o3ofnQjZbt" # } # ], # "mentions":[ # # ], # "tags":[ # # ], # "emojis":[ # # ], # "card":null, # "poll":null # } try: url = "{}/web/@{}".format( self.api_url, response["account"]["username"] ) except (KeyError, TypeError): url = "unknown" self.logger.debug( "Mastodon [%.2d/%.2d] (%d attached) delivered to %s", no, len(payloads), len(payload.get("media_ids", [])), url, ) self.logger.info( "Sent [%.2d/%.2d] Mastodon notification as public toot.", no, len(payloads), ) return not has_error def ping_tokens(self, *args, normalize=False, seen=None): """ Takes one or more strings and returns Mastodon-recognizable mention and hashtag tokens detected within. """ results = [] seen = seen if seen is not None else set() for arg in args: if normalize: for token in parse_list(arg): normalized = self.normalize_ping_token(token) if not normalized: continue key = normalized.casefold() if key not in seen: seen.add(key) results.append(normalized) continue for token in MENTION_DETECTION_RE.findall(arg): key = token.casefold() if key not in seen: seen.add(key) results.append(token) for token in HASHTAG_DETECTION_RE.findall(arg): if not self.valid_hashtag(token): continue key = token.casefold() if key not in seen: seen.add(key) results.append(token) return results @staticmethod def ping_payload(tokens): """Return a status suffix from one or more ping tokens.""" return (" " + " ".join(tokens)) if tokens else "" @staticmethod def normalize_ping_token(token): """Normalize a configured ping token into a mention or hashtag.""" token = token.strip() if not token: return None match = IS_USER.match(token) if match and token.startswith("@") and match.group("user"): return "@" + match.group("user") if token.startswith("#"): return token if NotifyMastodon.valid_hashtag(token) else None return None @staticmethod def valid_hashtag(token): """Return True if a token is a valid Mastodon hashtag.""" value = token[1:] if token.startswith("#") else token if not HASHTAG_VALUE_RE.match(value): return False return not value.replace("_", "").isdecimal() def _whoami(self, lazy=True): """Looks details of current authenticated user.""" if lazy and self._whoami_cache is not None: # Use cached response return self._whoami_cache # Send Mastodon Whoami request postokay, response = self._request( self.mastodon_whoami, method="GET", ) if postokay: # Sample Response: # { # 'id': '12345', # 'username': 'caronc', # 'acct': 'caronc', # 'display_name': 'Chris', # 'locked': False, # 'bot': False, # 'discoverable': False, # 'group': False, # 'created_at': '2022-11-08T00:00:00.000Z', # 'note': 'details', # 'url': 'https://noc.social/@caronc', # 'avatar': 'https://host/path/image.png', # 'avatar_static': 'https://host/path/image.png', # 'header': 'https://host/path/missing.png', # 'header_static': 'https://host/path/missing.png', # 'followers_count': 0, # 'following_count': 0, # 'statuses_count': 2, # 'last_status_at': '2022-11-09', # 'source': { # 'privacy': 'public', # 'sensitive': False, # 'language': None, # 'note': 'details', # 'fields': [], # 'follow_requests_count': 0 # }, # 'emojis': [], # 'fields': [] # } with contextlib.suppress(TypeError, KeyError): # Cache our response for future references self._whoami_cache = {response["username"]: response["id"]} elif response and "authorized scopes" in response.get("error", ""): self.logger.warning( "Failed to lookup Mastodon Auth details; " "missing scope: read:accounts" ) return self._whoami_cache if postokay else {} def _request(self, path, payload=None, method="POST"): """Wrapper to Mastodon API requests object.""" headers = { "User-Agent": self.app_id, "Authorization": f"Bearer {self.token}", } data = None files = None # Prepare our message url = f"{self.api_url}{path}" # Some Debug Logging self.logger.debug( f"Mastodon {method} URL:" f" {url} (cert_verify={self.verify_certificate})" ) # Open our attachment path if required: if isinstance(payload, AttachBase): # prepare payload files = { "file": ( payload.name, # file handle is safely closed in `finally`; inline open # is intentional open(payload.path, "rb"), # noqa: SIM115 "application/octet-stream", ) } # Provide a description data = { "description": payload.name, } else: headers["Content-Type"] = "application/json" data = dumps(payload) self.logger.debug(f"Mastodon Payload: {payload!s}") # Default content response object content = {} # 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 # Mastodon 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.5 seconds to the end just to allow a grace # period. wait = (self.ratelimit_reset - now).total_seconds() + 0.5 # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) # acquire our request mode fn = requests.post if method == "POST" else requests.get try: r = fn( url, data=data, files=files, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) try: content = loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} if r.status_code not in ( requests.codes.ok, requests.codes.created, requests.codes.accepted, ): # We had a problem status_str = NotifyMastodon.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Mastodon {} 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) try: # Capture rate limiting if possible self.ratelimit_remaining = int( r.headers.get("X-RateLimit-Remaining") ) self.ratelimit_reset = datetime.fromtimestamp( int(r.headers.get("X-RateLimit-Limit")), 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 except requests.RequestException as e: self.logger.warning( f"Exception received when sending Mastodon {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) finally: # Close our file (if it's open) stored in the second element # of our files tuple (index 1) if files: files["file"][1].close() return (True, content) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" if isinstance(url, str): base, sep, query = url.partition("?") url = base.replace("/#", "/%23") + sep + query results = NotifyBase.parse_url(url) 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"] = NotifyMastodon.unquote(results["qsd"]["token"]) elif not results["password"] and results["user"]: results["token"] = NotifyMastodon.unquote(results["user"]) # Apply our targets results["targets"] = NotifyMastodon.split_path(results["fullpath"]) # The defined Mastodon visibility if "visibility" in results["qsd"] and len( results["qsd"]["visibility"] ): # Simplified version results["visibility"] = NotifyMastodon.unquote( results["qsd"]["visibility"] ) elif results["schema"].startswith("toot"): results["visibility"] = MastodonMessageVisibility.PUBLIC # Get Idempotency Key (if specified) if "key" in results["qsd"] and len(results["qsd"]["key"]): results["key"] = NotifyMastodon.unquote(results["qsd"]["key"]) # Get Spoiler Text if "spoiler" in results["qsd"] and len(results["qsd"]["spoiler"]): results["spoiler"] = NotifyMastodon.unquote( results["qsd"]["spoiler"] ) # Get Language (if specified) if "language" in results["qsd"] and len(results["qsd"]["language"]): results["language"] = NotifyMastodon.unquote( results["qsd"]["language"] ) # Extract ping targets, comma/space separated if "ping" in results["qsd"]: results["ping"] = NotifyMastodon.unquote(results["qsd"]["ping"]) # Get Sensitive Flag (for Attachments) results["sensitive"] = parse_bool( results["qsd"].get( "sensitive", NotifyMastodon.template_args["sensitive"]["default"], ) ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyMastodon.template_args["batch"]["default"] ) ) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyMastodon.parse_list( results["qsd"]["to"] ) return results apprise-1.10.0/apprise/plugins/matrix/000077500000000000000000000000001517341665700177035ustar00rootroot00000000000000apprise-1.10.0/apprise/plugins/matrix/__init__.py000066400000000000000000000030661517341665700220210ustar00rootroot00000000000000# 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. """Matrix Notifications.""" from .base import IS_ROOM_ID, MatrixDiscoveryException, NotifyMatrix __all__ = [ "IS_ROOM_ID", "MatrixDiscoveryException", "NotifyMatrix", ] apprise-1.10.0/apprise/plugins/matrix/base.py000066400000000000000000003563751517341665700212120ustar00rootroot00000000000000# 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 sources # - https://github.com/matrix-org/matrix-python-sdk # - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst # # End-to-End Encryption references: # - https://spec.matrix.org/v1.11/client-server-api/ # #end-to-end-encryption # - https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/olm.md # - https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/megolm.md # import contextlib from json import dumps, loads import re from time import time import uuid from markdown import markdown import requests from ...common import ( NotifyFormat, NotifyImageSize, NotifyType, PersistentStoreMode, ) from ...exception import AppriseException from ...locale import gettext_lazy as _ from ...url import PrivacyMode from ...utils.parse import ( is_hostname, parse_bool, parse_list, validate_regex, ) from ..base import NotifyBase from .e2ee import ( MATRIX_E2EE_SUPPORT, MatrixMegOlmSession, MatrixOlmAccount, encrypt_attachment, verify_device_keys, verify_signed_otk, ) # Define default path MATRIX_V1_WEBHOOK_PATH = "/api/v1/matrix/hook" MATRIX_V2_API_PATH = "/_matrix/client/r0" MATRIX_V3_API_PATH = "/_matrix/client/v3" MATRIX_V3_MEDIA_PATH = "/_matrix/media/v3" MATRIX_V2_MEDIA_PATH = "/_matrix/media/r0" class MatrixDiscoveryException(AppriseException): """Apprise Matrix Exception Class.""" # Extend HTTP Error Messages MATRIX_HTTP_ERROR_MAP = { 403: "Unauthorized - Invalid Token.", 429: "Rate limit imposed; wait 2s and try again", } # Matrix Room Syntax IS_ROOM_ALIAS = re.compile( r"^\s*(#|%23)?(?P[A-Za-z0-9._=-]+)((:|%3A)" r"(?P[A-Za-z0-9.-]+))?\s*$", re.I, ) # Room ID MUST start with an exclamation to avoid ambiguity IS_ROOM_ID = re.compile( r"^\s*(!|!|%21)(?P[A-Za-z0-9._=-]+)((:|%3A)" r"(?P[A-Za-z0-9.-]+))?\s*$", re.I, ) # Matrix User ID (for DM targets); must start with @ IS_USER = re.compile( r"^\s*(@|%40)(?P[A-Za-z0-9._=+/-]+)((:|%3A)" r"(?P[A-Za-z0-9.-]+))?\s*$", re.I, ) # Matrix is_image check IS_IMAGE = re.compile(r"^image/.*", re.I) class MatrixMessageType: """The Matrix Message types.""" TEXT = "text" NOTICE = "notice" # matrix message types are placed into this list for validation purposes MATRIX_MESSAGE_TYPES = ( MatrixMessageType.TEXT, MatrixMessageType.NOTICE, ) class MatrixVersion: # Version 2 V2 = "2" # Version 3 V3 = "3" # webhook modes are placed into this list for validation purposes MATRIX_VERSIONS = ( MatrixVersion.V2, MatrixVersion.V3, ) class MatrixWebhookMode: # Webhook Mode is disabled DISABLED = "off" # The default webhook mode is to just be set to Matrix MATRIX = "matrix" # Support the slack webhook plugin SLACK = "slack" # Support the t2bot webhook plugin T2BOT = "t2bot" # Support matrix-hookshot generic webhooks HOOKSHOT = "hookshot" # webhook modes are placed into this list for validation purposes MATRIX_WEBHOOK_MODES = ( MatrixWebhookMode.DISABLED, MatrixWebhookMode.MATRIX, MatrixWebhookMode.SLACK, MatrixWebhookMode.T2BOT, MatrixWebhookMode.HOOKSHOT, ) class NotifyMatrix(NotifyBase): """A wrapper for Matrix Notifications.""" # The default descriptive name associated with the Notification service_name = "Matrix" # The services URL service_url = "https://matrix.org/" # The default protocol protocol = "matrix" # The default secure protocol secure_protocol = "matrixs" # Support Attachments attachment_support = True # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/matrix/" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_32 # The maximum allowable characters allowed in the body per message # https://spec.matrix.org/v1.6/client-server-api/#size-limits # The complete event MUST NOT be larger than 65536 bytes, when formatted # with the federation event format, including any signatures, and encoded # as Canonical JSON. # # To gracefully allow for some overhead' we'll define a max body length # of just slighty lower then the limit of the full message itself. body_maxlen = 65000 # Throttle a wee-bit to avoid thrashing request_rate_per_sec = 0.5 # How many retry attempts we'll make in the event the server asks us to # throttle back. default_retries = 2 # The number of micro seconds to wait if we get a 429 error code and # the server doesn't remind us how long we should wait for default_wait_ms = 1000 # Our default is to no not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.AUTO # Keep our cache for 20 days default_cache_expiry_sec = 60 * 60 * 24 * 20 # Number of signed_curve25519 one-time keys to generate and upload # per batch (both on initial device registration and replenishment). default_e2ee_otk_count = 10 # Replenish the server-side OTK pool when the estimated remaining # count drops below this value. /keys/claim consumes one OTK per # device; without replenishment the pool runs dry and subsequent # key-shares skip devices that have no OTK available. default_e2ee_otk_replenish_threshold = 5 # Used for server discovery discovery_base_key = "__discovery_base" discovery_identity_key = "__discovery_identity" # Defines how long we cache our discovery for discovery_cache_length_sec = 86400 # Define object templates templates = ( # Targets are ignored when using t2bot/hookshot mode; only a token is # required "{schema}://{token}", "{schema}://{user}@{token}", # Matrix Server "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{targets}", "{schema}://{token}@{host}/{targets}", "{schema}://{token}@{host}:{port}/{targets}", # Webhook mode "{schema}://{user}:{token}@{host}/{targets}", "{schema}://{user}:{token}@{host}:{port}/{targets}", ) # Define our template tokens 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": _("Access Token"), "type": "string", "private": True, "map_to": "password", "required": True, }, "target_user": { "name": _("Target User"), "type": "string", "prefix": "@", "map_to": "targets", }, "target_room_id": { "name": _("Target Room ID"), "type": "string", "prefix": "!", "map_to": "targets", }, "target_room_alias": { "name": _("Target Room Alias"), "type": "string", "prefix": "#", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "image": { "name": _("Include Image"), "type": "bool", "default": False, "map_to": "include_image", }, "discovery": { "name": _("Server Discovery"), "type": "bool", "default": True, }, "hsreq": { "name": _("Force Home Server on Room IDs"), "type": "bool", "default": True, }, "mode": { "name": _("Webhook Mode"), "type": "choice:string", "values": MATRIX_WEBHOOK_MODES, "default": MatrixWebhookMode.DISABLED, }, "path": { "name": _("Webhook Path"), "type": "string", "map_to": "webhook_path", "default": "/webhook", }, "version": { "name": _("Matrix API Verion"), "type": "choice:string", "values": MATRIX_VERSIONS, "default": MatrixVersion.V3, }, "msgtype": { "name": _("Message Type"), "type": "choice:string", "values": MATRIX_MESSAGE_TYPES, "default": MatrixMessageType.TEXT, }, "e2ee": { "name": _("End-to-End Encryption"), "type": "bool", "default": True, }, "token": { "alias_of": "token", }, "to": { "alias_of": "targets", }, }, ) def __init__( self, targets=None, mode=None, msgtype=None, version=None, include_image=None, discovery=None, hsreq=None, webhook_path=None, e2ee=None, **kwargs, ): """Initialize Matrix Object.""" super().__init__(**kwargs) # Prepare a list of rooms to connect and notify; separate # @user DM targets from room identifiers. self.rooms = [] self.users = [] for _target in parse_list(targets): if IS_USER.match(_target): self.users.append(_target) else: self.rooms.append(_target) # our home server gets populated after a login/registration self.home_server = None # our user_id gets populated after a login/registration self.user_id = None # This gets initialized after a login/registration self.access_token = None # Our device ID assigned by the Matrix server during login self.device_id = None # This gets incremented for each request made against the v3 API self.transaction_id = 0 # Lazy-initialized E2EE account (MatrixOlmAccount or None) self._e2ee_account = None # Place an image inline with the message body self.include_image = ( self.template_args["image"]["default"] if include_image is None else include_image ) # Prepare Delegate Server Lookup Check self.discovery = ( self.template_args["discovery"]["default"] if discovery is None else discovery ) # When enabled, room IDs missing a ':homeserver' segment will # be treated as legacy identifiers and automatically suffixed # with the authenticated homeserver. self.hsreq = ( self.template_args["hsreq"]["default"] if hsreq is None else hsreq ) # Public webhook path used by matrix-hookshot self.webhook_path = ( self.template_args["path"]["default"] if not isinstance(webhook_path, str) or not webhook_path.strip() else webhook_path.strip() ) if not self.webhook_path.startswith("/"): self.webhook_path = f"/{self.webhook_path}" self.webhook_path = self.webhook_path.rstrip("/") or "/" # End-to-end encryption (server mode only; requires cryptography) self.e2ee = ( self.template_args["e2ee"]["default"] if e2ee is None else parse_bool(e2ee) ) # Setup our mode self.mode = ( self.template_args["mode"]["default"] if not isinstance(mode, str) else mode.lower() ) if self.mode and self.mode not in MATRIX_WEBHOOK_MODES: msg = f"The mode specified ({mode}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Setup our version self.version = ( self.template_args["version"]["default"] if not isinstance(version, str) else version ) if self.version not in MATRIX_VERSIONS: msg = f"The version specified ({version}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Setup our message type self.msgtype = ( self.template_args["msgtype"]["default"] if not isinstance(msgtype, str) else msgtype.lower() ) if self.msgtype and self.msgtype not in MATRIX_MESSAGE_TYPES: msg = f"The msgtype specified ({msgtype}) is invalid." self.logger.warning(msg) raise TypeError(msg) if self.mode == MatrixWebhookMode.T2BOT: # t2bot configuration requires that a webhook id is specified self.access_token = validate_regex( self.password, r"^[a-z0-9]{64}$", "i" ) if not self.access_token: msg = ( "An invalid T2Bot/Matrix Webhook ID " f"({self.password}) was specified." ) self.logger.warning(msg) raise TypeError(msg) elif not is_hostname(self.host): msg = f"An invalid Matrix Hostname ({self.host}) was specified" self.logger.warning(msg) raise TypeError(msg) else: # Verify port if specified if self.port is not None and not ( isinstance(self.port, int) and self.port >= self.template_tokens["port"]["min"] and self.port <= self.template_tokens["port"]["max"] ): msg = f"An invalid Matrix Port ({self.port}) was specified" self.logger.warning(msg) raise TypeError(msg) if self.mode != MatrixWebhookMode.DISABLED: # Discovery only works when we're not using webhooks self.discovery = False # # Initialize from cache if present # if self.mode != MatrixWebhookMode.T2BOT: # our home server gets populated after a login/registration self.home_server = self.store.get("home_server") # our user_id gets populated after a login/registration self.user_id = self.store.get("user_id") # This gets initialized after a login/registration self.access_token = self.store.get("access_token") # Device ID assigned by server self.device_id = self.store.get("device_id") # Older cache entries may have user_id/access_token persisted # without home_server. Recover it from @user:homeserver so room # aliases do not degrade into '#room:None'. if not self.home_server and self.user_id: parts = self.user_id.split(":", 1) if len(parts) == 2: self.home_server = parts[1] # This gets incremented for each request made against the v3 API self.transaction_id = ( 0 if not self.access_token else self.store.get("transaction_id", 0) ) # Restore E2EE account from store if available if self.e2ee and MATRIX_E2EE_SUPPORT: acct_data = self.store.get("e2ee_account") if acct_data: with contextlib.suppress(Exception): self._e2ee_account = MatrixOlmAccount.from_dict(acct_data) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Matrix Notification.""" # Call the _send_ function applicable to whatever mode we're in # - calls _send_webhook_notification if the mode variable is set # - calls _send_server_notification if the mode variable is not set return getattr( self, "_send_{}_notification".format( "webhook" if self.mode != MatrixWebhookMode.DISABLED else "server" ), )(body=body, title=title, notify_type=notify_type, **kwargs) def _send_webhook_notification( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Perform Matrix Notification as a webhook.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } if self.mode == MatrixWebhookMode.T2BOT: # # t2bot Setup # # Prepare our URL url = ( "https://webhooks.t2bot.io/api/v1/matrix/hook/" f"{self.access_token}" ) elif self.mode == MatrixWebhookMode.HOOKSHOT: # Acquire our access token from our URL access_token = self.password if self.password else self.user # Prepare our public hookshot URL url = "{schema}://{hostname}{port}{webhook_path}/{token}".format( schema="https" if self.secure else "http", hostname=self.host, port=("" if not self.port else f":{self.port}"), webhook_path=self.webhook_path.rstrip("/"), token=access_token, ) else: # Acquire our access token from our URL access_token = self.password if self.password else self.user # Prepare our URL url = "{schema}://{hostname}{port}{webhook_path}/{token}".format( schema="https" if self.secure else "http", hostname=self.host, port=("" if not self.port else f":{self.port}"), webhook_path=MATRIX_V1_WEBHOOK_PATH, token=access_token, ) # Retrieve our payload payload = getattr(self, f"_{self.mode}_webhook_payload")( body=body, title=title, notify_type=notify_type, **kwargs ) self.logger.debug( "Matrix POST URL: {} (cert_verify={!r})".format( url, self.verify_certificate ) ) self.logger.debug(f"Matrix Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyMatrix.http_response_code_lookup( r.status_code, MATRIX_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Matrix 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 Matrix notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Matrix notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True def _slack_webhook_payload( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Format the payload for a Slack based message.""" if not hasattr(self, "_re_slack_formatting_rules"): # Prepare some one-time slack formatting variables self._re_slack_formatting_map = { # New lines must become the string version r"\r\*\n": "\\n", # Escape other special characters r"&": "&", r"<": "<", r">": ">", } # Iterate over above list and store content accordingly self._re_slack_formatting_rules = re.compile( r"(" + "|".join(self._re_slack_formatting_map.keys()) + r")", re.IGNORECASE, ) # Perform Formatting title = self._re_slack_formatting_rules.sub( # pragma: no branch lambda x: self._re_slack_formatting_map[x.group()], title, ) body = self._re_slack_formatting_rules.sub( # pragma: no branch lambda x: self._re_slack_formatting_map[x.group()], body, ) # prepare JSON Object payload = { "username": self.user if self.user else self.app_id, # Use Markdown language "mrkdwn": self.notify_format == NotifyFormat.MARKDOWN, "attachments": [ { "title": title, "text": body, "color": self.color(notify_type), "ts": time(), "footer": self.app_id, } ], } return payload def _matrix_webhook_payload( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Format the payload for a Matrix based message.""" payload = { "displayName": self.user if self.user else self.app_id, "format": ( "plain" if self.notify_format == NotifyFormat.TEXT else "html" ), "text": "", } if self.notify_format == NotifyFormat.HTML: payload["text"] = "{title}{body}".format( title=( "" if not title else f"

{NotifyMatrix.escape_html(title)}

" ), body=body, ) elif self.notify_format == NotifyFormat.MARKDOWN: payload["text"] = "{title}{body}".format( title=( "" if not title else f"

{NotifyMatrix.escape_html(title)}

" ), body=markdown(body), ) else: # NotifyFormat.TEXT payload["text"] = body if not title else f"{title}\r\n{body}" return payload def _t2bot_webhook_payload( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Format the payload for a T2Bot Matrix based messages.""" # Retrieve our payload payload = self._matrix_webhook_payload( body=body, title=title, notify_type=notify_type, **kwargs ) # Acquire our image url if we're configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) if image_url: # t2bot can take an avatarUrl Entry payload["avatarUrl"] = image_url return payload def _hookshot_webhook_payload( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Format the payload for a matrix-hookshot webhook.""" payload = { "username": self.user if self.user else self.app_id, "text": "", } if self.notify_format == NotifyFormat.HTML: payload["text"] = body if not title else f"{title}\r\n{body}" payload["html"] = "{title}{body}".format( title=( "" if not title else f"

{NotifyMatrix.escape_html(title)}

" ), body=body, ) elif self.notify_format == NotifyFormat.MARKDOWN: payload["text"] = body if not title else f"{title}\r\n{body}" payload["html"] = "{title}{body}".format( title=( "" if not title else f"

{NotifyMatrix.escape_html(title)}

" ), body=markdown(body), ) else: # NotifyFormat.TEXT payload["text"] = body if not title else f"{title}\r\n{body}" payload["html"] = NotifyMatrix.escape_html( payload["text"], convert_new_lines=True, whitespace=False ) return payload def _send_server_notification( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Direct Matrix Server Notification (no webhook)""" if self.access_token is None and self.password and not self.user: self.access_token = self.password self.transaction_id = uuid.uuid4() if ( self.access_token is None and not self._login() and not self._register() ): # We need to register return False # Resolve user_id (and device_id / home_server as a side-effect) via # /whoami whenever user_id is still absent after login/token setup. # This covers all paths where the server does not return user_id: # - raw access-token auth (no /login flow at all) # - username + ?token= (password treated as token, not a login) # - servers that omit optional /login response fields # Without user_id the m.direct lookup is skipped and # each send creates a fresh orphan DM room instead of reusing the # existing one. home_server is recovered from user_id inside # _whoami(); the fallback at handles any remaining gap. if not self.user_id: self._whoami() # Last-resort fallback: if home_server is still unknown, assume # it matches the Matrix host we are connecting to. if not self.home_server: self.home_server = self.host if len(self.rooms) == 0 and not self.users: # Attempt to retrieve a list of already joined channels self.rooms = self._joined_rooms() if len(self.rooms) == 0: # Nothing to notify self.logger.warning( "There were no Matrix rooms specified to notify." ) return False # Create a copy of our rooms to join and message rooms = list(self.rooms) # Initialize our error tracking has_error = False # Resolve DM user targets (@user) to room IDs for _user in self.users: dm_room_id = self._dm_room_find_or_create(_user) if dm_room_id: rooms.append(dm_room_id) else: self.logger.warning( "Could not find or create a DM room for Matrix user %s.", _user, ) has_error = True # E2EE setup (once per send call, not per room). # e2ee_capable means prerequisites are met; encryption is still # decided per-room based on whether that room requires E2EE. e2ee_capable = False if self.e2ee and self.secure and MATRIX_E2EE_SUPPORT: e2ee_capable = self._e2ee_setup() if not e2ee_capable: self.logger.warning( "Matrix E2EE setup failed; " "messages will be sent unencrypted." ) # Plaintext attachment payloads for unencrypted rooms. # Lazy-initialized on the first unencrypted room so that purely # E2EE setups never upload attachments in plaintext. attachments = None attachments_ready = False while len(rooms) > 0: # Get our room room = rooms.pop(0) # Get our room_id from our response room_id = self._room_join(room) if not room_id: # Notify our user about our failure self.logger.warning(f"Could not join Matrix room {room}.") # Mark our failure has_error = True continue if e2ee_capable and self._e2ee_room_encrypted(room_id): # E2EE path: encrypt message and any attachments if not self._e2ee_send_to_room( room_id, body, title, notify_type ): has_error = True continue if attach and self.attachment_support: session = self._e2ee_get_megolm(room_id) for attachment in attach: if not attachment: has_error = True break if not self._e2ee_send_attachment( attachment, room_id, session ): has_error = True continue # --- Unencrypted path (existing behaviour) --- # Upload attachments once; reuse content_uris for every # subsequent unencrypted room in this send call. if attach and self.attachment_support and not attachments_ready: attachments = self._send_attachments(attach) attachments_ready = True if attachments is False: return False # Acquire our image url if we're configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) # Always use PUT with a transaction ID # (spec-compliant since 2015) path = "/rooms/{}/send/m.room.message/{}".format( NotifyMatrix.quote(room_id), self.transaction_id ) if image_url and self.version == MatrixVersion.V2: # Define our payload image_payload = { "msgtype": "m.image", "url": image_url, "body": f"{title if title else notify_type}", } # Post our content postokay, _, _ = self._fetch( path, payload=image_payload, method="PUT" ) if not postokay: # Mark our failure has_error = True continue # Increment transaction ID so subsequent sends # don't reuse the same path if self.access_token != self.password: self.transaction_id += 1 self.store.set( "transaction_id", self.transaction_id, expires=self.default_cache_expiry_sec, ) path = "/rooms/{}/send/m.room.message/{}".format( NotifyMatrix.quote(room_id), self.transaction_id ) if attachments: for attachment in attachments: attachment["room_id"] = room_id attachment["type"] = "m.room.message" postokay, _, _ = self._fetch( path, payload=attachment, method="PUT" ) # Increment the transaction ID to avoid future messages # being recognized as retransmissions and ignored if self.access_token != self.password: self.transaction_id += 1 self.store.set( "transaction_id", self.transaction_id, expires=self.default_cache_expiry_sec, ) path = "/rooms/{}/send/m.room.message/{}".format( NotifyMatrix.quote(room_id), self.transaction_id, ) if not postokay: # Mark our failure has_error = True continue # Define our payload payload = { "msgtype": f"m.{self.msgtype}", "body": "{title}{body}".format( title="" if not title else f"# {title}\r\n", body=body, ), } # Update our payload advance formatting for the services that # support them. if self.notify_format == NotifyFormat.HTML: payload.update( { "format": "org.matrix.custom.html", "formatted_body": "{title}{body}".format( title=("" if not title else f"

{title}

"), body=body, ), } ) elif self.notify_format == NotifyFormat.MARKDOWN: title_ = ( "" if not title else ( "

{}".format( NotifyMatrix.escape_html(title, whitespace=False) ) + "

" ) ) payload.update( { "format": "org.matrix.custom.html", "formatted_body": "{title}{body}".format( title=title_, body=markdown(body), ), } ) # Post our content postokay, _, _ = self._fetch(path, payload=payload, method="PUT") # Increment the transaction ID to avoid future messages being # recognized as retransmissions and ignored if self.access_token != self.password: self.transaction_id += 1 self.store.set( "transaction_id", self.transaction_id, expires=self.default_cache_expiry_sec, ) if not postokay: # Notify our user self.logger.warning( f"Could not send notification Matrix room {room}." ) # Mark our failure has_error = True continue return not has_error def _send_attachments(self, attach): """Posts all of the provided attachments.""" payloads = [] for attachment in attach: if not attachment: # invalid attachment (bad file) return False if ( not IS_IMAGE.match(attachment.mimetype) and self.version == MatrixVersion.V2 ): # unsuppored at this time continue postokay, response, _ = self._fetch( "/upload", attachment=attachment ) if not (postokay and isinstance(response, dict)): # Failed to perform upload return False # If we get here, we'll have a response that looks like: # { # "content_uri": "mxc://example.com/a-unique-key" # } if self.version == MatrixVersion.V3: # Prepare our payload is_image = IS_IMAGE.match(attachment.mimetype) payloads.append( { "body": attachment.name, "info": { "mimetype": attachment.mimetype, "size": len(attachment), }, "msgtype": "m.image" if is_image else "m.file", "url": response.get("content_uri"), } ) if not is_image: # Setup `m.file' payloads[-1]["filename"] = attachment.name else: # Prepare our payload payloads.append( { "info": { "mimetype": attachment.mimetype, }, "msgtype": "m.image", "body": "tta.webp", "url": response.get("content_uri"), } ) return payloads def _register(self): """Register with the service if possible.""" # Prepare our Registration Payload. This will only work if # registration is enabled for the public payload = { "kind": "user", "auth": {"type": "m.login.dummy"}, } # parameters params = { "kind": "user", } # If a user is not specified, one will be randomly generated for # you. If you do not specify a password, you will be unable to # login to the account if you forget the access_token. if self.user: payload["username"] = self.user if self.password: payload["password"] = self.password # Reuse a previously assigned device ID when available so Matrix # keeps this notifier on a stable device identity across runs. if self.device_id: payload["device_id"] = self.device_id else: payload["initial_device_display_name"] = self.app_id # Register postokay, response, _ = self._fetch( "/register", payload=payload, params=params ) if not (postokay and isinstance(response, dict)): # Failed to register return False # Pull the response details self.access_token = response.get("access_token") self.user_id = response.get("user_id") self.device_id = response.get("device_id") # home_server may be absent in modern Matrix responses; derive # from user_id when the server does not include it explicitly. hs_from_response = response.get("home_server") if hs_from_response: self.home_server = hs_from_response elif self.user_id and not self.home_server: parts = self.user_id.split(":", 1) if len(parts) == 2: self.home_server = parts[1] self.store.set( "access_token", self.access_token, expires=self.default_cache_expiry_sec, ) if self.home_server: self.store.set( "home_server", self.home_server, expires=self.default_cache_expiry_sec, ) self.store.set( "user_id", self.user_id, expires=self.default_cache_expiry_sec ) if self.device_id: self.store.set( "device_id", self.device_id, expires=self.default_cache_expiry_sec, ) if self.access_token is not None: # Store our token into our store self.logger.debug("Registered successfully with Matrix server.") return True return False def _login(self): """Acquires the matrix token required for making future requests. If we fail we return False, otherwise we return True """ if self.access_token: # Login not required; silently skip-over return True if self.user and self.password: # Prepare our Authentication Payload if self.version == MatrixVersion.V3: payload = { "type": "m.login.password", "identifier": { "type": "m.id.user", "user": self.user, }, "password": self.password, } else: payload = { "type": "m.login.password", "user": self.user, "password": self.password, } # Reuse our last-known device ID when possible to avoid # creating a brand-new Matrix device on every login. if self.device_id: payload["device_id"] = self.device_id else: payload["initial_device_display_name"] = self.app_id else: # It's not possible to register since we need these 2 values # to make the action possible. self.logger.warning( "Failed to login to Matrix server: " "token or user/pass combo is missing." ) return False # Build our URL postokay, response, _ = self._fetch("/login", payload=payload) if not (postokay and isinstance(response, dict)): # Failed to login return False # Pull the response details self.access_token = response.get("access_token") self.user_id = response.get("user_id") self.device_id = response.get("device_id") # home_server was dropped from login responses in recent Matrix # spec versions. Only update if the server still returns it; # otherwise derive it from user_id so room-alias resolution works. hs_from_response = response.get("home_server") if hs_from_response: self.home_server = hs_from_response elif self.user_id and not self.home_server: parts = self.user_id.split(":", 1) if len(parts) == 2: self.home_server = parts[1] if not self.access_token: return False self.logger.debug("Authenticated successfully with Matrix server.") # Store our token into our store self.store.set( "access_token", self.access_token, expires=self.default_cache_expiry_sec, ) if self.home_server: self.store.set( "home_server", self.home_server, expires=self.default_cache_expiry_sec, ) self.store.set( "user_id", self.user_id, expires=self.default_cache_expiry_sec ) if self.device_id: self.store.set( "device_id", self.device_id, expires=self.default_cache_expiry_sec, ) return True def _whoami(self): """Resolve user_id, device_id, and home_server via GET /account/whoami. Called when a raw access token is supplied (no login flow), so the server never returned these identifiers directly. Results are cached in the persistent store for future calls. Returns True on success, False otherwise. """ ok, response, _ = self._fetch( "/account/whoami", payload=None, method="GET" ) if not (ok and isinstance(response, dict)): return False self.user_id = response.get("user_id") or self.user_id self.device_id = response.get("device_id") or self.device_id # Extract home_server from user_id (@localpart:homeserver) so that # DM targets without an explicit homeserver resolve correctly. if self.user_id and not self.home_server: parts = self.user_id.split(":", 1) if len(parts) == 2: self.home_server = parts[1] if self.user_id: self.store.set( "user_id", self.user_id, expires=self.default_cache_expiry_sec, ) if self.device_id: self.store.set( "device_id", self.device_id, expires=self.default_cache_expiry_sec, ) if self.home_server: self.store.set( "home_server", self.home_server, expires=self.default_cache_expiry_sec, ) return True def _logout(self): """Relinquishes token from remote server.""" if not self.access_token: # Login not required; silently skip-over return True # Prepare our Registration Payload payload = {} # Expire our token postokay, response, _ = self._fetch("/logout", payload=payload) if not postokay and response.get("errcode") != "M_UNKNOWN_TOKEN": # If we get here, the token was declared as having already # been expired. The response looks like this: # { # u'errcode': u'M_UNKNOWN_TOKEN', # u'error': u'Access Token unknown or expired', # } # # In this case it's okay to safely return True because # we're logged out in this case. return False # else: The response object looks like this if we were successful: # {} # Pull the response details self.access_token = None self.home_server = None self.user_id = None self.device_id = None self._e2ee_account = None # clear our tokens (including E2EE upload flag so it re-uploads # after a fresh login) self.store.clear( "access_token", "home_server", "user_id", "transaction_id", "device_id", "e2ee_keys_uploaded", ) self.logger.debug("Unauthenticated successfully with Matrix server.") return True def _room_join(self, room): """Joins a matrix room if we're not already in it. Otherwise it attempts to create it if it doesn't exist and always returns the room_id if it was successful, otherwise it returns None """ if not self.access_token: # We can't join a room if we're not logged in return None if not isinstance(room, str): # Not a supported string return None # Prepare our Join Payload payload = {} # Check if it's a room id... result = IS_ROOM_ID.match(room) if result: room_token = result.group("room") explicit_home_server = result.group("home_server") # Determine the homeserver context (used for cache metadata) home_server = ( explicit_home_server if explicit_home_server else self.home_server ) # When hsreq is enabled (legacy behaviour), we always require # a ':homeserver' segment on room IDs. Otherwise, we honour # exactly what the caller provided and do not synthesise a # homeserver when it was not specified. cache_key = f"!{room_token}:{home_server}" if explicit_home_server or self.hsreq: room_id = cache_key else: room_id = f"!{room_token}" # Check our cache for speed: try: return self.store[cache_key]["id"] except KeyError: pass # Build our URL path = f"/join/{NotifyMatrix.quote(room_id)}" # Attempt to join the channel postokay, response, _status_code = self._fetch( path, payload=payload ) if not postokay: return None # Prefer the server-provided room_id if one was returned, # otherwise fall back to whatever we joined with. joined_id = ( response.get("room_id") if isinstance(response, dict) else None ) or room_id # Cache mapping for faster future lookups. self.store.set( cache_key, { "id": joined_id, "home_server": home_server, }, ) return joined_id # Try to see if it's an alias then... result = IS_ROOM_ALIAS.match(room) if not result: # There is nothing else it could be self.logger.warning( f"Ignoring illegally formed room {room} " "from Matrix server list." ) return None # If we reach here, we're dealing with a channel alias home_server = ( self.home_server if not result.group("home_server") else result.group("home_server") ) if not home_server and self.user_id: parts = self.user_id.split(":", 1) if len(parts) == 2: home_server = parts[1] if not home_server: self.logger.warning( "Could not resolve a homeserver for Matrix room alias %s.", room, ) return None # tidy our room (alias) identifier room = "#{}:{}".format(result.group("room"), home_server) # Check our cache for speed: try: # We're done as we've already joined the channel return self.store[room]["id"] except KeyError: # No worries, we'll try to acquire the info pass # If we reach here, we need to join the channel # Build our URL path = f"/join/{NotifyMatrix.quote(room)}" # Attempt to join the channel postokay, response, status_code = self._fetch(path, payload=payload) if postokay: # Cache our entry for fast access later self.store.set( room, { "id": response.get("room_id"), "home_server": home_server, }, ) return response.get("room_id") # Only attempt to create a room when the server clearly indicates # the alias does not exist. A join can fail for many reasons, such # as invite required, auth failure, or permissions, and in those # cases auto-creating is both noisy and incorrect. if ( status_code == requests.codes.not_found or response.get("errcode") == "M_NOT_FOUND" ): return self._room_create(room) self.logger.warning( "Could not join Matrix room alias %s (error=%s). " "If this is a private room, ensure the user is invited or " "already joined, or specify the room_id (!...).", room, status_code, ) return None def _room_create(self, room): """Creates a matrix room and return it's room_id if successful otherwise None is returned.""" if not self.access_token: # We can't create a room if we're not logged in return None if not isinstance(room, str): # Not a supported string return None # Build our room if we have to: result = IS_ROOM_ALIAS.match(room) if not result: # Illegally formed room return None # Our home_server home_server = ( result.group("home_server") if result.group("home_server") else self.home_server ) if not home_server and self.user_id: parts = self.user_id.split(":", 1) if len(parts) == 2: home_server = parts[1] if not home_server: return None # update our room details room = "#{}:{}".format(result.group("room"), home_server) # Prepare our Create Payload payload = { "room_alias_name": result.group("room"), # Set our channel name "name": "#{} - {}".format(result.group("room"), self.app_desc), # hide the room by default; let the user open it up if they # wish to others. "visibility": "private", "preset": "trusted_private_chat", } # When E2EE is requested, enable encryption at room-creation time so # that the room is encrypted from its very first message. This only # applies when Apprise is the one creating the room; pre-existing # rooms keep whatever encryption state the server already has. if self.e2ee and self.secure and MATRIX_E2EE_SUPPORT: payload["initial_state"] = [ { "type": "m.room.encryption", "state_key": "", "content": {"algorithm": "m.megolm.v1.aes-sha2"}, } ] postokay, response, _ = self._fetch("/createRoom", payload=payload) if not postokay: # Failed to create channel # Typical responses: # - {u'errcode': u'M_ROOM_IN_USE', # u'error': u'Room alias already taken'} # - {u'errcode': u'M_UNKNOWN', # u'error': u'Internal server error'} if response and response.get("errcode") == "M_ROOM_IN_USE": return self._room_id(room) return None room_id = response.get("room_id") # Cache our entry for fast access later self.store.set( response.get("room_alias"), { "id": room_id, "home_server": home_server, }, ) # Pre-seed the room encryption cache so _e2ee_room_encrypted() does # not issue a redundant GET -- we just set the encryption state. if room_id and self.e2ee and self.secure and MATRIX_E2EE_SUPPORT: self.store.set( "e2ee_room_enc_{}".format(room_id), True, expires=self.default_cache_expiry_sec, ) return room_id def _joined_rooms(self): """Returns a list of the current rooms the logged in user is a part of.""" if not self.access_token: # No list is possible return [] postokay, response, _ = self._fetch( "/joined_rooms", payload=None, method="GET" ) if not postokay: # Failed to retrieve listings return [] # Return our list of rooms return response.get("joined_rooms", []) def _room_id(self, room): """Get room id from its alias. Args: room (str): The room alias name. Returns: returns the room id if it can, otherwise it returns None """ if not self.access_token: # We can't get a room id if we're not logged in return None if not isinstance(room, str): # Not a supported string return None # Build our room if we have to: result = IS_ROOM_ALIAS.match(room) if not result: # Illegally formed room return None # Our home_server home_server = ( result.group("home_server") if result.group("home_server") else self.home_server ) if not home_server and self.user_id: parts = self.user_id.split(":", 1) if len(parts) == 2: home_server = parts[1] if not home_server: return None # update our room details room = "#{}:{}".format(result.group("room"), home_server) # Make our request postokay, response, _ = self._fetch( f"/directory/room/{NotifyMatrix.quote(room)}", payload=None, method="GET", ) if postokay: return response.get("room_id") return None def _fetch( self, path, payload=None, params=None, attachment=None, method="POST", url_override=None, ok_status=None, ): """Wrapper to request.post() to manage it's response better and make the send() function cleaner and easier to maintain. This function always returns a 3-tuple: (success, response, status_code) The response is a dict when JSON is parseable, otherwise an empty dict. The status_code defaults to 500 on local failures. *ok_status* is an optional collection of additional HTTP status codes to treat as success (no warning logged). Use it for calls where a non-200 response is expected and meaningful, e.g. 404 on a state-event probe that returns "not found" = "feature not enabled". """ # Define our headers if params is None: params = {} headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", } if self.access_token is not None: headers["Authorization"] = f"Bearer {self.access_token}" # Server Discovery / Well-known URI if url_override: url = url_override else: try: url = self.base_url except MatrixDiscoveryException: # Discovery failed; we're done return (False, {}, requests.codes.internal_server_error) # Default return status code status_code = requests.codes.internal_server_error if path == "/upload": if self.version == MatrixVersion.V3: url += MATRIX_V3_MEDIA_PATH + path else: url += MATRIX_V2_MEDIA_PATH + path params.update({"filename": attachment.name}) with open(attachment.path, "rb") as fp: payload = fp.read() # Update our content type headers["Content-Type"] = attachment.mimetype elif not url_override: if self.version == MatrixVersion.V3: url += MATRIX_V3_API_PATH + path else: url += MATRIX_V2_API_PATH + path # Our response object response = {} # fetch function fn = ( requests.post if method == "POST" else (requests.put if method == "PUT" else requests.get) ) # Always call throttle before any remote server i/o is made self.throttle() # Define how many attempts we'll make if we get caught in a # throttle event retries = self.default_retries if self.default_retries > 0 else 1 while retries > 0: # Decrement our throttle retry count retries -= 1 self.logger.debug( "Matrix {} URL: {} (cert_verify={!r})".format( ( "POST" if method == "POST" else ("PUT" if method == "PUT" else "GET") ), url, self.verify_certificate, ) ) self.logger.debug(f"Matrix Payload: {payload!s}") # Initialize our response object r = None try: r = fn( url, data=dumps(payload) if not attachment else payload, params=params if params else None, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Store status code status_code = r.status_code self.logger.debug( "Matrix Response: code={}, {}".format( r.status_code, r.content ) ) response = loads(r.content) if r.status_code == requests.codes.too_many_requests: wait_ms = self.default_wait_ms try: wait_ms = response["retry_after_ms"] except KeyError: try: errordata = response["error"] wait_ms = errordata["retry_after_ms"] except KeyError: pass self.logger.warning( "Matrix server requested we throttle back " "{}ms; retries left {}.".format(wait_ms, retries) ) self.logger.debug(f"Response Details:\r\n{r.content}") # Throttle for specified wait self.throttle(wait=wait_ms / 1000) # Try again continue elif r.status_code != requests.codes.ok: # We had a problem if ok_status and r.status_code in ok_status: # Caller declared this status code acceptable # (e.g. 404 on a state-event probe). Return # failure tuple silently -- no warning logged. return (False, response, status_code) status_str = NotifyMatrix.http_response_code_lookup( r.status_code, MATRIX_HTTP_ERROR_MAP ) self.logger.warning( "Failed to handshake with Matrix server: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug(f"Response Details:\r\n{r.content}") # Return; we're done return (False, response, status_code) except (AttributeError, TypeError, ValueError): # This gets thrown if we can't parse our JSON Response # - ValueError = r.content is Unparsable # - TypeError = r.content is None # - AttributeError = r is None self.logger.warning("Invalid response from Matrix server.") self.logger.debug( "Response Details:\r\n%r", b"" if not r else (r.content or b""), ) return (False, {}, status_code) except ( requests.TooManyRedirects, requests.RequestException, ) as e: self.logger.warning( "A Connection error occurred while registering " "with Matrix server." ) self.logger.debug("Socket Exception: %s", e) # Return; we're done return (False, response, status_code) except OSError as e: self.logger.warning( "An I/O error occurred while reading {}.".format( attachment.name if attachment else "unknown file" ) ) self.logger.debug("I/O Exception: %s", e) return (False, {}, status_code) return (True, response, status_code) # If we get here, we ran out of retries return (False, {}, status_code) # --------------------------------------------------------------- # E2EE helpers # --------------------------------------------------------------- def _e2ee_room_encrypted(self, room_id): """Return ``True`` if *room_id* has E2EE enabled on the server. The result is cached in the persistent store so subsequent sends to the same room do not issue additional network requests. """ cache_key = "e2ee_room_enc_{}".format(room_id) cached = self.store.get(cache_key) if cached is not None: return cached ok, response, _ = self._fetch( "/rooms/{}/state/m.room.encryption".format( NotifyMatrix.quote(room_id) ), method="GET", # 404 = no encryption state event = room is not encrypted; # suppress the warning that _fetch would otherwise log. ok_status={requests.codes.not_found}, ) result = ok and bool(response) self.store.set( cache_key, result, expires=self.default_cache_expiry_sec, ) return result def _e2ee_setup(self): """Ensure the E2EE device account exists and keys are uploaded. Creates a new :class:`MatrixOlmAccount` if one does not yet exist in the persistent store, then calls :meth:`_e2ee_upload_keys` if the server has not yet received our device keys for the current access token. Returns ``True`` on success, ``False`` on failure. """ if self._e2ee_account is None: acct_data = self.store.get("e2ee_account") if acct_data: try: self._e2ee_account = MatrixOlmAccount.from_dict(acct_data) except Exception: self._e2ee_account = None if self._e2ee_account is None: self._e2ee_account = MatrixOlmAccount() self.store.set( "e2ee_account", self._e2ee_account.to_dict(), expires=self.default_cache_expiry_sec, ) # Keys uploaded status must match the current Matrix device identity # and the account keys we are about to use. This lets us recover from # cached state where the homeserver assigned a different device_id or # where the local E2EE account changed. current_binding = ( "{}|{}|{}|{}".format( self.user_id or "", self.device_id or "", self._e2ee_account.identity_key, self._e2ee_account.signing_key, ) if self._e2ee_account is not None else "" ) if self.store.get("e2ee_device_binding") != current_binding: self.store.clear("e2ee_keys_uploaded") if not self.store.get("e2ee_keys_uploaded"): return self._e2ee_upload_keys() return True def _e2ee_upload_keys(self): """POST device keys to ``/_matrix/client/v3/keys/upload``.""" if not self.user_id or not self.device_id: self.logger.warning( "Matrix E2EE: cannot upload keys without user_id " "and device_id; ensure login completes first." ) return False payload = { "device_keys": self._e2ee_account.device_keys_payload( self.user_id, self.device_id ), "one_time_keys": self._e2ee_account.one_time_keys_payload( self.user_id, self.device_id, count=self.default_e2ee_otk_count, ), "fallback_keys": self._e2ee_account.fallback_keys_payload( self.user_id, self.device_id ), } postokay, response, _ = self._fetch("/keys/upload", payload=payload) if not postokay: self.logger.warning("Matrix E2EE: device key upload failed.") return False # Mirror stable python-olm account behaviour: once uploaded, the # current one-time key batch is considered published and a future # upload should generate a fresh set. self._e2ee_account.mark_keys_as_published() self.store.set( "e2ee_account", self._e2ee_account.to_dict(), expires=self.default_cache_expiry_sec, ) self.store.set( "e2ee_keys_uploaded", True, expires=self.default_cache_expiry_sec, ) self.store.set( "e2ee_device_binding", "{}|{}|{}|{}".format( self.user_id, self.device_id, self._e2ee_account.identity_key, self._e2ee_account.signing_key, ), expires=self.default_cache_expiry_sec, ) # Track the server-side OTK count so _e2ee_replenish_otks can # decide whether a top-up is needed after the next /keys/claim. counts = ( response.get("one_time_key_counts", {}) if isinstance(response, dict) else {} ) self.store.set( "e2ee_otk_server_count", counts.get("signed_curve25519", 0), expires=self.default_cache_expiry_sec, ) self.logger.debug( "Matrix E2EE: device keys uploaded for %s / %s " "(server OTK count: %d).", self.user_id, self.device_id, counts.get("signed_curve25519", 0), ) return True def _e2ee_replenish_otks(self, claimed_count=0, skipped_no_otk=0): """Top up the server-side OTK pool after a ``/keys/claim`` event. Parameters: claimed_count -- number of OTKs successfully consumed by the preceding ``/keys/claim`` (= ``built_count`` from :meth:`_e2ee_share_room_key`) skipped_no_otk -- devices that were skipped because the server returned no OTK for them (pool already dry) A replenishment upload is issued when any of the following is true: - ``skipped_no_otk > 0``: the pool was already depleted during the current claim -- top up immediately so the next key share can reach those devices. - estimated remaining OTKs after claim < ``default_e2ee_otk_replenish_threshold``: pool is running low. - server count was never recorded (unknown state): replenish as a precaution. Only ``one_time_keys`` is uploaded so the server does not treat this as a device re-registration. Returns ``True`` on success (or when no top-up was needed), ``False`` on network failure (non-fatal -- the preceding send already succeeded). """ if not self.user_id or not self.device_id: return False server_count = self.store.get("e2ee_otk_server_count") unknown_count = server_count is None if unknown_count: remaining = 0 else: remaining = max(0, server_count - claimed_count) need_replenish = ( skipped_no_otk > 0 or unknown_count or remaining < self.default_e2ee_otk_replenish_threshold ) if not need_replenish: self.logger.trace( "Matrix E2EE: OTK pool sufficient " "(~%d remaining after claim); skipping replenishment.", remaining, ) return True if skipped_no_otk > 0: self.logger.warning( "Matrix E2EE: %d device(s) had no OTK available during " "key share (server pool was depleted). Those device(s) " "will not decrypt the current message. Replenishing OTK " "pool now so the next session rotation can reach them.", skipped_no_otk, ) elif unknown_count: self.logger.debug( "Matrix E2EE: server OTK count unknown; " "replenishing as a precaution.", ) else: self.logger.debug( "Matrix E2EE: OTK pool low (~%d remaining after claim, " "threshold=%d); replenishing.", remaining, self.default_e2ee_otk_replenish_threshold, ) payload = { "one_time_keys": self._e2ee_account.one_time_keys_payload( self.user_id, self.device_id, count=self.default_e2ee_otk_count, ), } postokay, response, _ = self._fetch("/keys/upload", payload=payload) if not postokay: self.logger.warning( "Matrix E2EE: OTK replenishment upload failed " "(estimated ~%d remaining); pool may be depleted " "on the next key share.", remaining, ) return False self._e2ee_account.mark_keys_as_published() self.store.set( "e2ee_account", self._e2ee_account.to_dict(), expires=self.default_cache_expiry_sec, ) counts = ( response.get("one_time_key_counts", {}) if isinstance(response, dict) else {} ) new_count = counts.get("signed_curve25519", 0) self.store.set( "e2ee_otk_server_count", new_count, expires=self.default_cache_expiry_sec, ) self.logger.debug( "Matrix E2EE: OTK pool replenished; " "server now reports %d signed_curve25519 key(s).", new_count, ) return True def _e2ee_get_megolm(self, room_id): """Return the current outbound MegOLM session for *room_id*. Creates a new session when none exists or when the existing one has reached the rotation threshold. Also clears the ``e2ee_key_shared_*`` flag so the new session key is re-shared. """ store_key = "e2ee_megolm_{}".format(room_id) session_data = self.store.get(store_key) if session_data: try: session = MatrixMegOlmSession.from_dict(session_data) if not session.should_rotate(): return session except Exception: # Cached session is unreadable or from an older incompatible # format; force creation of a fresh one below. pass # New or rotated session session = MatrixMegOlmSession() self.store.set( store_key, session.to_dict(), expires=self.default_cache_expiry_sec, ) # Clear key-shared flag so we share the new session key self.store.clear("e2ee_key_shared_{}".format(room_id)) return session def _e2ee_save_megolm(self, room_id, session): """Persist the updated MegOLM session state.""" self.store.set( "e2ee_megolm_{}".format(room_id), session.to_dict(), expires=self.default_cache_expiry_sec, ) def _e2ee_room_members(self, room_id): """Query device keys for all joined members of *room_id*. Returns a nested dict:: {user_id: {device_id: {"curve25519": ..., "ed25519": ...}}} Returns ``None`` on HTTP failure, empty dict when the room has no members (unlikely but tolerated). """ path = "/rooms/{}/joined_members".format(NotifyMatrix.quote(room_id)) postokay, response, _ = self._fetch(path, payload=None, method="GET") if not postokay or not isinstance(response, dict): return None member_ids = list(response.get("joined", {}).keys()) if not member_ids: return {} postokay, resp, _ = self._fetch( "/keys/query", payload={"device_keys": {uid: [] for uid in member_ids}}, ) if not postokay or not isinstance(resp, dict): return None result = {} for uid, devices in resp.get("device_keys", {}).items(): result[uid] = {} for dev_id, dev_info in devices.items(): if not verify_device_keys(dev_info, uid, dev_id): self.logger.debug( "Matrix E2EE: device key signature invalid " "for %s / %s; device skipped.", uid, dev_id, ) continue keys = dev_info.get("keys", {}) result[uid][dev_id] = { "curve25519": keys.get("curve25519:{}".format(dev_id), ""), "ed25519": keys.get("ed25519:{}".format(dev_id), ""), } return result def _e2ee_share_room_key(self, room_id, session): """Send the MegOLM session key to all devices in *room_id*. Flow: 1. Fetch joined-member device keys via /keys/query 2. Claim one-time keys via /keys/claim 3. Create outbound Olm sessions and encrypt the room-key event 4. Deliver via PUT /sendToDevice/m.room.encrypted/{txnId} Returns ``True`` on success (partial device failures are tolerated), ``False`` only when a critical step fails. """ members = self._e2ee_room_members(room_id) if members is None: self.logger.warning( "Matrix E2EE: failed to query room members for %s.", room_id, ) return False if not members: self.logger.trace( "Matrix E2EE: no room members found for %s; " "skipping key share.", room_id, ) return True # Count total device slots being requested (for diagnostics) total_devices = sum(len(devs) for devs in members.values()) self.logger.debug( "Matrix E2EE: sharing session %s for room %s with " "%d member(s) / %d device(s).", session.session_id[:12], room_id, len(members), total_devices, ) # Build the claim request for all member devices. # "signed_curve25519" is the algorithm Matrix clients publish and # servers are required to support; "curve25519" (unsigned) is # deprecated and usually yields no keys on current servers. otk_request = {} for uid, devs in members.items(): otk_request[uid] = dict.fromkeys(devs, "signed_curve25519") postokay, otk_resp, _ = self._fetch( "/keys/claim", payload={"one_time_keys": otk_request}, ) if not postokay: self.logger.warning("Matrix E2EE: failed to claim one-time keys.") return False otk_keys = ( otk_resp.get("one_time_keys", {}) if isinstance(otk_resp, dict) else {} ) # Log failures from the claim response # spec: server populates failures{} with unreachable servers failures = ( otk_resp.get("failures", {}) if isinstance(otk_resp, dict) else {} ) if failures: self.logger.debug( "Matrix E2EE: /keys/claim reported failures for server(s): %s", list(failures.keys()), ) # Build to-device message payload to_device_msgs = {} room_key_content = { "algorithm": "m.megolm.v1.aes-sha2", "room_id": room_id, "session_id": session.session_id, "session_key": session.session_key(), } self.logger.trace( "Matrix E2EE: room_key session_id=%s counter=%d", session.session_id[:12], session._counter, ) skipped_own = 0 skipped_no_ik = 0 skipped_no_otk = 0 skipped_otk_invalid = 0 skipped_olm_fail = 0 built_count = 0 for uid, devices in members.items(): to_device_msgs[uid] = {} for dev_id, dev_info in devices.items(): # Skip our own device to avoid self-Olm-session setup if uid == self.user_id and dev_id == self.device_id: skipped_own += 1 self.logger.trace( "Matrix E2EE: skipping own device %s / %s.", uid, dev_id, ) continue their_ik = dev_info.get("curve25519", "") if not their_ik: skipped_no_ik += 1 self.logger.trace( "Matrix E2EE: no curve25519 key for " "%s / %s; device skipped.", uid, dev_id, ) continue # Locate and verify the OTK for this device. # Servers return signed_curve25519 keys (the algorithm we # requested) as {"key": ..., "signatures": ...} dicts. # signed_curve25519 OTKs are always KeyObjects # {"key": ..., "signatures": ...}; plain-string values # are not valid for this algorithm and are rejected. their_otk = None otk_entry = otk_keys.get(uid, {}).get(dev_id, {}) self.logger.trace( "Matrix E2EE: OTK entry for %s / %s: keys=%s", uid, dev_id, list(otk_entry.keys()) if isinstance(otk_entry, dict) else repr(type(otk_entry)), ) if isinstance(otk_entry, dict): for k, v in otk_entry.items(): if not k.startswith("signed_curve25519:"): self.logger.trace( "Matrix E2EE: OTK key %r for %s / %s " "is not signed_curve25519; skipped.", k, uid, dev_id, ) continue if not isinstance(v, dict): self.logger.trace( "Matrix E2EE: OTK for %s / %s is " "not a KeyObject (got %s); skipped.", uid, dev_id, type(v).__name__, ) break ed25519_pub = dev_info.get("ed25519", "") if not ed25519_pub or not verify_signed_otk( v, uid, dev_id, ed25519_pub ): skipped_otk_invalid += 1 # Keep at debug -- invalid signature is unexpected # and worth surfacing at -vv. self.logger.debug( "Matrix E2EE: OTK signature " "invalid for %s / %s (ed25519_pub=%s); " "skipped.", uid, dev_id, ed25519_pub[:12] if ed25519_pub else "(empty)", ) else: their_otk = v.get("key") self.logger.trace( "Matrix E2EE: OTK accepted for " "%s / %s (key_id=%s).", uid, dev_id, k, ) break else: self.logger.trace( "Matrix E2EE: no OTK dict for %s / %s " "(type=%s); device skipped.", uid, dev_id, type(otk_entry).__name__, ) if not their_otk: skipped_no_otk += 1 self.logger.trace( "Matrix E2EE: no usable OTK for %s / %s; " "device skipped.", uid, dev_id, ) continue try: olm_session = self._e2ee_account.create_outbound_session( their_ik, their_otk ) except Exception as exc: skipped_olm_fail += 1 # Keep at debug -- Olm session failure is unexpected. self.logger.debug( "Matrix E2EE: failed to build Olm session " "for %s / %s: %s", uid, dev_id, exc, ) continue # Build the m.room_key inner plaintext per Matrix spec: # https://spec.matrix.org/v1.11/client-server-api/#mroomkey # # Required fields only; non-standard extension fields # (sender_device_keys, org.matrix.msc4147.device_keys) have # been removed because they: # - Add ~930 bytes to an otherwise ~400-byte payload, # bloating the Olm ciphertext from ~400B to ~1640B. # - Are not part of the spec and may confuse strict # implementations (Element/matrix-sdk-crypto warns on # unknown fields in to-device events in some builds). # - Contain unsigned device-key material that recipients # should instead fetch via /keys/query for authenticity. inner = dumps( { "type": "m.room_key", "content": room_key_content, "sender": self.user_id, "recipient": uid, "recipient_keys": { "ed25519": dev_info.get("ed25519", "") }, "keys": {"ed25519": self._e2ee_account.signing_key}, } ) ciphertext = olm_session.encrypt(inner) built_count += 1 self.logger.trace( "Matrix E2EE: Olm-encrypted room key for " "%s / %s (ciphertext type=%d, inner_len=%d).", uid, dev_id, ciphertext.get("type", -1), len(inner), ) to_device_msgs[uid][dev_id] = { "algorithm": "m.olm.v1.curve25519-aes-sha2", "ciphertext": {their_ik: ciphertext}, "sender_key": self._e2ee_account.identity_key, } self.logger.debug( "Matrix E2EE: key-share summary for room %s: " "built=%d skipped_own=%d skipped_no_ik=%d " "skipped_no_otk=%d skipped_otk_invalid=%d " "skipped_olm_fail=%d", room_id, built_count, skipped_own, skipped_no_ik, skipped_no_otk, skipped_otk_invalid, skipped_olm_fail, ) # Only send if at least one device message was built if not any(v for v in to_device_msgs.values()): self.logger.trace( "Matrix E2EE: no to-device messages built for " "room %s; nothing to send.", room_id, ) return True if self.access_token != self.password: self.transaction_id += 1 self.store.set( "transaction_id", self.transaction_id, expires=self.default_cache_expiry_sec, ) path = "/sendToDevice/m.room.encrypted/{}".format(self.transaction_id) postokay, _, _ = self._fetch( path, payload={"messages": to_device_msgs}, method="PUT", ) if not postokay: self.logger.warning( "Matrix E2EE: failed to deliver room key to devices in %s.", room_id, ) return False self.logger.debug( "Matrix E2EE: room key delivered to %d device(s) in %s " "(txnId=%s).", built_count, room_id, self.transaction_id, ) # Check whether the OTK pool needs topping up. Pass the number of # OTKs consumed (built_count) and any devices skipped because the # server had no OTK for them so _e2ee_replenish_otks can log the # right diagnostic and decide whether an upload is needed. # We always reach here with built_count >= 1 (the any() guard above # returns early when no messages were built), so the call is never # redundant -- _e2ee_replenish_otks itself decides whether to upload. self._e2ee_replenish_otks( claimed_count=built_count, skipped_no_otk=skipped_no_otk, ) return True def _e2ee_send_to_room(self, room_id, body, title, notify_type): """Encrypt and send one message to *room_id* via MegOLM. Shares the MegOLM session key with room members when the session is new or has just been rotated. Returns ``True`` on success, ``False`` on failure. """ session = self._e2ee_get_megolm(room_id) self.logger.trace( "Matrix E2EE: using MegOLM session %s counter=%d for %s.", session.session_id[:12], session._counter, room_id, ) # Share the room key unless this exact MegOLM session was already # announced. Older stores may contain a legacy boolean flag; treat it # as stale so the next send re-shares the key and repairs recipients # that never received the original m.room_key. shared_flag = "e2ee_key_shared_{}".format(room_id) cached_shared = self.store.get(shared_flag) if cached_shared != session.session_id: self.logger.trace( "Matrix E2EE: session key not yet shared " "(cached=%r current=%r); sharing now.", cached_shared[:12] if isinstance(cached_shared, str) else cached_shared, session.session_id[:12], ) if not self._e2ee_share_room_key(room_id, session): return False self.store.set( shared_flag, session.session_id, expires=self.default_cache_expiry_sec, ) else: self.logger.trace( "Matrix E2EE: session key already shared for " "session %s; skipping key share.", session.session_id[:12], ) # Build the inner plaintext event msg_content = { "msgtype": "m.{}".format(self.msgtype), "body": "{title}{body}".format( title="" if not title else "# {}\r\n".format(title), body=body, ), } if self.notify_format == NotifyFormat.HTML: msg_content.update( { "format": "org.matrix.custom.html", "formatted_body": "{title}{body}".format( title=( "" if not title else "

{}

".format(title) ), body=body, ), } ) elif self.notify_format == NotifyFormat.MARKDOWN: msg_content.update( { "format": "org.matrix.custom.html", "formatted_body": "{title}{body}".format( title=( "" if not title else "

{}

".format( NotifyMatrix.escape_html( title, whitespace=False ) ) ), body=markdown(body), ), } ) inner_event = { "type": "m.room.message", "content": msg_content, "room_id": room_id, } ciphertext = session.encrypt(inner_event) self._e2ee_save_megolm(room_id, session) self.logger.trace( "Matrix E2EE: MegOLM ciphertext produced for room %s " "(session_id=%s counter_before_encrypt=%d).", room_id, session.session_id[:12], # _advance() already ran; counter is now N+1 after encrypt session._counter - 1, ) path = "/rooms/{}/send/m.room.encrypted/{}".format( NotifyMatrix.quote(room_id), self.transaction_id ) encrypted_payload = { "algorithm": "m.megolm.v1.aes-sha2", "ciphertext": ciphertext, "sender_key": self._e2ee_account.identity_key, "session_id": session.session_id, "device_id": self.device_id or "", } postokay, _, _ = self._fetch( path, payload=encrypted_payload, method="PUT" ) if self.access_token != self.password: self.transaction_id += 1 self.store.set( "transaction_id", self.transaction_id, expires=self.default_cache_expiry_sec, ) return postokay def _e2ee_send_attachment(self, attachment, room_id, session): """Encrypt *attachment* and deliver it to *room_id* via MegOLM. Steps: 1. Read the file into memory and encrypt with AES-256-CTR. 2. Upload the ciphertext to the media server (content_uri). 3. Build an ``m.room.message`` inner event whose ``file`` field carries the EncryptedFile metadata (key + iv + sha256). 4. Encrypt the inner event with MegOLM and PUT to the room. Returns ``True`` on success, ``False`` on any failure. """ # Read file bytes try: with open(attachment.path, "rb") as fh: file_data = fh.read() except OSError as e: self.logger.warning( "Matrix E2EE: could not read attachment {}.".format( attachment.name or "file" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False # Encrypt locally with AES-256-CTR ciphertext, file_info = encrypt_attachment(file_data) # Upload the ciphertext to the media server. # The encrypted bytes are posted directly rather than from a file # path, so we call requests.post() directly instead of _fetch(). headers = { "User-Agent": self.app_id, "Content-Type": "application/octet-stream", "Accept": "application/json", } if self.access_token: headers["Authorization"] = f"Bearer {self.access_token}" try: base = self.base_url except Exception: return False media_path = ( MATRIX_V3_MEDIA_PATH if self.version == MatrixVersion.V3 else MATRIX_V2_MEDIA_PATH ) upload_url = base + media_path + "/upload" self.logger.debug( "Matrix E2EE: uploading encrypted attachment to %s " "(name=%s size=%d iv=%s sha256=%s).", upload_url, attachment.name or "file", len(ciphertext), file_info.get("iv", "?"), file_info.get("hashes", {}).get("sha256", "?"), ) self.throttle() try: r = requests.post( upload_url, data=ciphertext, params={"filename": attachment.name or "file"}, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) except requests.RequestException as e: self.logger.warning( "Matrix E2EE: connection error uploading encrypted attachment." ) self.logger.debug(f"Socket Exception: {e!s}") return False try: upload_resp = loads(r.content) except Exception: upload_resp = {} self.logger.debug( "Matrix E2EE: upload response HTTP %d body=%r.", r.status_code, r.content[:200], ) if r.status_code != requests.codes.ok or not upload_resp.get( "content_uri" ): self.logger.warning( "Matrix E2EE: media upload failed (HTTP {}).".format( r.status_code ) ) return False file_info["url"] = upload_resp["content_uri"] self.logger.debug( "Matrix E2EE: attachment content_uri=%s.", upload_resp["content_uri"], ) # Build the inner plaintext attachment event is_image = IS_IMAGE.match(attachment.mimetype) content = { "msgtype": "m.image" if is_image else "m.file", "body": attachment.name or "file", "file": file_info, "info": { "mimetype": attachment.mimetype, "size": len(attachment), }, } if not is_image: content["filename"] = attachment.name or "file" inner_event = { "type": "m.room.message", "content": content, "room_id": room_id, } # Encrypt with MegOLM and send ciphertext_event = session.encrypt(inner_event) self._e2ee_save_megolm(room_id, session) path = "/rooms/{}/send/m.room.encrypted/{}".format( NotifyMatrix.quote(room_id), self.transaction_id ) encrypted_payload = { "algorithm": "m.megolm.v1.aes-sha2", "ciphertext": ciphertext_event, "sender_key": self._e2ee_account.identity_key, "session_id": session.session_id, "device_id": self.device_id or "", } postokay, _, _ = self._fetch( path, payload=encrypted_payload, method="PUT" ) if self.access_token != self.password: self.transaction_id += 1 self.store.set( "transaction_id", self.transaction_id, expires=self.default_cache_expiry_sec, ) return postokay def _dm_room_find_or_create(self, user): """Resolve *user* (``@localpart`` or ``@localpart:homeserver``) to a Matrix room ID suitable for direct messaging. Lookup order: 1. Persistent-store cache. 2. ``GET /user/{selfId}/account_data/m.direct`` -- check whether an existing DM room already exists for this user. 3. ``POST /createRoom`` with ``is_direct=true`` and an invite for the target user. The ``m.direct`` account-data entry is then updated so other clients also recognise the room as a DM. Returns the room ID string on success, or ``None`` on failure. """ result = IS_USER.match(user) if not result: self.logger.warning("Matrix DM: invalid user identifier %r.", user) return None home_server = ( result.group("home_server") if result.group("home_server") else self.home_server ) user_id = "@{}:{}".format(result.group("user"), home_server) cache_key = "dm_room_{}".format(user_id) cached = self.store.get(cache_key) if cached: return cached # Fetch existing m.direct mapping from the server mdirect = {} if self.user_id: ok, resp, _ = self._fetch( "/user/{}/account_data/m.direct".format( NotifyMatrix.quote(self.user_id) ), method="GET", ) if ok and isinstance(resp, dict): mdirect = resp rooms = mdirect.get(user_id, []) if rooms: room_id = rooms[0] self.store.set( cache_key, room_id, expires=self.default_cache_expiry_sec, ) return room_id # No existing DM room -- create one dm_payload = { "is_direct": True, "preset": "trusted_private_chat", "invite": [user_id], } # When E2EE is requested, enable encryption at room-creation time. if self.e2ee and self.secure and MATRIX_E2EE_SUPPORT: dm_payload["initial_state"] = [ { "type": "m.room.encryption", "state_key": "", "content": {"algorithm": "m.megolm.v1.aes-sha2"}, } ] ok, response, _ = self._fetch("/createRoom", payload=dm_payload) if not ok or not isinstance(response, dict): self.logger.warning( "Matrix DM: failed to create room for %s.", user_id ) return None room_id = response.get("room_id") if not room_id: return None self.store.set( cache_key, room_id, expires=self.default_cache_expiry_sec, ) # Pre-seed the room encryption cache. if self.e2ee and self.secure and MATRIX_E2EE_SUPPORT: self.store.set( "e2ee_room_enc_{}".format(room_id), True, expires=self.default_cache_expiry_sec, ) # Update the m.direct account-data mapping so other clients # recognise this room as a DM conversation. if self.user_id: mdirect[user_id] = [*mdirect.get(user_id, []), room_id] self._fetch( "/user/{}/account_data/m.direct".format( NotifyMatrix.quote(self.user_id) ), payload=mdirect, method="PUT", ) return room_id # --------------------------------------------------------------- # Destructor / URL / parse # --------------------------------------------------------------- def __del__(self): """Ensure we relinquish our token.""" if self.mode == MatrixWebhookMode.T2BOT: # nothing to do return if self.store.mode != PersistentStoreMode.MEMORY: # We no longer have to log out as we have persistant storage # to re-use our credentials with return if ( self.access_token is not None and self.access_token == self.password and not self.user ): return # Best-effort cleanup only with contextlib.suppress(Exception): self._logout() @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.mode, ( self.host if self.mode != MatrixWebhookMode.T2BOT else self.access_token ), self.port if self.port else (443 if self.secure else 80), ( self.webhook_path if self.mode == MatrixWebhookMode.HOOKSHOT else None ), self.user if self.mode != MatrixWebhookMode.T2BOT else None, self.password if self.mode != MatrixWebhookMode.T2BOT else None, ) @staticmethod def runtime_deps(): """Return runtime dependency package names. E2EE support requires the `cryptography` package. """ return ("cryptography",) 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", "mode": self.mode, "version": self.version, "msgtype": self.msgtype, "discovery": "yes" if self.discovery else "no", "hsreq": "yes" if self.hsreq else "no", } if self.mode == MatrixWebhookMode.HOOKSHOT: params["path"] = self.webhook_path if not self.e2ee: params["e2ee"] = "no" # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) auth = "" if self.mode != MatrixWebhookMode.T2BOT: # Determine Authentication if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyMatrix.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="", ), ) elif self.user or self.password: auth = "{value}@".format( value=NotifyMatrix.quote( self.user if self.user else self.password, safe="" ), ) return "{schema}://{auth}{hostname}{port}/{rooms}?{params}".format( schema=(self.secure_protocol if self.secure else self.protocol), auth=auth, hostname=( NotifyMatrix.quote(self.host, safe="") if self.mode != MatrixWebhookMode.T2BOT else self.pprint(self.access_token, privacy, safe="") ), port=("" if not self.port else f":{self.port}"), rooms=NotifyMatrix.quote("/".join(self.rooms + self.users)), params=NotifyMatrix.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.rooms) + len(self.users) 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 if not results.get("host"): return None # Get our rooms results["targets"] = NotifyMatrix.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"] += NotifyMatrix.parse_list(results["qsd"]["to"]) # Boolean to include an image or not results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyMatrix.template_args["image"]["default"], ) ) # Boolean to perform a server discovery results["discovery"] = parse_bool( results["qsd"].get( "discovery", NotifyMatrix.template_args["discovery"]["default"], ) ) # Boolean to enforce ':homeserver' on room IDs when missing results["hsreq"] = parse_bool( results["qsd"].get( "hsreq", NotifyMatrix.template_args["hsreq"]["default"], ) ) if "path" in results["qsd"]: results["webhook_path"] = NotifyMatrix.unquote( results["qsd"]["path"] ) # E2EE flag if "e2ee" in results["qsd"]: results["e2ee"] = parse_bool(results["qsd"]["e2ee"]) # Get our mode results["mode"] = results["qsd"].get("mode") # t2bot detection... look for just a hostname, and/or just a # user/host if we match this; we can go ahead and set the mode # (but only if it was otherwise not set) if ( results["mode"] is None and not results["password"] and not results["targets"] ): # Default mode to t2bot results["mode"] = MatrixWebhookMode.T2BOT if ( results["mode"] and results["mode"].lower() == MatrixWebhookMode.T2BOT ): # unquote our hostname and pass it in as the password/token results["password"] = NotifyMatrix.unquote(results["host"]) # Support the message type keyword if "msgtype" in results["qsd"] and len(results["qsd"]["msgtype"]): results["msgtype"] = NotifyMatrix.unquote( results["qsd"]["msgtype"] ) # Support the use of the token= keyword if "token" in results["qsd"] and len(results["qsd"]["token"]): results["password"] = NotifyMatrix.unquote(results["qsd"]["token"]) elif not results["password"] and results["user"]: # swap results["password"] = results["user"] results["user"] = None # Support the use of the version= or v= keyword if "version" in results["qsd"] and len(results["qsd"]["version"]): results["version"] = NotifyMatrix.unquote( results["qsd"]["version"] ) elif "v" in results["qsd"] and len(results["qsd"]["v"]): results["version"] = NotifyMatrix.unquote(results["qsd"]["v"]) return results @staticmethod def parse_native_url(url): """ Support https://webhooks.t2bot.io/api/v1/matrix/hook/WEBHOOK_TOKEN/ """ result = re.match( r"^https?://webhooks\.t2bot\.io/api/v[0-9]+/matrix/hook/" r"(?P[A-Z0-9_-]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: mode = f"mode={MatrixWebhookMode.T2BOT}" return NotifyMatrix.parse_url( "{schema}://{webhook_token}/{params}".format( schema=NotifyMatrix.secure_protocol, webhook_token=result.group("webhook_token"), params=( f"?{mode}" if not result.group("params") else "{}&{}".format(result.group("params"), mode) ), ) ) return None def server_discovery(self): """ Home Server Discovery as documented here: https://spec.matrix.org/v1.11/client-server-api/#well-known-uri """ if not (self.discovery and self.secure): # Nothing further to do with insecure server setups return "" # Get our content from cache base_url, identity_url = ( self.store.get(self.discovery_base_key), self.store.get(self.discovery_identity_key), ) if not (base_url is None and identity_url is None): # We can use our cached value and return early return base_url # the Matrix ID at the first colon. verify_url = ( "{schema}://{hostname}{port}/.well-known/matrix/client".format( schema="https" if self.secure else "http", hostname=self.host, port=("" if not self.port else f":{self.port}"), ) ) _, response, status_code = self._fetch( None, method="GET", url_override=verify_url ) # Output may look as follows: # { # "m.homeserver": { # "base_url": "https://matrix.example.com" # }, # "m.identity_server": { # "base_url": "https://nuxref.com" # } # } if status_code == requests.codes.not_found: # This is an acceptable response; we're done self.logger.debug( "Matrix Well-Known Base URI not found at %s", verify_url, ) # Set our keys out for fast recall later on self.store.set( self.discovery_base_key, "", expires=self.discovery_cache_length_sec, ) self.store.set( self.discovery_identity_key, "", expires=self.discovery_cache_length_sec, ) return "" elif status_code != requests.codes.ok: # We're done early as we couldn't load the results msg = "Matrix Well-Known Base URI Discovery Failed" self.logger.warning( "%s - %s returned error code: %d", msg, verify_url, status_code, ) raise MatrixDiscoveryException(msg, error_code=status_code) if not response: # This is an acceptable response; we simply do nothing self.logger.debug( "Matrix Well-Known Base URI not defined %s", verify_url ) # Set our keys out for fast recall later on self.store.set( self.discovery_base_key, "", expires=self.discovery_cache_length_sec, ) self.store.set( self.discovery_identity_key, "", expires=self.discovery_cache_length_sec, ) return "" # # Parse our m.homeserver information # try: base_url = response["m.homeserver"]["base_url"].rstrip("/") results = NotifyBase.parse_url(base_url, verify_host=True) except (AttributeError, TypeError, KeyError): # AttributeError: result wasn't a string (rstrip failed) # TypeError : response wasn't a dictionary # KeyError : response not to standards results = None if not results: msg = "Matrix Well-Known Base URI Discovery Failed" self.logger.warning( "%s - m.homeserver payload is missing or invalid: %s", msg, response, ) raise MatrixDiscoveryException(msg) # # Our .well-known extraction was successful; now we need to # verify that the version information resolves. # verify_url = f"{base_url}/_matrix/client/versions" # Post our content _, _, status_code = self._fetch( None, method="GET", url_override=verify_url ) if status_code != requests.codes.ok: # We're done early as we couldn't load the results msg = "Matrix Well-Known Base URI Discovery Verification Failed" self.logger.warning( "%s - %s returned error code: %d", msg, verify_url, status_code, ) raise MatrixDiscoveryException(msg, error_code=status_code) # # Phase 2: Handle m.identity_server IF defined # if "m.identity_server" in response: try: identity_url = response["m.identity_server"][ "base_url" ].rstrip("/") results = NotifyBase.parse_url(identity_url, verify_host=True) except (AttributeError, TypeError, KeyError): # AttributeError: result wasn't a string (rstrip failed) # TypeError : response wasn't a dictionary # KeyError : response not to standards results = None if not results: msg = "Matrix Well-Known Identity URI Discovery Failed" self.logger.warning( "%s - m.identity_server payload is missing or invalid: %s", msg, response, ) raise MatrixDiscoveryException(msg) # # Verify identity server found # verify_url = f"{identity_url}/_matrix/identity/v2" # Post our content _postokay, _, status_code = self._fetch( None, method="GET", url_override=verify_url ) if status_code != requests.codes.ok: # We're done early as we couldn't load the results msg = "Matrix Well-Known Identity URI Discovery Failed" self.logger.warning( "%s - %s returned error code: %d", msg, verify_url, status_code, ) raise MatrixDiscoveryException(msg, error_code=status_code) # Update our cache self.store.set( self.discovery_identity_key, identity_url, # Add 2 seconds to prevent this key from expiring before # base expires=self.discovery_cache_length_sec + 2, ) else: # No identity server self.store.set( self.discovery_identity_key, "", # Add 2 seconds to prevent this key from expiring before # base expires=self.discovery_cache_length_sec + 2, ) # Update our cache self.store.set( self.discovery_base_key, base_url, expires=self.discovery_cache_length_sec, ) return base_url @property def base_url(self): """Returns the base_url if known.""" try: base_url = self.server_discovery() if base_url: # We can use our cached value and return early return base_url except MatrixDiscoveryException: self.store.clear( self.discovery_base_key, self.discovery_identity_key ) raise # If we get hear, we need to build our URL dynamically based on # what was provided to us during the plugins initialization return "{schema}://{hostname}{port}".format( schema="https" if self.secure else "http", hostname=self.host, port=("" if not self.port else f":{self.port}"), ) @property def identity_url(self): """Returns the identity_url if known.""" base_url = self.base_url identity_url = self.store.get(self.discovery_identity_key) return identity_url if identity_url else base_url apprise-1.10.0/apprise/plugins/matrix/e2ee.py000066400000000000000000000771211517341665700211050ustar00rootroot00000000000000# 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. # Minimal Olm + MegOLM outbound implementation for Matrix E2EE. # # Only the *sending* path is implemented, which is all Apprise requires. # All cryptographic primitives come from the `cryptography` package that # is already an optional Apprise dependency (used by pem.py, VAPID, FCM). # # Protocol references: # Olm spec: # https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/olm.md # MegOLM spec: # https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/megolm.md # Matrix E2EE client-server API: # https://spec.matrix.org/v1.11/client-server-api/ # #end-to-end-encryption import base64 from json import dumps import os import struct import time as _time import uuid try: from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import ( hashes, hmac as _hmac_mod, padding as _pad_mod, ) from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey, ) from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, X25519PublicKey, ) from cryptography.hazmat.primitives.ciphers import ( Cipher, algorithms, modes, ) from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.serialization import ( Encoding, NoEncryption, PrivateFormat, PublicFormat, ) # E2EE support is available MATRIX_E2EE_SUPPORT = True except ImportError: # E2EE support unavailable; `pip install cryptography` to enable MATRIX_E2EE_SUPPORT = False # Rotate the MegOLM session after this many messages MEGOLM_ROTATION_MSGS = 100 # Rotate the MegOLM session after this many seconds (7 days) MEGOLM_ROTATION_AGE = 60 * 60 * 24 * 7 # Bump this whenever the custom outbound MegOLM/session serialization or # interoperability-critical behavior changes. Older cached sessions are then # treated as incompatible and rotated automatically. MATRIX_MEGOLM_STORE_VERSION = 1 # ----------------------------------------------------------------------- # Private helpers # ----------------------------------------------------------------------- def _b64enc(data): """Matrix-style unpadded base64 of *data* as ASCII.""" return base64.b64encode(data).rstrip(b"=").decode("utf-8") def _b64dec(s): """Decode a base64 string; tolerates missing padding and URL-safe chars.""" s = s.replace("-", "+").replace("_", "/") pad = len(s) % 4 if pad: s += "=" * (4 - pad) return base64.b64decode(s) def _hmac_sha256(key, data): """32-byte HMAC-SHA-256 of *data* keyed by *key*.""" h = _hmac_mod.HMAC(key, hashes.SHA256(), backend=default_backend()) h.update(data) return h.finalize() def _hkdf_sha256(ikm, length, salt, info): """HKDF-SHA-256. *salt* may be ``None`` or explicit ``bytes``.""" return HKDF( algorithm=hashes.SHA256(), length=length, salt=salt, info=info, backend=default_backend(), ).derive(ikm) def _aes_cbc_encrypt(key, iv, plaintext): """AES-256-CBC-encrypt *plaintext* with PKCS#7 padding.""" padder = _pad_mod.PKCS7(128).padder() padded = padder.update(plaintext) + padder.finalize() cipher = Cipher( algorithms.AES(key), modes.CBC(iv), backend=default_backend(), ) enc = cipher.encryptor() return enc.update(padded) + enc.finalize() def _varint(n): """Encode *n* as a protobuf-style base-128 varint.""" if n == 0: return b"\x00" out = [] while n: byte = n & 0x7F n >>= 7 if n: byte |= 0x80 out.append(byte) return bytes(out) def _pb_bytes(field_num, data): """Protobuf wire-type 2 (length-delimited bytes) field.""" tag = _varint((field_num << 3) | 2) return tag + _varint(len(data)) + data def _pb_varint_field(field_num, value): """Protobuf wire-type 0 (varint) field.""" tag = _varint((field_num << 3) | 0) return tag + _varint(value) def _canonical_json(obj): """UTF-8 canonical JSON (sorted keys, no spaces) for signing.""" return dumps( obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"), ).encode("utf-8") def _verify_ed25519(public_key_b64, message_bytes, signature_b64): """Verify an Ed25519 signature. Returns ``True`` when *signature_b64* is a valid signature of *message_bytes* under *public_key_b64*. Returns ``False`` on any error (wrong key, bad signature, decode failure, etc.). Requires ``MATRIX_E2EE_SUPPORT`` (the ``cryptography`` package). """ try: pub = Ed25519PublicKey.from_public_bytes(_b64dec(public_key_b64)) pub.verify(_b64dec(signature_b64), message_bytes) return True except Exception: return False def verify_device_keys(dev_info, user_id, device_id): """Verify the Ed25519 self-signature on a /keys/query device object. Per the spec, the signed payload must carry ``user_id`` and ``device_id`` fields whose values match the identity being verified. This prevents a malicious homeserver from substituting keys from one device into the record of another. Returns ``True`` only when all of the following hold: - ``dev_info["user_id"] == user_id`` - ``dev_info["device_id"] == device_id`` - The Ed25519 self-signature over the canonical payload is valid. """ # Identity binding: payload fields must match who we think we're # verifying. Without this a server could swap key objects across # users/devices and the signature would still verify. if dev_info.get("user_id") != user_id: return False if dev_info.get("device_id") != device_id: return False sig_key = "ed25519:{}".format(device_id) ed25519_pub = dev_info.get("keys", {}).get(sig_key, "") if not ed25519_pub: return False sig = dev_info.get("signatures", {}).get(user_id, {}).get(sig_key, "") if not sig: return False # Signed payload: all fields except 'signatures' and 'unsigned' signed_obj = { k: v for k, v in dev_info.items() if k not in ("signatures", "unsigned") } return _verify_ed25519(ed25519_pub, _canonical_json(signed_obj), sig) def verify_signed_otk(otk_obj, user_id, device_id, ed25519_pub_b64): """Verify the Ed25519 signature on a ``signed_curve25519`` OTK. The device signs the OTK object (excluding ``signatures``) with the same Ed25519 key published in its device keys. Returns ``True`` only when the signature is present and valid. """ sig = ( otk_obj.get("signatures", {}) .get(user_id, {}) .get("ed25519:{}".format(device_id), "") ) if not sig: return False signed_obj = {k: v for k, v in otk_obj.items() if k != "signatures"} return _verify_ed25519(ed25519_pub_b64, _canonical_json(signed_obj), sig) def encrypt_attachment(data): """Encrypt *data* bytes for upload to a Matrix E2EE room. Implements the Matrix attachment encryption spec (v2): https://spec.matrix.org/v1.11/client-server-api/#sending-encrypted-attachments Algorithm: AES-256-CTR. IV: 8 random bytes followed by 8 zero bytes (avoids counter wrap). Returns a ``(ciphertext, file_info)`` tuple where *file_info* is the ``EncryptedFile`` object to embed in the ``m.room.message`` event: .. code-block:: json { "v": "v2", "key": { "kty": "oct", "alg": "A256CTR", "k": "", "key_ops": ["encrypt", "decrypt"], "ext": true }, "iv": "", "hashes": { "sha256": "" } } """ key = os.urandom(32) # IV: 8 random bytes + 8 zero bytes (spec requirement) iv = os.urandom(8) + b"\x00" * 8 cipher = Cipher( algorithms.AES(key), modes.CTR(iv), backend=default_backend(), ) enc = cipher.encryptor() ciphertext = enc.update(data) + enc.finalize() # SHA-256 of the ciphertext (for integrity verification by recipients) h = hashes.Hash(hashes.SHA256(), backend=default_backend()) h.update(ciphertext) sha256_digest = h.finalize() # JWK key: base64url no-padding k_b64url = base64.urlsafe_b64encode(key).rstrip(b"=").decode() # IV: base64url no-padding (spec uses unpadded base64) iv_b64url = base64.urlsafe_b64encode(iv).rstrip(b"=").decode() # SHA-256 hash: standard base64 no-padding sha256_b64 = base64.b64encode(sha256_digest).rstrip(b"=").decode() file_info = { "v": "v2", "key": { "kty": "oct", "key_ops": ["encrypt", "decrypt"], "alg": "A256CTR", "k": k_b64url, "ext": True, }, "iv": iv_b64url, "hashes": {"sha256": sha256_b64}, } return ciphertext, file_info # ----------------------------------------------------------------------- # MatrixOlmAccount # ----------------------------------------------------------------------- class MatrixOlmAccount: """Device-level Curve25519 + Ed25519 key pair. Generates a new key pair on first use and persists it via ``to_dict()`` / ``from_dict()``. Also creates outbound Olm sessions used to distribute MegOLM room keys to other devices. Reference: Olm spec, Section 2 ("Keys"). """ def __init__( self, ik_priv_b64=None, sk_priv_b64=None, otks=None, fallback_otk=None, ): """Initialise from saved keys or generate a fresh key pair. Parameters are the base64-encoded raw 32-byte private key bytes for the Curve25519 identity key (*ik*) and Ed25519 signing key (*sk*). Supply both or neither. """ if ik_priv_b64 and sk_priv_b64: self._ik = X25519PrivateKey.from_private_bytes( _b64dec(ik_priv_b64) ) self._sk = Ed25519PrivateKey.from_private_bytes( _b64dec(sk_priv_b64) ) else: self._ik = X25519PrivateKey.generate() self._sk = Ed25519PrivateKey.generate() # Cache public-key bytes for efficiency self._ik_pub = self._ik.public_key().public_bytes( Encoding.Raw, PublicFormat.Raw ) self._sk_pub = self._sk.public_key().public_bytes( Encoding.Raw, PublicFormat.Raw ) self._otks = dict(otks or {}) self._fallback_otk = fallback_otk # --- Public-key properties ------------------------------------------- @property def identity_key(self): """Base64-encoded Curve25519 public identity key.""" return _b64enc(self._ik_pub) @property def signing_key(self): """Base64-encoded Ed25519 public signing key.""" return _b64enc(self._sk_pub) # --- Signing --------------------------------------------------------- def sign(self, data): """Ed25519-sign *data* (bytes or str) and return base64.""" if isinstance(data, str): data = data.encode("utf-8") return _b64enc(self._sk.sign(data)) # --- Serialisation --------------------------------------------------- def to_dict(self): """Export private keys for persistent storage.""" return { "ik": _b64enc( self._ik.private_bytes( Encoding.Raw, PrivateFormat.Raw, NoEncryption() ) ), "sk": _b64enc( self._sk.private_bytes( Encoding.Raw, PrivateFormat.Raw, NoEncryption() ) ), "otks": self._otks, "fallback_otk": self._fallback_otk, } @staticmethod def from_dict(data): """Restore from a ``to_dict()`` snapshot.""" return MatrixOlmAccount( ik_priv_b64=data["ik"], sk_priv_b64=data["sk"], otks=data.get("otks"), fallback_otk=data.get("fallback_otk"), ) # --- Key-upload payload ---------------------------------------------- def device_keys_payload(self, user_id, device_id): """Build the signed ``device_keys`` object for ``POST /keys/upload``. Reference: https://spec.matrix.org/v1.11/client-server-api/ #post_matrixclientv3keysupload """ device_keys = { "algorithms": [ "m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2", ], "device_id": device_id, "keys": { "curve25519:{}".format(device_id): self.identity_key, "ed25519:{}".format(device_id): self.signing_key, }, "user_id": user_id, } sig = self.sign(_canonical_json(device_keys)) device_keys["signatures"] = { user_id: {"ed25519:{}".format(device_id): sig} } return device_keys def _signed_curve25519_key(self, user_id, device_id, key_b64): """Wrap a Curve25519 key in a signed KeyObject.""" payload = {"key": key_b64} payload["signatures"] = { user_id: { "ed25519:{}".format(device_id): self.sign( _canonical_json(payload) ) } } return payload def _ensure_otks(self, count=10): """Ensure at least *count* signed_curve25519 one-time keys exist.""" while len(self._otks) < count: key_id = uuid.uuid4().hex[:10] priv = X25519PrivateKey.generate() self._otks[key_id] = _b64enc( priv.private_bytes( Encoding.Raw, PrivateFormat.Raw, NoEncryption() ) ) def one_time_keys_payload(self, user_id, device_id, count=10): """Build signed ``one_time_keys`` for ``POST /keys/upload``.""" self._ensure_otks(count=count) payload = {} for key_id, priv_b64 in self._otks.items(): priv = X25519PrivateKey.from_private_bytes(_b64dec(priv_b64)) pub = priv.public_key().public_bytes( Encoding.Raw, PublicFormat.Raw ) payload["signed_curve25519:{}".format(key_id)] = ( self._signed_curve25519_key(user_id, device_id, _b64enc(pub)) ) return payload def fallback_keys_payload(self, user_id, device_id): """Build signed ``fallback_keys`` for ``POST /keys/upload``.""" if not self._fallback_otk: key_id = uuid.uuid4().hex[:10] priv = X25519PrivateKey.generate() self._fallback_otk = { "id": key_id, "sk": _b64enc( priv.private_bytes( Encoding.Raw, PrivateFormat.Raw, NoEncryption() ) ), } priv = X25519PrivateKey.from_private_bytes( _b64dec(self._fallback_otk["sk"]) ) pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) key_id = self._fallback_otk["id"] return { "signed_curve25519:{}".format(key_id): self._signed_curve25519_key( user_id, device_id, _b64enc(pub) ) } def mark_keys_as_published(self): """Mark the current OTK batch as published. This mirrors stable python-olm's ``Account.mark_keys_as_published()``: the uploaded one-time keys are no longer treated as the next unpublished batch, so a subsequent upload can generate a fresh set. """ self._otks.clear() # --- Outbound session ------------------------------------------------ def create_outbound_session( self, their_identity_key_b64, their_one_time_key_b64 ): """Create an outbound Olm session to a remote device. Performs the X3DH triple-DH key exchange and returns a :class:`MatrixOlmSession` ready to encrypt the first message. Parameters: their_identity_key_b64 - recipient's base64 Curve25519 pub key their_one_time_key_b64 - recipient's base64 Curve25519 OTK Reference: Olm spec, Section 4.1 ("Session establishment"). """ their_ik = X25519PublicKey.from_public_bytes( _b64dec(their_identity_key_b64) ) their_otk = X25519PublicKey.from_public_bytes( _b64dec(their_one_time_key_b64) ) # E_A is Alice's ephemeral key. It serves BOTH as the Base-Key # (outer pre-key field 2) AND as the initial Ratchet-Key (inner # field 1). The Olm spec Section 5.1 is explicit: # "E_A^pub is also the ratchet key for the first message." # libolm passes the same keypair to both the X3DH and the ratchet # initialisation (ratchet.cpp: initialise_as_alice receives base_key # and uses it as the initial sender ratchet key). Using two # different keys here breaks decryption. eph = X25519PrivateKey.generate() eph_pub = eph.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) # Triple DH (Olm spec, Section 4.1) # DH1 = X25519(IK_A, OTK_B) # DH2 = X25519(E_A, IK_B) # DH3 = X25519(E_A, OTK_B) dh1 = self._ik.exchange(their_otk) dh2 = eph.exchange(their_ik) dh3 = eph.exchange(their_otk) # Root-key derivation (libolm ratchet.cpp initialise_as_alice / # vodozemac shared_secret.rs Shared3DHSecret::expand): # IKM = DH1 || DH2 || DH3 (96 bytes β€” no zero prefix) # salt = nullptr / 0x00*32 (RFC 5869: missing salt = HashLen zeros) # info = "OLM_ROOT" # # libolm passes the 96-byte secret directly # (session.cpp: secret[3 * CURVE25519_SHARED_SECRET_LENGTH]). # vodozemac does the same (Shared3DHSecret is Box<[u8; 96]>). # Adding any prefix produces a different PRK and therefore # different root/chain keys, causing the recipient to fail to # decrypt the Olm pre-key message that carries the MegOLM room key. ikm = dh1 + dh2 + dh3 keys = _hkdf_sha256(ikm, 64, salt=None, info=b"OLM_ROOT") root_key = keys[:32] chain_key = keys[32:] return MatrixOlmSession( our_ik_pub=self._ik_pub, eph_pub=eph_pub, their_otk_pub=_b64dec(their_one_time_key_b64), their_ik_pub=_b64dec(their_identity_key_b64), root_key=root_key, chain_key=chain_key, ) # ----------------------------------------------------------------------- # MatrixOlmSession # ----------------------------------------------------------------------- class MatrixOlmSession: """Single-use outbound Olm session (type-0 pre-key messages only). Sufficient for delivering the MegOLM room-key to one recipient device. Each call to :meth:`encrypt` advances the chain ratchet once. Reference: Olm spec, Section 5 ("Message format"). """ def __init__( self, our_ik_pub, eph_pub, their_otk_pub, their_ik_pub, root_key, chain_key, ): self._our_ik_pub = our_ik_pub # eph_pub is Alice's ephemeral key E_A. Per Olm spec Section 5.1, # E_A^pub is used in BOTH the outer pre-key Base-Key field AND the # inner normal-message Ratchet-Key field. They must be identical. self._eph_pub = eph_pub self._their_otk_pub = their_otk_pub self._their_ik_pub = their_ik_pub self._root_key = root_key self._chain_key = chain_key self._counter = 0 @property def their_identity_key(self): """Base64-encoded Curve25519 identity key of the remote device.""" return _b64enc(self._their_ik_pub) def encrypt(self, plaintext): """Encrypt *plaintext* (str) as an Olm pre-key (type-0) message. Returns ``{"type": 0, "body": ""}`` suitable for inclusion in the ``ciphertext`` object of an ``m.olm.v1.curve25519-aes-sha2`` event. Reference: Olm spec, Section 5.1. """ if isinstance(plaintext, str): plaintext = plaintext.encode("utf-8") # -- Chain ratchet (Olm spec Section 6.1) ----------------- msg_key = _hmac_sha256(self._chain_key, b"\x01") self._chain_key = _hmac_sha256(self._chain_key, b"\x02") # -- Expand msg_key -> AES key / MAC key / IV ----------- # HKDF(msg_key, 80, salt=0x00*32, info="OLM_KEYS") keys = _hkdf_sha256(msg_key, 80, salt=b"\x00" * 32, info=b"OLM_KEYS") aes_key = keys[:32] mac_key = keys[32:64] iv = keys[64:80] # -- AES-256-CBC ------------------------------------------ ciphertext = _aes_cbc_encrypt(aes_key, iv, plaintext) # -- Inner message (version | fields | MAC) --------------- # Field numbers from the Olm spec wire format: # 0x0A = field 1, wire-type 2 (bytes) -> Ratchet-Key # 0x10 = field 2, wire-type 0 (varint) -> Chain-Index # 0x22 = field 4, wire-type 2 (bytes) -> Cipher-Text # Note: there is no field 3 in the normal-message format; the # ciphertext is field 4 (tag 0x22), NOT field 3 (tag 0x1A). # E_A^pub appears in BOTH the inner Ratchet-Key (field 1) and the # outer Base-Key (field 2) -- same bytes, same key, per spec. inner = ( b"\x03" + _pb_bytes(1, self._eph_pub) + _pb_varint_field(2, self._counter) + _pb_bytes(4, ciphertext) ) inner_mac = _hmac_sha256(mac_key, inner)[:8] # -- Outer pre-key message -------------------------------- # Field numbers from the Olm spec wire format: # 0x0A = field 1 (bytes) -> One-Time-Key (Bob's OTK being consumed) # 0x12 = field 2 (bytes) -> Base-Key (Alice's E_A; same key as the # first Ratchet-Key) # 0x1A = field 3 (bytes) -> Identity-Key (Alice's identity key) # 0x22 = field 4 (bytes) -> Message (inner message + inner MAC) # # The outer pre-key message has NO trailing MAC of its own. # libolm session.cpp allocates exactly # encode_one_time_key_message_length() bytes β€” no extra space for an # outer MAC. vodozemac decodes the outer payload with prost (strict # protobuf) so extra bytes after the last field cause a DecodeError # and the session fails to establish. outer = ( b"\x03" + _pb_bytes(1, self._their_otk_pub) + _pb_bytes(2, self._eph_pub) + _pb_bytes(3, self._our_ik_pub) + _pb_bytes(4, inner + inner_mac) ) self._counter += 1 return {"type": 0, "body": _b64enc(outer)} # ----------------------------------------------------------------------- # MatrixMegOlmSession # ----------------------------------------------------------------------- class MatrixMegOlmSession: """Outbound MegOLM session for room-message encryption. State: a 4-component 256-bit ratchet R[0..3], a 32-bit counter, and a per-session Ed25519 signing key. The ratchet advances after every encrypted message. See :data:`MEGOLM_ROTATION_MSGS` and :data:`MEGOLM_ROTATION_AGE` for rotation thresholds. Reference: MegOLM spec. """ def __init__( self, ratchet=None, counter=0, sk_priv_b64=None, created_at=None, ): """New session (random state) or restore from ``to_dict()``.""" self._ratchet = ( [os.urandom(32) for _ in range(4)] if ratchet is None else [bytes(r) for r in ratchet] ) self._counter = counter if sk_priv_b64 is None: self._sk = Ed25519PrivateKey.generate() else: self._sk = Ed25519PrivateKey.from_private_bytes( _b64dec(sk_priv_b64) ) self._sk_pub = self._sk.public_key().public_bytes( Encoding.Raw, PublicFormat.Raw ) # Session ID is the base64 of the Ed25519 signing public key self.session_id = _b64enc(self._sk_pub) self.created_at = ( created_at if created_at is not None else _time.time() ) # --- Ratchet ---------------------------------------------------------- def _advance(self): """Advance the MegOLM ratchet by one step. Mirrors libolm megolm.c ``megolm_advance`` and vodozemac ratchet.rs ``Ratchet::advance``. The ratchet has 4 parts R[0..3]. On each step, determine the highest-index part h that stays constant: - counter+1 is a multiple of 2^24 β†’ h=0 (advance R[0..3] from R[0]) - counter+1 is a multiple of 2^16 β†’ h=1 (advance R[1..3] from R[1]) - counter+1 is a multiple of 2^8 β†’ h=2 (advance R[2..3] from R[2]) - otherwise β†’ h=3 (advance R[3] from R[3]) All derived parts are computed from the ORIGINAL value of R[h] (saved before any modification), then R[h] itself is updated last. This matches libolm's loop which processes higher indices first (i = 3 down to h), ensuring data[h] is still the original when it is finally overwritten at i==h. """ r = self._ratchet n1 = self._counter + 1 # next counter value if n1 % (1 << 24) == 0: # h=0: all four parts derived from original R[0] orig = r[0] r[3] = _hmac_sha256(orig, b"\x03") r[2] = _hmac_sha256(orig, b"\x02") r[1] = _hmac_sha256(orig, b"\x01") r[0] = _hmac_sha256(orig, b"\x00") elif n1 % (1 << 16) == 0: # h=1: R[1..3] derived from original R[1] orig = r[1] r[3] = _hmac_sha256(orig, b"\x03") r[2] = _hmac_sha256(orig, b"\x02") r[1] = _hmac_sha256(orig, b"\x01") elif n1 % (1 << 8) == 0: # h=2: R[2..3] derived from original R[2] orig = r[2] r[3] = _hmac_sha256(orig, b"\x03") r[2] = _hmac_sha256(orig, b"\x02") else: # h=3: R[3] re-seeded from itself r[3] = _hmac_sha256(r[3], b"\x03") self._counter += 1 def _message_keys(self): """Derive (aes_key, mac_key, iv) from the full ratchet state R_i. Per the MegOLM spec Section 4.3 and vodozemac (cipher/key.rs ``new_megolm``), the HKDF IKM is the complete 128-byte ratchet value R_i = R[0]||R[1]||R[2]||R[3]. Using only R[3] (32 bytes) produces different keys from what any standard client derives. Spec: AES_KEY||HMAC_KEY||AES_IV = HKDF(0, R_i, "MEGOLM_KEYS", 80) """ keys = _hkdf_sha256( b"".join(self._ratchet), 80, salt=None, info=b"MEGOLM_KEYS" ) return keys[:32], keys[32:64], keys[64:80] # --- Rotation -------------------------------------------------------- def should_rotate(self, msg_count=None): """Return ``True`` if this session has reached a rotation threshold.""" if msg_count is None: msg_count = self._counter if msg_count >= MEGOLM_ROTATION_MSGS: return True return (_time.time() - self.created_at) >= MEGOLM_ROTATION_AGE # --- Encryption ------------------------------------------------------ def encrypt(self, payload_dict): """Encrypt *payload_dict* and return base64 MegOLM ciphertext. Wire format (MegOLM spec, Section 4): version (1 B = 0x03) | Protobuf body (field 8: message_index varint, | field 9: ciphertext bytes) | HMAC-SHA-256 (8 B) | Ed25519 sig (64 B) Reference: MegOLM spec, Section 4. """ plaintext = dumps(payload_dict).encode("utf-8") aes_key, mac_key, iv = self._message_keys() ct_bytes = _aes_cbc_encrypt(aes_key, iv, plaintext) # MegOLM spec wire format (libolm message.cpp): # GROUP_MESSAGE_INDEX_TAG = 0x08 (field 1, wire-type 0 varint) # GROUP_CIPHERTEXT_TAG = 0x12 (field 2, wire-type 2 bytes) pb_body = _pb_varint_field(1, self._counter) + _pb_bytes(2, ct_bytes) body = b"\x03" + pb_body mac = _hmac_sha256(mac_key, body)[:8] sig = self._sk.sign(body + mac) self._advance() return _b64enc(body + mac + sig) # --- Session-key export (shared via Olm to room members) ------------ def session_key(self): """Base64 MegOLM session key for sharing in ``m.room_key`` events. Wire format (libolm outbound_group_session.c, ``olm_outbound_group_session_key``): version (1 B = 0x02) | counter (4 B big-endian) | R[0..3] (128 B) | Ed25519 signing pub key (32 B) | Ed25519 signature (64 B) over all preceding 165 bytes The signature lets the recipient verify the session key came from the device that owns the Ed25519 signing key published in /keys/upload. Without it, vodozemac and other clients reject the key. Reference: MegOLM spec, Section 2; libolm ``outbound_group_session.c``. """ payload = ( b"\x02" + struct.pack(">I", self._counter) + b"".join(self._ratchet) + self._sk_pub ) sig = self._sk.sign(payload) return _b64enc(payload + sig) # --- Serialisation --------------------------------------------------- def to_dict(self): """Export session state for persistent storage.""" return { "version": MATRIX_MEGOLM_STORE_VERSION, "ratchet": [_b64enc(r) for r in self._ratchet], "counter": self._counter, "sk": _b64enc( self._sk.private_bytes( Encoding.Raw, PrivateFormat.Raw, NoEncryption() ) ), "session_id": self.session_id, "created_at": self.created_at, } @staticmethod def from_dict(data): """Restore from a ``to_dict()`` snapshot.""" if data.get("version") != MATRIX_MEGOLM_STORE_VERSION: raise ValueError("Incompatible MegOLM session cache format") return MatrixMegOlmSession( ratchet=[_b64dec(r) for r in data["ratchet"]], counter=data["counter"], sk_priv_b64=data["sk"], created_at=data.get("created_at"), ) apprise-1.10.0/apprise/plugins/mattermost.py000066400000000000000000000760331517341665700211610ustar00rootroot00000000000000# 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. """ Mattermost Notifications. This plugin supports 2 modes of operation: 1. Webhook mode (default): - Uses Mattermost Incoming Webhooks: /hooks/ - Targets are channel names (for example: '#support' or 'support') - If no targets are specified, Mattermost uses the webhook default 2. Bot mode (mode=bot): - Uses Mattermost REST API: /api/v4/posts - Requires a Bot (or User) Access Token (Bearer token) - Targets are channel_id values by default - Channel name resolution is supported when a team is known """ from __future__ import annotations import io from itertools import chain # Create an incoming webhook; the website will provide you with something like: # http://localhost:8065/hooks/yobjmukpaw3r3urc5h6i369yima # ^^^^^^^^^^^^^^^^^^^^^^^^^^^ # |-- this is the webhook --| # # You can effectively turn the url above to read this: # mmost://localhost:8065/yobjmukpaw3r3urc5h6i369yima # - swap http with mmost # - drop /hooks/ reference from json import dumps, loads import re from typing import Any import requests from ..common import NotifyImageSize, NotifyType, PersistentStoreMode from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, parse_list, validate_regex from .base import NotifyBase # Some Reference Locations: # - https://docs.mattermost.com/developer/webhooks-incoming.html # - https://docs.mattermost.com/administration/config-settings.html IS_CHANNEL = re.compile(r"^(#|%23)(?P[A-Za-z0-9_-]+)$") IS_CHANNEL_ID = re.compile(r"^(\+|%2B)?(?P[A-Za-z0-9_-]+)$") class MattermostMode: """Supported Mattermost integration modes.""" # Incoming webhook mode WEBHOOK = "webhook" # Bot API mode BOT = "bot" # Define our Mattermost Modes MATTERMOST_MODES = ( MattermostMode.WEBHOOK, MattermostMode.BOT, ) class NotifyMattermost(NotifyBase): """A wrapper for Mattermost Notifications.""" # The default descriptive name associated with the Notification service_name = "Mattermost" # The services URL service_url = "https://mattermost.com/" # The default protocol protocol = "mmost" # The default secure protocol secure_protocol = "mmosts" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/mattermost/" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # The maximum allowable characters allowed in the body per message body_maxlen = 4000 # Mattermost does not have a title title_maxlen = 0 # Attachment support defaults True; toggled off for webhook mode attachment_support = True # Allow persistent caching of bot channel lookups storage_mode = PersistentStoreMode.AUTO # Keep our cache for 20 days default_cache_expiry_sec = 60 * 60 * 24 * 20 # Lower rate req since service is self hosted in most # circumstances request_rate_per_sec = 0.02 templates = ( "{schema}://{host}/{token}", "{schema}://{host}:{port}/{token}", "{schema}://{host}/{fullpath}/{token}", "{schema}://{host}:{port}/{fullpath}/{token}", "{schema}://{user}@{host}/{token}", "{schema}://{user}@{host}:{port}/{token}", "{schema}://{user}@{host}/{fullpath}/{token}", "{schema}://{user}@{host}:{port}/{fullpath}/{token}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User"), "type": "string", }, "host": { "name": _("Hostname"), "type": "string", "required": True, }, "token": { # Webhook Token (webhook mode) OR Access Token (bot mode) "name": _("Token"), "type": "string", "private": True, "required": True, }, "fullpath": { "name": _("Path"), "type": "string", }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "target_channel": { "name": _("Target Channel"), "type": "string", "prefix": "#", "map_to": "targets", }, "target_channel_id": { "name": _("Target Channel ID"), "type": "string", "prefix": "", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "channel": { # Backwards compatible "alias_of": "targets", }, "channels": { # Backwards compatible "alias_of": "targets", }, "icon_url": { "name": _("Icon URL"), "type": "string", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "team": { "alias_of": "user", }, "botname": { "alias_of": "user", }, "mode": { "name": _("Mode"), "type": "choice:string", "values": MATTERMOST_MODES, "default": MATTERMOST_MODES[0], }, "to": { "alias_of": "targets", }, }, ) def __init__( self, token: str, fullpath: str | None = None, targets: list[str] | str | None = None, include_image: bool = False, icon_url: str | None = None, mode: str | None = None, **kwargs: Any, ) -> None: """Initialize Mattermost object.""" super().__init__(**kwargs) self.schema = "https" if self.secure else "http" self.fullpath = ( "" if not isinstance(fullpath, str) else fullpath.strip() ) # Mode if isinstance(mode, str) and mode.strip(): mode_ = mode.strip().lower() self.mode = next( (m for m in MATTERMOST_MODES if m.startswith(mode_)), None ) if self.mode not in MATTERMOST_MODES: msg = f"The Mattermost mode specified ({mode}) is invalid." self.logger.warning(msg) raise TypeError(msg) else: self.mode = self.template_args["mode"]["default"] # Enable attachment support only in bot mode self.attachment_support = self.mode == MattermostMode.BOT # Token (webhook token in webhook mode, bearer token in bot mode) self.token = validate_regex(token) if not self.token: msg = f"An invalid Mattermost Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # Used for URL generation afterwards only self._invalid_targets = [] # Channels: self.targets = [] for target in parse_list(targets): result = IS_CHANNEL.match(target) if result: if self.mode == MattermostMode.BOT and not self.user: # No team was defined and we're in BOT mode self.logger.warning( "Mattermost bot mode requires a team to resolve " "%s, dropping it.", target, ) self._invalid_targets.append(target) continue # store valid channel self.targets.append(("#", result.group("name"))) continue result = IS_CHANNEL_ID.match(target) if result: if self.mode == MattermostMode.WEBHOOK: # store valid channel self.targets.append(("#", result.group("name"))) else: # MattermostMode.BOT # store valid channel_id self.targets.append(("+", result.group("name"))) continue self.logger.warning( "Dropping invalid Mattermost target %s", target, ) self._invalid_targets.append(target) # Webhook mode features (ignored in bot mode) self.include_image = include_image # Support a user-provided icon URL self.icon_url = icon_url def __len__(self) -> int: """Returns the number of outbound HTTP requests expected.""" return max(1, len(self.targets)) def _channel_lookup(self, channel: str) -> str | None: """ Resolve a channel name to a channel_id. Resolution occurs only during send(); results are persistently cached. """ # Attempt to pull from Persistent Storage if available key = f"c:{channel}" cached = self.store.get(key) if cached: return cached port = "" if self.port is None else f":{self.port}" team = NotifyMattermost.quote(self.user, safe="") name = NotifyMattermost.quote(channel, safe="") headers: dict[str, str] = { "User-Agent": self.app_id, "Accept": "application/json", "Authorization": f"Bearer {self.token}", } url = "{}://{}{}{}/api/v4/teams/name/{}/channels/name/{}".format( self.schema, self.host, port, self.fullpath.rstrip("/"), team, name, ) self.logger.debug( "Mattermost channel lookup URL: %s (cert_verify=%r)", url, self.verify_certificate, ) self.throttle() try: r = requests.get( url, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: status = self.http_response_code_lookup(r.status_code) self.logger.warning( "Mattermost channel lookup failed for %s: %s, error=%d.", channel, status, r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return None try: data = loads(r.content.decode("utf-8")) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None self.logger.debug( "Mattermost channel lookup response was not JSON:\r\n%r", (r.content or b"")[:2000], ) return None channel_id = data.get("id") if not isinstance(channel_id, str) or not channel_id.strip(): return None self.store.set( key, channel_id, expires=self.default_cache_expiry_sec, ) return channel_id except requests.RequestException as e: self.logger.warning( "A Connection error occurred performing Mattermost channel " "lookup for %s.", channel, ) self.logger.debug("Socket Exception: %s", e) return None def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, attach=None, **kwargs: Any, ) -> bool: """Perform Mattermost Notification.""" if self.mode == MattermostMode.BOT and not self.targets: self.logger.warning( "Mattermost BOT mode has no valid channels to notify, " "aborting." ) return False # Initialize our error tracking has_error = False # Prepare our port reference in advance port = "" if self.port is None else f":{self.port}" headers: dict[str, str] = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Pre-read attachment content before the target loop so the same # bytes can be sent to every target via a fresh io.BytesIO wrapper. # AttachMemory streams close after the first read and cannot be # re-opened; reading everything upfront avoids that problem. attach_data: list[tuple[str, bytes, str]] = [] if self.mode == MattermostMode.BOT: url = "{}://{}{}{}/api/v4/posts".format( self.schema, self.host, port, self.fullpath.rstrip("/") ) # URL used to upload file attachments before posting upload_url = "{}://{}{}{}/api/v4/files".format( self.schema, self.host, port, self.fullpath.rstrip("/") ) # Append headers headers["Authorization"] = f"Bearer {self.token}" expected = (requests.codes.created, requests.codes.ok) if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): if not attachment: self.logger.error( "Could not access Mattermost attachment %s.", attachment.url(privacy=True), ) return False # Capture name and mimetype before open() so that # AttachMemory's exists() check (triggered by these # properties) does not fail on a closed stream. fname = attachment.name or f"file{no:03}.dat" mimetype = attachment.mimetype self.logger.debug( "Reading Mattermost attachment %s", attachment.url(privacy=True), ) try: with attachment.open() as fp: content = fp.read() except (OSError, ValueError) as e: self.logger.warning( "An I/O error occurred reading" " Mattermost attachment %s.", fname, ) self.logger.debug("I/O Exception: %s", e) return False attach_data.append((fname, content, mimetype)) else: # self.mode == MattermostMode.WEBHOOK url = "{}://{}{}{}/hooks/{}".format( self.schema, self.host, port, self.fullpath.rstrip("/"), self.token, ) expected = (requests.codes.ok,) # Iterate over our targets targets = self.targets.copy() if self.mode == MattermostMode.WEBHOOK and not targets: targets = [(None, None)] for kind, value in targets: target = value if kind == "#" and self.mode == MattermostMode.BOT: target = self._channel_lookup(value) if not target: has_error = True continue if self.mode == MattermostMode.BOT: # Upload pre-read attachment bytes scoped to this # channel_id. Fresh io.BytesIO wrappers are created for # each target so all targets receive the same content. file_ids: list[str] = [] attach_error = False if attach_data: upload_headers: dict[str, str] = { "User-Agent": self.app_id, "Authorization": f"Bearer {self.token}", } for fname, content, mimetype in attach_data: self.logger.debug( "Posting Mattermost attachment %s", fname, ) try: self.throttle() r = requests.post( upload_url, data={"channel_id": target}, headers=upload_headers, files={ "files": ( fname, io.BytesIO(content), mimetype, ) }, verify=self.verify_certificate, timeout=self.request_timeout, ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred" " uploading Mattermost" " attachment." ) self.logger.debug("Socket Exception: %s", e) attach_error = True break if r.status_code not in ( requests.codes.created, requests.codes.ok, ): status = self.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to upload Mattermost" " attachment %s: %s, error=%d.", fname, status, r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) attach_error = True break try: data = loads(r.content.decode("utf-8")) file_id = data.get("file_infos", [{}])[0].get( "id", "" ) except ( AttributeError, TypeError, ValueError, IndexError, ): self.logger.warning( "Failed to parse Mattermost" " file upload response." ) attach_error = True break if not file_id: self.logger.warning( "Mattermost file upload returned no file ID." ) attach_error = True break file_ids.append(file_id) if attach_error: has_error = True continue payload: dict[str, Any] = { "channel_id": target, "message": body, } if file_ids: payload["file_ids"] = file_ids else: payload: dict[str, Any] = { "text": body, } image_url = self.icon_url if not image_url and self.include_image: image_url = self.image_url(notify_type) if image_url: payload["icon_url"] = image_url payload["username"] = self.user if self.user else self.app_id if target: payload["channel"] = target self.logger.debug( "Mattermost %s POST URL: %s (cert_verify=%r)", self.mode, url, self.verify_certificate, ) self.logger.debug("Mattermost %s Payload: %s", self.mode, payload) self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in expected: status = self.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send Mattermost notification to " "%s: %s, error=%d.", f"channel_id {target}" if self.mode == MattermostMode.BOT else f"channel {target}", status, r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) has_error = True continue self.logger.info( "Sent Mattermost %s notification to %s.", self.mode, target, ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Mattermost " "%s notification to %s.", self.mode, target, ) self.logger.debug("Socket Exception: %s", e) has_error = True continue # Return our overall status return not has_error @property def url_identifier(self) -> tuple[Any, ...]: """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.mode, self.token, self.host, self.port, self.fullpath, self.user if self.mode == MattermostMode.BOT else None, ) def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Returns the URL built dynamically based on specified arguments.""" params: dict[str, Any] = { "image": "yes" if self.include_image else "no", } if self.mode != self.template_args["mode"]["default"]: params["mode"] = self.mode if self.mode == MattermostMode.WEBHOOK and self.icon_url: params["icon_url"] = self.icon_url params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if self.targets: # historically the value only accepted one channel and is # therefore identified as 'channel'. Channels have always been # optional, so that is why this setting is nested in an if block entries = [] for kind, value in self.targets: if kind == "#": entries.append(f"#{value}") else: entries.append(f"+{value}") params["to"] = ",".join( chain( [NotifyMattermost.quote(v, safe="#+") for v in entries], [ NotifyMattermost.quote(x, safe="") for x in self._invalid_targets ], ) ) default_port = 443 if self.secure else 80 default_schema = self.secure_protocol if self.secure else self.protocol # Determine if there is a source present source = "" if self.user: source = "{source}@".format( source=NotifyMattermost.quote(self.user, safe=""), ) return ( "{schema}://{source}{hostname}{port}{fullpath}{token}" "/?{params}".format( schema=default_schema, source=source, # 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=( "/" if not self.fullpath else "{}/".format( NotifyMattermost.quote(self.fullpath, safe="/") ) ), token=self.pprint(self.token, privacy, safe=""), params=NotifyMattermost.urlencode(params), ) ) @staticmethod def parse_url(url: str): """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 # Acquire our tokens; the last one will always be our token # all entries before it will be our path tokens = NotifyMattermost.split_path(results["fullpath"]) results["token"] = None if not tokens else tokens.pop() results["fullpath"] = ( "" if not tokens else "/{}".format("/".join(tokens)) ) # Define our optional list of channels to notify results["targets"] = [] # Support both 'to' (for yaml configuration) and channel(s)= if "to" in results["qsd"] and len(results["qsd"]["to"]): # Allow the user to specify the channel to post to results["targets"].extend( NotifyMattermost.parse_list(results["qsd"]["to"]) ) if "channel" in results["qsd"] and len(results["qsd"]["channel"]): results["targets"].extend( NotifyMattermost.parse_list(results["qsd"]["channel"]) ) if "channels" in results["qsd"] and len(results["qsd"]["channels"]): # Allow the user to specify the channel to post to results["targets"].extend( NotifyMattermost.parse_list(results["qsd"]["channels"]) ) # Image manipulation results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyMattermost.template_args["image"]["default"] ) ) # Our Mode if "mode" in results["qsd"] and results["qsd"]["mode"]: results["mode"] = NotifyMattermost.unquote(results["qsd"]["mode"]) # Team support (bot mode lookup). This maps to `user`. if "team" in results["qsd"] and results["qsd"]["team"]: results["user"] = NotifyMattermost.unquote(results["qsd"]["team"]) if "mode" not in results: results["mode"] = MattermostMode.BOT elif "botname" in results["qsd"] and results["qsd"]["botname"]: results["user"] = NotifyMattermost.unquote( results["qsd"]["botname"] ) if "icon_url" in results["qsd"]: results["icon_url"] = NotifyMattermost.unquote( results["qsd"]["icon_url"] ) return results @staticmethod def parse_native_url(url: str) -> dict[str, Any] | None: """ Support parsing the webhook straight from URL https://HOST:443/workflows/WORKFLOWID/triggers/manual/paths/invoke https://mattermost.HOST/hooks/TOKEN """ # Match our workflows webhook URL and re-assemble result = re.match( r"^http(?Ps?)://(?Pmattermost\.[A-Z0-9_.-]+)" r"(:(?P[1-9][0-9]{0,5}))?" r"/hooks/" r"(?P[A-Z0-9_-]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: default_port = ( int(result.group("port")) if result.group("port") else (443 if result.group("secure") else 80) ) default_schema = ( NotifyMattermost.secure_protocol if result.group("secure") else NotifyMattermost.protocol ) # Construct our URL return NotifyMattermost.parse_url( "{schema}://{host}{port}/{token}/{params}".format( schema=default_schema, host=result.group("host"), port=( "" if not result.group("port") or int(result.group("port")) == default_port else f":{default_port}" ), token=result.group("token"), params=( "" if not result.group("params") else result.group("params") ), ) ) return None apprise-1.10.0/apprise/plugins/messagebird.py000066400000000000000000000304321517341665700212400ustar00rootroot00000000000000# 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. # Create an account https://messagebird.com if you don't already have one # # Get your (apikey) and api example from the dashboard here: # - https://dashboard.messagebird.com/en/user/index # 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 NotifyMessageBird(NotifyBase): """A wrapper for MessageBird Notifications.""" # The default descriptive name associated with the Notification service_name = "MessageBird" # The services URL service_url = "https://messagebird.com" # The default protocol secure_protocol = "msgbird" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/messagebird/" # MessageBird uses the http protocol with JSON requests notify_url = "https://rest.messagebird.com/messages" # 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}/{source}", "{schema}://{apikey}/{source}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "required": True, "private": True, "regex": (r"^[a-z0-9]{25}$", "i"), }, "source": { "name": _("Source Phone No"), "type": "string", "prefix": "+", "required": True, "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", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, "from": { "alias_of": "source", }, }, ) def __init__(self, apikey, source, targets=None, **kwargs): """Initialize MessageBird 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 MessageBird API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) result = is_phone_no(source) if not result: msg = f"The MessageBird source specified ({source}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Store our source self.source = result["full"] # Parse our targets self.targets = [] targets = parse_phone_no(targets) if not targets: # No sources specified, use our own phone no self.targets.append(self.source) return # otherwise, store all of our target numbers for target in 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 MessageBird Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no MessageBird 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/x-www-form-urlencoded", "Authorization": f"AccessKey {self.apikey}", } # Prepare our payload payload = { "originator": f"+{self.source}", "recipients": None, "body": body, } # Create a copy of the targets list targets = list(self.targets) while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["recipients"] = f"+{target}" # Some Debug Logging self.logger.debug( "MessageBird POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"MessageBird 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, verify=self.verify_certificate, timeout=self.request_timeout, ) # Sample output of a successful transmission # { # "originator": "+15553338888", # "body": "test", # "direction": "mt", # "mclass": 1, # "reference": null, # "createdDatetime": "2019-08-22T01:32:18+00:00", # "recipients": { # "totalCount": 1, # "totalSentCount": 1, # "totalDeliveredCount": 0, # "totalDeliveryFailedCount": 0, # "items": [ # { # "status": "sent", # "statusDatetime": "2019-08-22T01:32:18+00:00", # "recipient": 15553338888, # "messagePartCount": 1 # } # ] # }, # "validity": null, # "gateway": 10, # "typeDetails": {}, # "href": "https://rest.messagebird.com/messages/\ # b5d424244a5b4fd0b5b5728bccaafc23", # "datacoding": "plain", # "scheduledDatetime": null, # "type": "sms", # "id": "b5d424244a5b4fd0b5b5728bccaafc23" # } if r.status_code not in ( requests.codes.ok, requests.codes.created, ): # We had a problem status_str = NotifyMessageBird.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send MessageBird notification to {}: " "{}{}error={}.".format( ",".join(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 MessageBird notification to {target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending" f" MessageBird:{target} " + "notification." ) 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.apikey, self.source) 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) return "{schema}://{apikey}/{source}/{targets}/?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), source=self.source, targets="/".join( [NotifyMessageBird.quote(x, safe="") for x in self.targets] ), params=NotifyMessageBird.urlencode(params), ) 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 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"] = NotifyMessageBird.split_path(results["fullpath"]) try: # The first path entry is the source/originator results["source"] = results["targets"].pop(0) except IndexError: # No path specified... this URL is potentially un-parseable; we can # hope for a from= entry results["source"] = None # The hostname is our authentication key results["apikey"] = NotifyMessageBird.unquote(results["host"]) # 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"] += NotifyMessageBird.parse_phone_no( results["qsd"]["to"] ) if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyMessageBird.unquote( results["qsd"]["from"] ) return results apprise-1.10.0/apprise/plugins/misskey.py000066400000000000000000000235301517341665700204400ustar00rootroot00000000000000# 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. visit https://misskey-hub.net/ and see what it's all about if you want. # Choose a service you want to create an account on from here: # https://misskey-hub.net/en/instances.html # # - For this plugin, I tested using https://misskey.sda1.net and created an # account. # # 2. Generate an API Key: # - Settings > API > Generate Key # - Name it whatever you want # - Assign it 'AT LEAST': # a. Compose or delete chat messages # b. Compose or delete notes # # # This plugin also supports taking the URL (as identified above) directly # as well. from json import dumps import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase class MisskeyVisibility: """The visibility of any note created.""" # post will be public PUBLIC = "public" HOME = "home" FOLLOWERS = "followers" SPECIFIED = "specified" # Define the types in a list for validation purposes MISSKEY_VISIBILITIES = ( MisskeyVisibility.PUBLIC, MisskeyVisibility.HOME, MisskeyVisibility.FOLLOWERS, MisskeyVisibility.SPECIFIED, ) class NotifyMisskey(NotifyBase): """A wrapper for Misskey Notifications.""" # The default descriptive name associated with the Notification service_name = "Misskey" # The services URL service_url = "https://misskey-hub.net/" # The default protocol protocol = "misskey" # The default secure protocol secure_protocol = "misskeys" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/misskey/" # The title is not used title_maxlen = 0 # The maximum allowable characters allowed in the body per message body_maxlen = 512 # Define object templates templates = ("{schema}://{project_id}/{msghook}",) # Define object templates templates = ( "{schema}://{token}@{host}", "{schema}://{token}@{host}:{port}", ) # Define our template arguments # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "token": { "name": _("Access Token"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, "visibility": { "name": _("Visibility"), "type": "choice:string", "values": MISSKEY_VISIBILITIES, "default": MisskeyVisibility.PUBLIC, }, }, ) def __init__(self, token=None, visibility=None, **kwargs): """Initialize Misskey Object.""" super().__init__(**kwargs) self.token = validate_regex(token) if not self.token: msg = "An invalid Misskey Access Token was specified." self.logger.warning(msg) raise TypeError(msg) if visibility: # Input is a string; attempt to get the lookup from our # sound mapping vis = ( "invalid" if not isinstance(visibility, str) else visibility.lower().strip() ) # This little bit of black magic allows us to match against # against multiple versions of the same string ... etc self.visibility = next( (v for v in MISSKEY_VISIBILITIES if v.startswith(vis)), None ) if self.visibility not in MISSKEY_VISIBILITIES: msg = ( f"The Misskey visibility specified ({visibility}) is" " invalid." ) self.logger.warning(msg) raise TypeError(msg) else: self.visibility = self.template_args["visibility"]["default"] # Prepare our URL self.schema = "https" if self.secure else "http" self.api_url = f"{self.schema}://{self.host}" if isinstance(self.port, int): self.api_url += f":{self.port}" 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 if self.secure else self.protocol, self.token, self.host, self.port, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = { "visibility": self.visibility, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) host = self.host if isinstance(self.port, int): host += f":{self.port}" return "{schema}://{token}@{host}/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, host=host, token=self.pprint(self.token, privacy, safe=""), params=NotifyMisskey.urlencode(params), ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Wrapper to _send since we can alert more then one channel.""" # prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Prepare our payload payload = { "i": self.token, "text": body, "visibility": self.visibility, } api_url = f"{self.api_url}/api/notes/create" self.logger.debug( "Misskey GET URL:" f" {api_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Misskey Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( api_url, headers=headers, data=dumps(payload), verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyMisskey.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Misskey 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 Misskey notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Misskey notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @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 if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyMisskey.unquote(results["qsd"]["token"]) elif not results["password"] and results["user"]: results["token"] = NotifyMisskey.unquote(results["user"]) # Capture visibility if specified if "visibility" in results["qsd"] and len( results["qsd"]["visibility"] ): results["visibility"] = NotifyMisskey.unquote( results["qsd"]["visibility"] ) return results apprise-1.10.0/apprise/plugins/mqtt.py000066400000000000000000000524041517341665700177430ustar00rootroot00000000000000# 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. # PAHO MQTT Documentation: # https://www.eclipse.org/paho/index.php?page=clients/python/docs/index.php # # Looking at the PAHO MQTT Source can help shed light on what's going on too # as their inline documentation is pretty good! # https://github.com/eclipse/paho.mqtt.python\ # /blob/master/src/paho/mqtt/client.py from datetime import datetime from os.path import isfile import re import ssl from time import sleep from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool, parse_list from .base import NotifyBase # Default our global support flag NOTIFY_MQTT_SUPPORT_ENABLED = False try: # 3rd party modules import paho.mqtt.client as mqtt # We're good to go! NOTIFY_MQTT_SUPPORT_ENABLED = True MQTT_PROTOCOL_MAP = { # v3.1.1 "311": mqtt.MQTTv311, # v3.1 "31": mqtt.MQTTv31, # v5.0 "5": mqtt.MQTTv5, # v5.0 (alias) "50": mqtt.MQTTv5, } except ImportError: # No problem; we just simply can't support this plugin because we're # either using Linux, or simply do not have pywin32 installed. MQTT_PROTOCOL_MAP = {} # A lookup map for relaying version to user HUMAN_MQTT_PROTOCOL_MAP = { "v3.1.1": "311", "v3.1": "31", "v5.0": "5", } class NotifyMQTT(NotifyBase): """A wrapper for MQTT Notifications.""" # Set our global enabled flag enabled = NOTIFY_MQTT_SUPPORT_ENABLED requirements = { # Define our required packaging in order to work "packages_required": "paho-mqtt != 2.0.*" } # The default descriptive name associated with the Notification service_name = "MQTT Notification" # The default protocol protocol = "mqtt" # Secure protocol secure_protocol = "mqtts" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/mqtt/" # MQTT does not have a title title_maxlen = 0 # The maximum length a body can be set to body_maxlen = 268435455 # Use a throttle; but it doesn't need to be so strict since most # MQTT server hostings can handle the small bursts of packets and are # locally hosted anyway request_rate_per_sec = 0.5 # Port Defaults (unless otherwise specified) mqtt_insecure_port = 1883 # The default secure port to use (if mqtts://) mqtt_secure_port = 8883 # The default mqtt keepalive value mqtt_keepalive = 30 # The default mqtt transport mqtt_transport = "tcp" # The number of seconds to wait for a publish to occur at before # checking to see if it's been sent yet. mqtt_block_time_sec = 0.2 # Set the maximum number of messages with QoS>0 that can be part way # through their network flow at once. mqtt_inflight_messages = 200 # Define object templates templates = ( "{schema}://{user}@{host}/{topic}", "{schema}://{user}@{host}:{port}/{topic}", "{schema}://{user}:{password}@{host}/{topic}", "{schema}://{user}:{password}@{host}:{port}/{topic}", ) template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "topic": { "name": _("Target Queue"), "type": "string", "required": True, "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "qos": { "name": _("QOS"), "type": "int", "default": 0, "min": 0, "max": 2, }, "version": { "name": _("Version"), "type": "choice:string", "values": HUMAN_MQTT_PROTOCOL_MAP, "default": "v3.1.1", }, "client_id": { "name": _("Client ID"), "type": "string", }, "session": { "name": _("Use Session"), "type": "bool", "default": False, }, "retain": { "name": _("Retain Messages"), "type": "bool", "default": False, }, "to": { "alias_of": "targets", }, }, ) def __init__( self, targets=None, version=None, qos=None, client_id=None, session=None, retain=None, **kwargs, ): """Initialize MQTT Object.""" super().__init__(**kwargs) # Initialize topics self.topics = parse_list(targets) if version is None: self.version = self.template_args["version"]["default"] else: self.version = version # Save our client id if specified self.client_id = client_id # Maintain our session (associated with our user id if set) self.session = ( self.template_args["session"]["default"] if session is None or not self.client_id else parse_bool(session) ) # Our Retain Message Flag self.retain = ( self.template_args["retain"]["default"] if retain is None else parse_bool(retain) ) # Set up our Quality of Service (QoS) try: self.qos = ( self.template_args["qos"]["default"] if qos is None else int(qos) ) if ( self.qos < self.template_args["qos"]["min"] or self.qos > self.template_args["qos"]["max"] ): # Let error get handle on exceptio higher up raise ValueError("") except (ValueError, TypeError): msg = f"An invalid MQTT QOS ({qos}) was specified." self.logger.warning(msg) raise TypeError(msg) from None if not self.port: # Assign port (if not otherwise set) self.port = ( self.mqtt_secure_port if self.secure else self.mqtt_insecure_port ) self.ca_certs = None if self.secure: # verify SSL key or abort # TODO: There is no error reporting or aborting here? # It could be useful to inform the user _where_ Apprise # tried to find the root CA certificates file. self.ca_certs = next( ( cert for cert in self.CA_CERTIFICATE_FILE_LOCATIONS if isfile(cert) ), None, ) # Set up our MQTT Publisher try: # Get our protocol self.mqtt_protocol = MQTT_PROTOCOL_MAP[ re.sub(r"[^0-9]+", "", self.version) ] except KeyError: msg = ( f"An invalid MQTT Protocol version ({version}) was specified." ) self.logger.warning(msg) raise TypeError(msg) from None # Our MQTT Client Object self.client = mqtt.Client( client_id=self.client_id, clean_session=not self.session, userdata=None, protocol=self.mqtt_protocol, transport=self.mqtt_transport, ) # Our maximum number of in-flight messages self.client.max_inflight_messages_set(self.mqtt_inflight_messages) # Toggled to False once our connection has been established at least # once self.__initial_connect = True def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform MQTT Notification.""" if len(self.topics) == 0: # There were no services to notify self.logger.warning("There were no MQTT topics to notify.") return False # For logging: url = f"{self.host}:{self.port}" try: if self.__initial_connect: # Our initial connection if self.user: self.client.username_pw_set( self.user, password=self.password ) if self.secure: if self.ca_certs is None: self.logger.error( "MQTT secure communication can not be verified, " "CA certificates file missing" ) return False self.client.tls_set( ca_certs=self.ca_certs, certfile=None, keyfile=None, cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS, ciphers=None, ) # Set our TLS Verify Flag self.client.tls_insecure_set(not self.verify_certificate) # Establish our connection if ( self.client.connect( self.host, port=self.port, keepalive=self.mqtt_keepalive, ) != mqtt.MQTT_ERR_SUCCESS ): self.logger.warning( "An MQTT connection could not be established for" f" {url}" ) return False # Start our client loop self.client.loop_start() # Throttle our start otherwise the starting handshaking doesnt # work. I'm not sure if this is a bug or not, but with qos=0, # and without this sleep(), the messages randomly fails to be # delivered. sleep(0.01) # Toggle our flag since we never need to enter this area again self.__initial_connect = False # Create a copy of the subreddits list topics = list(self.topics) has_error = False while len(topics) > 0 and not has_error: # Retrieve our subreddit topic = topics.pop() # For logging: url = f"{self.host}:{self.port}/{topic}" # Always call throttle before any remote server i/o is made self.throttle() # handle a re-connection if ( not self.client.is_connected() and self.client.reconnect() != mqtt.MQTT_ERR_SUCCESS ): self.logger.warning( f"An MQTT connection could not be sustained for {url}" ) has_error = True break # Some Debug Logging self.logger.debug( "MQTT POST URL:" f" {url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"MQTT Payload: {body!s}") result = self.client.publish( topic, payload=body, qos=self.qos, retain=self.retain ) if result.rc != mqtt.MQTT_ERR_SUCCESS: # Toggle our status self.logger.warning( f"An error (rc={result.rc}) occured when sending MQTT" f" to {url}" ) has_error = True break elif not result.is_published(): self.logger.debug( "Blocking until MQTT payload is published..." ) reference = datetime.now() while not has_error and not result.is_published(): # Throttle sleep(self.mqtt_block_time_sec) # Our own throttle so we can abort eventually.... elapsed = (datetime.now() - reference).total_seconds() if elapsed >= self.socket_read_timeout: self.logger.warning( "The MQTT message could not be delivered" ) has_error = True # if we reach here; we're at the bottom of our loop # we loop around and do the next topic now except ConnectionError as e: self.logger.warning(f"MQTT Connection Error received from {url}") self.logger.debug(f"Socket Exception: {e!s}") return False except ssl.CertificateError as e: self.logger.warning( f"MQTT SSL Certificate Error received from {url}" ) self.logger.debug(f"Socket Exception: {e!s}") return False except ValueError as e: # ValueError's are thrown from publish() call if there is a problem self.logger.warning(f"MQTT Publishing error received: from {url}") self.logger.debug(f"Socket Exception: {e!s}") return False if not has_error: # Verbal notice self.logger.info("Sent MQTT notification") 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 if self.secure else self.protocol, self.user, self.password, self.host, ( self.port if self.port else ( self.mqtt_secure_port if self.secure else self.mqtt_insecure_port ) ), self.fullpath.rstrip("/"), self.client_id, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "version": self.version, "qos": str(self.qos), "session": "yes" if self.session else "no", "retain": "yes" if self.retain else "no", } if self.client_id: # Our client id is set if specified params["client_id"] = self.client_id # 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=NotifyMQTT.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyMQTT.quote(self.user, safe=""), ) default_port = ( self.mqtt_secure_port if self.secure else self.mqtt_insecure_port ) 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( [NotifyMQTT.quote(x, safe="/") for x in self.topics] ), params=NotifyMQTT.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.topics) @staticmethod def parse_url(url): """There are no parameters nessisary for this protocol; simply having windows:// is all you need. This function just makes sure that is in place. """ results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results try: # Acquire topic(s) results["targets"] = parse_list( NotifyMQTT.unquote(results["fullpath"].lstrip("/")) ) except AttributeError: # No 'fullpath' specified results["targets"] = [] # The MQTT protocol version to use if "version" in results["qsd"] and len(results["qsd"]["version"]): results["version"] = NotifyMQTT.unquote(results["qsd"]["version"]) # The MQTT Client ID if "client_id" in results["qsd"] and len(results["qsd"]["client_id"]): results["client_id"] = NotifyMQTT.unquote( results["qsd"]["client_id"] ) if "session" in results["qsd"] and len(results["qsd"]["session"]): results["session"] = parse_bool(results["qsd"]["session"]) # Message Retain Flag if "retain" in results["qsd"] and len(results["qsd"]["retain"]): results["retain"] = parse_bool(results["qsd"]["retain"]) # The MQTT Quality of Service to use if "qos" in results["qsd"] and len(results["qsd"]["qos"]): results["qos"] = NotifyMQTT.unquote(results["qsd"]["qos"]) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].extend( NotifyMQTT.parse_list(results["qsd"]["to"]) ) # return results return results @property def CA_CERTIFICATE_FILE_LOCATIONS(self): """Return possible locations to root certificate authority (CA) bundles. Taken from https://golang.org/src/crypto/x509/root_linux.go TODO: Maybe refactor to a general utility function? """ candidates = [ # Debian/Ubuntu/Gentoo etc. "/etc/ssl/certs/ca-certificates.crt", # Fedora/RHEL 6 "/etc/pki/tls/certs/ca-bundle.crt", # OpenSUSE "/etc/ssl/ca-bundle.pem", # OpenELEC "/etc/pki/tls/cacert.pem", # CentOS/RHEL 7 "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", # macOS Homebrew; brew install ca-certificates "/usr/local/etc/ca-certificates/cert.pem", ] # Certifi provides Mozilla's carefully curated collection of Root # Certificates for validating the trustworthiness of SSL certificates # while verifying the identity of TLS hosts. It has been extracted from # the Requests project. try: import certifi candidates.append(certifi.where()) except ImportError: # pragma: no cover pass return candidates @staticmethod def runtime_deps(): """Return a tuple of top-level Python package names that this plugin imported as optional runtime dependencies. """ return ("paho",) apprise-1.10.0/apprise/plugins/msg91.py000066400000000000000000000315611517341665700177170ustar00rootroot00000000000000# 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. # Create an account https://msg91.com/ if you don't already have one # # Get your (authkey) from the dashboard here: # - https://world.msg91.com/user/index.php#api # # Note: You will need to define a template for this to work # # Get details on the API used in this plugin here: # - https://docs.msg91.com/reference/send-sms from json import dumps import re 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 MSG91PayloadField: """Identifies the fields available in the JSON Payload.""" BODY = "body" MESSAGETYPE = "type" # Add entries here that are reserved RESERVED_KEYWORDS = ("mobiles",) class NotifyMSG91(NotifyBase): """A wrapper for MSG91 Notifications.""" # The default descriptive name associated with the Notification service_name = "MSG91" # The services URL service_url = "https://msg91.com" # The default protocol secure_protocol = "msg91" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/msg91/" # MSG91 uses the http protocol with JSON requests notify_url = "https://control.msg91.com/api/v5/flow/" # 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 # Our supported mappings and component keys component_key_re = re.compile( r"(?P((?P[a-z0-9_-])?|(?Pbody|type)))", re.IGNORECASE ) # Define object templates templates = ("{schema}://{template}@{authkey}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "template": { "name": _("Template ID"), "type": "string", "required": True, "private": True, "regex": (r"^[a-z0-9 _-]+$", "i"), }, "authkey": { "name": _("Authentication Key"), "type": "string", "required": True, "private": True, "regex": (r"^[a-z0-9]+$", "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, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, "short_url": { "name": _("Short URL"), "type": "bool", "default": False, }, }, ) # Define any kwargs we're using template_kwargs = { "template_mapping": { "name": _("Template Mapping"), "prefix": ":", }, } def __init__( self, template, authkey, targets=None, short_url=None, template_mapping=None, **kwargs, ): """Initialize MSG91 Object.""" super().__init__(**kwargs) # Authentication Key (associated with project) self.authkey = validate_regex( authkey, *self.template_tokens["authkey"]["regex"] ) if not self.authkey: msg = ( "An invalid MSG91 Authentication Key " f"({authkey}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Template ID self.template = validate_regex( template, *self.template_tokens["template"]["regex"] ) if not self.template: msg = f"An invalid MSG91 Template ID ({template}) was specified." self.logger.warning(msg) raise TypeError(msg) if short_url is None: self.short_url = self.template_args["short_url"]["default"] else: self.short_url = parse_bool(short_url) # 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"]) self.template_mapping = {} if template_mapping: # Store our extra payload entries self.template_mapping.update(template_mapping) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform MSG91 Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no MSG91 targets to notify.") return False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "authkey": self.authkey, } # Base recipient_payload = { "mobiles": None, # Keyword Tokens MSG91PayloadField.BODY: body, MSG91PayloadField.MESSAGETYPE: notify_type.value, } # Prepare Recipient Payload Object for key, value in self.template_mapping.items(): if key in RESERVED_KEYWORDS: self.logger.warning( "Ignoring MSG91 custom payload entry %s", key ) continue if key in recipient_payload: if not value: # Do not store element in payload response del recipient_payload[key] else: # Re-map recipient_payload[value] = recipient_payload[key] del recipient_payload[key] else: # Append entry recipient_payload[key] = value # Prepare our recipients recipients = [] for target in self.targets: recipient = recipient_payload.copy() recipient["mobiles"] = target recipients.append(recipient) # Prepare our payload payload = { "template_id": self.template, "short_url": 1 if self.short_url else 0, # target phone numbers are sent with a comma delimiter "recipients": recipients, } # Some Debug Logging self.logger.debug( "MSG91 POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"MSG91 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, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyMSG91.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send MSG91 notification to {}: " "{}{}error={}.".format( ",".join(self.targets), 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 MSG91 notification to {}.".format( ",".join(self.targets) ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending MSG91:{} " "notification.".format(",".join(self.targets)) ) self.logger.debug(f"Socket Exception: {e!s}") 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.template, self.authkey) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "short_url": str(self.short_url), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Payload body extras prefixed with a ':' sign # Append our payload extras into our parameters params.update({f":{k}": v for k, v in self.template_mapping.items()}) return "{schema}://{template}@{authkey}/{targets}/?{params}".format( schema=self.secure_protocol, template=self.pprint(self.template, privacy, safe=""), authkey=self.pprint(self.authkey, privacy, safe=""), targets="/".join( [NotifyMSG91.quote(x, safe="") for x in self.targets] ), params=NotifyMSG91.urlencode(params), ) 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 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"] = NotifyMSG91.split_path(results["fullpath"]) # The hostname is our authentication key results["authkey"] = NotifyMSG91.unquote(results["host"]) # The template id is kept in the user field results["template"] = NotifyMSG91.unquote(results["user"]) if "short_url" in results["qsd"] and len(results["qsd"]["short_url"]): results["short_url"] = parse_bool(results["qsd"]["short_url"]) # 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"] += NotifyMSG91.parse_phone_no( results["qsd"]["to"] ) # store any additional payload extra's defined results["template_mapping"] = { NotifyMSG91.unquote(x): NotifyMSG91.unquote(y) for x, y in results["qsd:"].items() } return results apprise-1.10.0/apprise/plugins/msteams.py000066400000000000000000000656361517341665700204420ustar00rootroot00000000000000# 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 create a webhook; you can read more about # this here: # https://dev.outlook.com/Connectors/\ # GetStarted#creating-messages-through-office-365-connectors-\ # in-microsoft-teams # # More details are here on API Construction: # https://docs.microsoft.com/en-ca/outlook/actionable-messages/\ # message-card-reference # # I personally created a free account at teams.microsoft.com and then # went to the store (bottom left hand side of slack like interface). # # From here you can search for 'Incoming Webhook'. Once you click on it, # you can associate the webhook with your team. At this point, you can # optionally also assign it a name, an avatar. Finally you'll have to # assign it a channel it will notify. # # When you've completed this, it will generate you a (webhook) URL that # looks like: # https://team-name.webhook.office.com/webhookb2/ \ # abcdefgf8-2f4b-4eca-8f61-225c83db1967@abcdefg2-5a99-4849-8efc-\ # c9e78d28e57d/IncomingWebhook/291289f63a8abd3593e834af4d79f9fe/\ # a2329f43-0ffb-46ab-948b-c9abdad9d643 # # Yes... The URL is that big... But it looks like this (greatly simplified): # https://TEAM-NAME.webhook.office.com/webhookb2/ABCD/IncomingWebhook/DEFG/HIJK # ^ ^ ^ ^ # | | | | # These are important <--------------------------^--------------------^----^ # # The Legacy format didn't have the team name identified and reads 'outlook' # While this still works, consider that Microsoft will be dropping support # for this soon, so you may need to update your IncomingWebhook. Here is # what a legacy URL looked like: # https://outlook.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK # ^ ^ ^ ^ # | | | | # legacy team reference: 'outlook' | | | # | | | # These are important <--------------^--------------------^----^ # # You'll notice that the first token is actually 2 separated by an @ symbol # But lets just ignore that and assume it's one great big token instead. # # These 3 tokens need to be placed in the URL after the Team # msteams://TEAM/ABCD/DEFG/HIJK # import json from json.decoder import JSONDecodeError import re import requests from ..apprise_attachment import AppriseAttachment from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, validate_regex from ..utils.templates import TemplateType, apply_template from .base import NotifyBase class NotifyMSTeams(NotifyBase): """A wrapper for Microsoft Teams Notifications.""" # The default descriptive name associated with the Notification service_name = "MSTeams" # The services URL service_url = "https://teams.micrsoft.com/" # The default secure protocol secure_protocol = "msteams" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/msteams/" # MSTeams uses the http protocol with JSON requests notify_url_v1 = ( "https://outlook.office.com/webhook/" "{token_a}/IncomingWebhook/{token_b}/{token_c}" ) # New MSTeams webhook (as of April 11th, 2021) notify_url_v2 = ( "https://{team}.webhook.office.com/webhookb2/" "{token_a}/IncomingWebhook/{token_b}/{token_c}" ) notify_url_v3 = ( "https://{team}.webhook.office.com/webhookb2/" "{token_a}/IncomingWebhook/{token_b}/{token_c}/{token_d}" ) # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # The maximum allowable characters allowed in the body per message body_maxlen = 1000 # Default Notification Format notify_format = NotifyFormat.MARKDOWN # 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_msteams_template_size = 35000 # Define object templates templates = ( # New required format "{schema}://{team}/{token_a}/{token_b}/{token_c}", # Deprecated "{schema}://{token_a}/{token_b}/{token_c}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ # The Microsoft Team Name "team": { "name": _("Team Name"), "type": "string", "required": True, "regex": (r"^[A-Z0-9_-]+$", "i"), }, # Token required as part of the API request # /AAAAAAAAA@AAAAAAAAA/........./......... "token_a": { "name": _("Token A"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9-]+@[A-Z0-9-]+$", "i"), }, # Token required as part of the API request # /................../BBBBBBBBB/.......... "token_b": { "name": _("Token B"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, # Token required as part of the API request # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC "token_c": { "name": _("Token C"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9-]+$", "i"), }, # Token required as part of the API request # /........./........./........./DDDDDDDDDDDDDDDDD "token_d": { "name": _("Token D"), "type": "string", "private": True, "required": False, "regex": (r"^V2[a-zA-Z0-9-_]+$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "image": { "name": _("Include Image"), "type": "bool", "default": False, "map_to": "include_image", }, "version": { "name": _("Version"), "type": "choice:int", "values": (1, 2, 3), "default": 2, }, "template": { "name": _("Template Path"), "type": "string", "private": True, }, }, ) # Define our token control template_kwargs = { "tokens": { "name": _("Template Tokens"), "prefix": ":", }, } def __init__( self, token_a, token_b, token_c, token_d=None, team=None, version=None, include_image=True, template=None, tokens=None, **kwargs, ): """Initialize Microsoft Teams Object. You can optional specify a template and identify arguments you wish to populate your template with when posting. Some reserved template arguments that can not be over-ridden are: `body`, `title`, and `type`. """ super().__init__(**kwargs) try: self.version = int(version) except TypeError: # None was specified... take on default self.version = self.template_args["version"]["default"] except ValueError: # invalid content was provided; let this get caught in the next # validation check for the version self.version = None if self.version not in self.template_args["version"]["values"]: msg = f"An invalid MSTeams Version ({version}) was specified." self.logger.warning(msg) raise TypeError(msg) self.team = validate_regex(team) if not self.team: NotifyBase.logger.deprecate( "Apprise requires you to identify your Microsoft Team name as " "part of the URL. e.g.: " f"msteams://TEAM-NAME/{token_a}/{token_b}/{token_c}" ) # Fallback self.team = "outlook" self.token_a = validate_regex( token_a, *self.template_tokens["token_a"]["regex"] ) if not self.token_a: msg = ( f"An invalid MSTeams (first) Token ({token_a}) was specified." ) self.logger.warning(msg) raise TypeError(msg) self.token_b = validate_regex( token_b, *self.template_tokens["token_b"]["regex"] ) if not self.token_b: msg = ( f"An invalid MSTeams (second) Token ({token_b}) was specified." ) self.logger.warning(msg) raise TypeError(msg) self.token_c = validate_regex( token_c, *self.template_tokens["token_c"]["regex"] ) if not self.token_c: msg = ( f"An invalid MSTeams (third) Token ({token_c}) was specified." ) self.logger.warning(msg) raise TypeError(msg) self.token_d = validate_regex( token_d, *self.template_tokens["token_d"]["regex"] ) # Place a thumbnail image inline with the message body self.include_image = include_image # 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) # Enforce maximum file size self.template[0].max_file_size = self.max_msteams_template_size # Template functionality self.tokens = {} if isinstance(tokens, dict): self.tokens.update(tokens) elif tokens: msg = ( "The specified MSTeams Template Tokens " f"({tokens}) are not identified as a dictionary." ) self.logger.warning(msg) raise TypeError(msg) self.logger.deprecate( "Microsoft is deprecating their MSTeams webhooks on " "December 31, 2025. It is advised that you switch to " "Microsoft Power Automate (already supported by Apprise as " "workflows://. For more information visit: " "https://appriseit.com/services/workflows/" ) def gen_payload( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """This function generates our payload whether it be the generic one Apprise generates by default, or one provided by a specified external template.""" # Acquire our to-be footer icon if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) if not self.template: # By default we use a generic working payload if there was # no template specified payload = { "@type": "MessageCard", "@context": "https://schema.org/extensions", "summary": self.app_desc, "themeColor": self.color(notify_type), "sections": [ { "activityImage": None, "activityTitle": title, "text": body, }, ], } if image_url: payload["sections"][0]["activityImage"] = image_url return payload # If our code reaches here, then we generate ourselves the payload template = self.template[0] if not template: # We could not access the attachment self.logger.error( "Could not access MSTeam template" f" {template.url(privacy=True)}." ) return False # Take a copy of our token dictionary tokens = self.tokens.copy() # Apply some defaults template values 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) tokens["app_image_url"] = image_url tokens["app_url"] = self.app_url # Enforce Application mode tokens["app_mode"] = TemplateType.JSON try: with open(template.path) as fp: content = json.loads(apply_template(fp.read(), **tokens)) except OSError: self.logger.error( f"MSTeam template {template.url(privacy=True)} could not be" " read." ) return None except JSONDecodeError as e: self.logger.error( f"MSTeam template {template.url(privacy=True)} contains" " invalid JSON." ) self.logger.debug(f"JSONDecodeError: {e}") return None # Load our JSON data (if valid) has_error = False if "@type" not in content: self.logger.error( f"MSTeam template {template.url(privacy=True)} is missing" " @type kwarg." ) has_error = True if "@context" not in content: self.logger.error( f"MSTeam template {template.url(privacy=True)} is missing" " @context kwarg." ) has_error = True return content if not has_error else None def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Microsoft Teams Notification.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } if self.version == 1: notify_url = self.notify_url_v1.format( token_a=self.token_a, token_b=self.token_b, token_c=self.token_c, ) if self.version == 2: notify_url = self.notify_url_v2.format( team=self.team, token_a=self.token_a, token_b=self.token_b, token_c=self.token_c, ) if self.version == 3: notify_url = self.notify_url_v3.format( team=self.team, token_a=self.token_a, token_b=self.token_b, token_c=self.token_c, token_d=self.token_d, ) # Generate our payload if it's possible payload = self.gen_payload( body=body, title=title, notify_type=notify_type, **kwargs ) if not payload: # No need to present a reason; that will come from the # gen_payload() function itself return False self.logger.debug( "MSTeams POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"MSTeams Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=json.dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyMSTeams.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send MSTeams 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] ) # We failed return False else: self.logger.info("Sent MSTeams notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending MSTeams notification." ) self.logger.debug(f"Socket Exception: {e!s}") # We failed 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.team if self.version > 1 else None, self.token_a, self.token_b, self.token_c, ) 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.version != self.template_args["version"]["default"]: params["version"] = str(self.version) if self.template: params["template"] = NotifyMSTeams.quote( self.template[0].url(), safe="" ) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Store any template entries if specified params.update({f":{k}": v for k, v in self.tokens.items()}) result = None if self.version == 1: result = ( "{schema}://{token_a}/{token_b}/{token_c}/?{params}".format( schema=self.secure_protocol, token_a=self.pprint(self.token_a, privacy, safe="@"), token_b=self.pprint(self.token_b, privacy, safe=""), token_c=self.pprint(self.token_c, privacy, safe=""), params=NotifyMSTeams.urlencode(params), ) ) if self.version == 2: result = ( "{schema}://{team}/{token_a}/{token_b}/{token_c}/" "?{params}".format( schema=self.secure_protocol, team=NotifyMSTeams.quote(self.team, safe=""), token_a=self.pprint(self.token_a, privacy, safe=""), token_b=self.pprint(self.token_b, privacy, safe=""), token_c=self.pprint(self.token_c, privacy, safe=""), params=NotifyMSTeams.urlencode(params), ) ) if self.version == 3: result = ( "{schema}://{team}/{token_a}/{token_b}/{token_c}/" "{token_d}/?{params}".format( schema=self.secure_protocol, team=NotifyMSTeams.quote(self.team, safe=""), token_a=self.pprint(self.token_a, privacy, safe=""), token_b=self.pprint(self.token_b, privacy, safe=""), token_c=self.pprint(self.token_c, privacy, safe=""), token_d=self.pprint(self.token_d, privacy, safe=""), params=NotifyMSTeams.urlencode(params), ) ) return result @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 unquoted entries entries = NotifyMSTeams.split_path(results["fullpath"]) # Deprecated mode (backwards compatibility) if results.get("user"): # If a user was found, it's because it's still part of the first # token, so we concatinate them results["token_a"] = "{}@{}".format( NotifyMSTeams.unquote(results["user"]), NotifyMSTeams.unquote(results["host"]), ) else: # Get the Team from the hostname results["team"] = NotifyMSTeams.unquote(results["host"]) # Get the token from the path results["token_a"] = ( None if not entries else NotifyMSTeams.unquote(entries.pop(0)) ) results["token_b"] = ( None if not entries else NotifyMSTeams.unquote(entries.pop(0)) ) results["token_c"] = ( None if not entries else NotifyMSTeams.unquote(entries.pop(0)) ) results["token_d"] = ( None if not entries else NotifyMSTeams.unquote(entries.pop(0)) ) # Get Image results["include_image"] = parse_bool( results["qsd"].get("image", True) ) # Get Team name if defined if "team" in results["qsd"] and results["qsd"]["team"]: results["team"] = NotifyMSTeams.unquote(results["qsd"]["team"]) # Template Handling if "template" in results["qsd"] and results["qsd"]["template"]: results["template"] = NotifyMSTeams.unquote( results["qsd"]["template"] ) # Override version if defined if "version" in results["qsd"] and results["qsd"]["version"]: results["version"] = NotifyMSTeams.unquote( results["qsd"]["version"] ) else: version = 1 if results.get("team"): version = 2 if results.get("token_d"): version = 3 # Set our version if not otherwise set results["version"] = version # Store our tokens results["tokens"] = results["qsd:"] return results @staticmethod def parse_native_url(url): """ Legacy Support: https://outlook.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK New Hook Support: https://team-name.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK Newer Hook Support: https://team-name.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK/V2LMNOP """ # We don't need to do incredibly details token matching as the purpose # of this is just to detect that were dealing with an msteams url # token parsing will occur once we initialize the function result = re.match( r"^https?://(?P[^.]+)(?P\.webhook)?\.office\.com/" r"webhook(?Pb2)?/" r"(?P[A-Z0-9-]+@[A-Z0-9-]+)/" r"IncomingWebhook/" r"(?P[A-Z0-9]+)/" r"(?P[A-Z0-9-]+)/" r"(?PV2[A-Z0-9-_]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: # Version 3 URL return NotifyMSTeams.parse_url( "{schema}://{team}/{token_a}/{token_b}/{token_c}/{token_d}" "/{params}".format( schema=NotifyMSTeams.secure_protocol, team=result.group("team"), token_a=result.group("token_a"), token_b=result.group("token_b"), token_c=result.group("token_c"), token_d=result.group("token_d"), params=( "" if not result.group("params") else result.group("params") ), ) ) result = re.match( r"^https?://(?P[^.]+)(?P\.webhook)?\.office\.com/" r"webhook(?Pb2)?/" r"(?P[A-Z0-9-]+@[A-Z0-9-]+)/" r"IncomingWebhook/" r"(?P[A-Z0-9]+)/" r"(?P[A-Z0-9-]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: if result.group("v2a"): # Version 2 URL return NotifyMSTeams.parse_url( "{schema}://{team}/{token_a}/{token_b}/{token_c}" "/{params}".format( schema=NotifyMSTeams.secure_protocol, team=result.group("team"), token_a=result.group("token_a"), token_b=result.group("token_b"), token_c=result.group("token_c"), params=( "" if not result.group("params") else result.group("params") ), ) ) else: # Version 1 URLs # team is also set to 'outlook' in this case return NotifyMSTeams.parse_url( "{schema}://{token_a}/{token_b}/{token_c}/{params}".format( schema=NotifyMSTeams.secure_protocol, token_a=result.group("token_a"), token_b=result.group("token_b"), token_c=result.group("token_c"), params=( "" if not result.group("params") else result.group("params") ), ) ) return None apprise-1.10.0/apprise/plugins/nextcloud.py000066400000000000000000000475461517341665700207760ustar00rootroot00000000000000# 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 from json import loads import re import requests from ..common import NotifyType, PersistentStoreMode from ..exception import AppriseException from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_list from .base import NotifyBase # Is Group Detection IS_GROUP = re.compile( r"^\s*((#|%23)(?P[a-z0-9_-]+)|" r"((#|%23)?(?Pall|everyone|\*)))\s*$", re.I, ) # Is User Detection IS_USER = re.compile( r"^\s*(@|%40)?(?P[a-z0-9_-]+)\s*$", re.I, ) class NextcloudGroupDiscoveryException(AppriseException): """Apprise Nextcloud Group Discovery Exception Class.""" class NotifyNextcloud(NotifyBase): """A wrapper for Nextcloud Notifications. Targets can be individual users, groups, or everyone: - user: specify one or more usernames as path segments - group: prefix with a hash (e.g., ``#DevTeam``) - everyone: use ``all`` (aliases: ``everyone``, ``*``) Group and everyone expansion uses Nextcloud's OCS provisioning API and requires appropriate permissions (typically an admin account) and the provisioning API enabled on the server. """ # The default descriptive name associated with the Notification service_name = "Nextcloud" # The services URL service_url = "https://nextcloud.com/" # Insecure protocol (for those self hosted requests) protocol = "ncloud" # The default protocol (this is secure for notica) secure_protocol = "nclouds" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/nextcloud/" # Nextcloud title length title_maxlen = 255 # Defines the maximum allowable characters per message. body_maxlen = 4000 # Our default is to not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.AUTO # Defines how long we cache our discovery for group_discovery_cache_length_sec = 86400 # unique identifier to cache the 'all' group category all_group_id = "all" # 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 tokens 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_user": { "name": _("Target User"), "type": "string", "map_to": "targets", "prefix": "@", }, "target_group": { "name": _("Target Group"), "type": "string", "map_to": "targets", "prefix": "#", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ # Nextcloud uses different API end points depending on the version # being used however the (API) payload remains the same. Allow # users to specify the version they are using: "version": { "name": _("Version"), "type": "int", "min": 1, "default": 21, }, "url_prefix": { "name": _("URL Prefix"), "type": "string", }, "to": { "alias_of": "targets", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, } def __init__( self, targets=None, version=None, headers=None, url_prefix=None, **kwargs, ): """Initialize Nextcloud Object.""" super().__init__(**kwargs) # Store our targets self.targets = [] self.groups = set() for target in parse_list(targets): results = IS_GROUP.match(target) if results: group_id = ( self.all_group_id if results.group("all") else results.group("group") ) self.groups.add(group_id) self.logger.debug("Added Nextcloud group '%s'", group_id) continue results = IS_USER.match(target) if results: # Store our target self.targets.append(results.group("user")) self.logger.debug( "Added Nextcloud user '%s'", self.targets[-1] ) continue self.logger.warning( "Ignored invalid Nextcloud user/group '%s'", target ) self.version = self.template_args["version"]["default"] if version is not None: try: self.version = int(version) if self.version < self.template_args["version"]["min"]: # Let upper exception handle this raise ValueError() except (ValueError, TypeError): msg = ( f"At invalid Nextcloud version ({version}) was specified." ) self.logger.warning(msg) raise TypeError(msg) from None # Support URL Prefix self.url_prefix = ( "" if not url_prefix else ("/" + url_prefix.strip("/")) ) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) return def _fetch(self, payload=None, target=None, group=None): """Wrapper to NextCloud API requests object.""" # our method method = "POST" if target else "GET" # Prepare our Header headers = { "User-Agent": self.app_id, "OCS-APIREQUEST": "true", "Accept": "application/json", } # Apply any/all header over-rides defined headers.update(self.headers) # Prepare base URL fragments scheme = "https" if self.secure else "http" host_port = ( self.host if not isinstance(self.port, int) else f"{self.host}:{self.port}" ) base = f"{scheme}://{host_port}" if self.url_prefix: base = f"{base}{self.url_prefix}" # Auth auth = (self.user, self.password) if self.user else None # our URL Parameters params = {} # our response content = None if target: # Nextcloud URL based on version used query = f'v{self.version} Notify "{target}"' esc_target = NotifyNextcloud.quote(target) url = ( f"{base}/ocs/v2.php/" "apps/admin_notifications/" f"api/v1/notifications/{esc_target}" if self.version < 21 else ( f"{base}/ocs/v2.php/" "apps/notifications/" f"api/v2/admin_notifications/{esc_target}" ) ) elif group: query = f'Group "{group}"' params = { "format": "json", } esc_group = NotifyNextcloud.quote(group) url = f"{base}/ocs/v1.php/cloud/groups/{esc_group}" else: # Users query = "Users" params = { "format": "json", } url = f"{base}/ocs/v1.php/cloud/users" self.throttle() self.logger.debug( "Nextcloud %s %s URL: %s (cert_verify=%r)", query, method, url, self.verify_certificate, ) if payload: self.logger.debug( "Nextcloud v%d Payload: %s", self.version, payload ) try: # Prepare our request object request = requests.post if target else requests.get r = request( url, headers=headers, data=payload, params=params, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) try: content = loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} if r.status_code != requests.codes.ok: status_str = NotifyNextcloud.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Nextcloud %s: %s%serror=%d.", query, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) if target: return (False, content) raise NextcloudGroupDiscoveryException( f"{query} non-200 response" ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred with Nextcloud %s", query, ) self.logger.debug(f"Socket Exception: {e!s}") if target: return (False, None) raise NextcloudGroupDiscoveryException( f"{query} socket exception" ) from None self.logger.info("Sent Nextcloud %s", query) return (True, content) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Nextcloud Notification.""" # Create a copy of our targets targets = set(self.targets) # Initialize our has_error flag has_error = False if self.groups: # Append our group lookup try: for group in self.groups: if group == self.all_group_id: targets |= self.all_users() else: # specific group targets |= self.users_by_group(group) except NextcloudGroupDiscoveryException: # logging already handled within all_users and user_by_group() return False if not targets: # There were no services to notify self.logger.warning("There were no Nextcloud targets to notify.") return False for target in targets: # Prepare our Payload payload = { "shortMessage": title if title else self.app_desc, } if body: # Only store the longMessage if a body was defined; nextcloud # doesn't take kindly to empty longMessage entries. payload["longMessage"] = body is_okay, _ = self._fetch(payload, target) if not is_okay: # Toggle our status has_error = True return not has_error def users_by_group(self, group): """ Lists users associated with a provided group """ # Check our cache targets = self.store.get(group) if targets is not None: # Returned cached value self.logger.trace( f"Using Nextcloud cached response for group '{group}' query" ) return set(targets) # _fetch throws an exception if it fails, so we can # go ahead and ignore checking for it. _, response = self._fetch(group=group) # Initialize our targets targets = set() # If we get here, our fetch was successful; look up our users users = response.get("ocs", {}).get("data", {}).get("users") if isinstance(users, list): targets = {s for u in users if (s := str(u).strip())} if not targets: self.logger.warning( "No users associated with Nextcloud group '%s'", group ) self.store.set( group, list(targets), expires=self.group_discovery_cache_length_sec ) return targets def all_users(self): """ Lists users associated with Nextcloud instance """ # Check our cache targets = self.store.get(self.all_group_id) if targets is not None: self.logger.trace( "Using Nextcloud cached response for all-user query" ) return set(targets) # _fetch throws an exception if it fails, so we can # go ahead and ignore checking for it. _, response = self._fetch() # Initialize our targets targets = set() # If we get here, our fetch was successful; look up our users users = response.get("ocs", {}).get("data", {}).get("users") if isinstance(users, list): targets = {s for u in users if (s := str(u).strip())} if not targets: self.logger.warning( "Failed to retrieve all users from Nextcloud", ) # early exit; no cache return targets self.store.set( self.all_group_id, list(targets), expires=self.group_discovery_cache_length_sec, ) return targets @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.url_prefix, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Create URL parameters from our headers params = {f"+{k}": v for k, v in self.headers.items()} # Set our version params["version"] = str(self.version) if self.url_prefix.rstrip("/"): params["url_prefix"] = self.url_prefix # 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=NotifyNextcloud.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyNextcloud.quote(self.user, safe=""), ) group_prefix = self.template_tokens["target_group"]["prefix"] user_prefix = self.template_tokens["target_user"]["prefix"] 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( [ NotifyNextcloud.quote(x, safe=(group_prefix + user_prefix)) for x in chain( # Groups are prefixed with a pound/hashtag symbol [f"{group_prefix}{x}" for x in self.groups], # Users [f"{user_prefix}{x}" for x in self.targets], ) ] ), params=NotifyNextcloud.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) + len(self.groups) return max(1, 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 # Fetch our targets results["targets"] = NotifyNextcloud.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"] += NotifyNextcloud.parse_list( results["qsd"]["to"] ) # Allow users to over-ride the Nextcloud version being used if "version" in results["qsd"] and len(results["qsd"]["version"]): results["version"] = NotifyNextcloud.unquote( results["qsd"]["version"] ) # Support URL Prefixes if "url_prefix" in results["qsd"] and len( results["qsd"]["url_prefix"] ): results["url_prefix"] = NotifyNextcloud.unquote( results["qsd"]["url_prefix"] ) # 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"] = { NotifyNextcloud.unquote(x): NotifyNextcloud.unquote(y) for x, y in results["qsd+"].items() } return results apprise-1.10.0/apprise/plugins/nextcloudtalk.py000066400000000000000000000266501517341665700216430ustar00rootroot00000000000000# 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 requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_list from .base import NotifyBase class NotifyNextcloudTalk(NotifyBase): """A wrapper for Nextcloud Talk Notifications.""" # The default descriptive name associated with the Notification service_name = _("Nextcloud Talk") # The services URL service_url = "https://nextcloud.com/talk" # Insecure protocol (for those self hosted requests) protocol = "nctalk" # The default protocol (this is secure for notica) secure_protocol = "nctalks" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/nextcloudtalk/" # Nextcloud title length title_maxlen = 255 # Defines the maximum allowable characters per message. body_maxlen = 4000 # Define object templates templates = ( "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) # Define our template tokens 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", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_room_id": { "name": _("Room ID"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "url_prefix": { "name": _("URL Prefix"), "type": "string", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, } def __init__(self, targets=None, headers=None, url_prefix=None, **kwargs): """Initialize Nextcloud Talk Object.""" super().__init__(**kwargs) if self.user is None or self.password is None: msg = "A NextCloudTalk User and Password must be specified." self.logger.warning(msg) raise TypeError(msg) # Store our targets self.targets = parse_list(targets) # Support URL Prefix self.url_prefix = "" if not url_prefix else url_prefix.strip("/") self.headers = {} if headers: # Store our extra headers self.headers.update(headers) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Nextcloud Talk Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning( "There were no Nextcloud Talk targets to notify." ) return False # Prepare our Header headers = { "User-Agent": self.app_id, "OCS-APIRequest": "true", "Accept": "application/json", "Content-Type": "application/json", } # Apply any/all header over-rides defined headers.update(self.headers) # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) while len(targets): target = targets.pop(0) # Prepare our Payload if not body: payload = { "message": title if title else self.app_desc, } else: payload = { "message": ( title + "\r\n" + body if title else self.app_desc + "\r\n" + body ), } # Nextcloud Talk URL notify_url = ( "{schema}://{host}/{url_prefix}" f"/ocs/v2.php/apps/spreed/api/v1/chat/{target}" ) notify_url = notify_url.format( schema="https" if self.secure else "http", host=( self.host if not isinstance(self.port, int) else f"{self.host}:{self.port}" ), url_prefix=self.url_prefix, target=target, ) self.logger.debug( "Nextcloud Talk POST URL: %s (cert_verify=%r)", notify_url, self.verify_certificate, ) self.logger.debug("Nextcloud Talk Payload: %s", 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, auth=(self.user, self.password), verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.created, requests.codes.ok, ): # We had a problem status_str = NotifyNextcloudTalk.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Nextcloud Talk 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] ) # track our failure has_error = True continue else: self.logger.info("Sent Nextcloud Talk notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Nextcloud Talk " "notification." ) self.logger.debug(f"Socket Exception: {e!s}") # track 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 if self.secure else self.protocol, self.user, self.password, self.host, self.port, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Our default set of parameters params = 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.url_prefix: params["url_prefix"] = self.url_prefix # Determine Authentication auth = "{user}:{password}@".format( user=NotifyNextcloudTalk.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, 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( [NotifyNextcloudTalk.quote(x) for x in self.targets] ), params=NotifyNextcloudTalk.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) return targets if 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) if not results: # We're done early as we couldn't load the results return results # Fetch our targets results["targets"] = NotifyNextcloudTalk.split_path( results["fullpath"] ) # Support URL Prefixes if "url_prefix" in results["qsd"] and len( results["qsd"]["url_prefix"] ): results["url_prefix"] = NotifyNextcloudTalk.unquote( results["qsd"]["url_prefix"] ) # 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"] = { NotifyNextcloudTalk.unquote(x): NotifyNextcloudTalk.unquote(y) for x, y in results["qsd+"].items() } return results apprise-1.10.0/apprise/plugins/notica.py000066400000000000000000000322411517341665700202300ustar00rootroot00000000000000# 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. Simply visit https://notica.us # 2. You'll be provided a new variation of the website which will look # something like: https://notica.us/?abc123. # ^ # | # token # # Your token is actually abc123 (do not include/grab the question mark) # You can use that URL as is directly in Apprise, or you can follow # the next step which shows you how to assemble the Apprise URL: # # 3. With respect to the above, your apprise URL would be: # notica://abc123 # import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import validate_regex from .base import NotifyBase class NoticaMode: """Tracks if we're accessing the notica upstream server or a locally hosted one.""" # We're dealing with a self hosted service SELFHOSTED = "selfhosted" # We're dealing with the official hosted service at https://notica.us OFFICIAL = "official" # Define our Notica Modes NOTICA_MODES = ( NoticaMode.SELFHOSTED, NoticaMode.OFFICIAL, ) class NotifyNotica(NotifyBase): """A wrapper for Notica Notifications.""" # The default descriptive name associated with the Notification service_name = "Notica" # The services URL service_url = "https://notica.us/" # Insecure protocol (for those self hosted requests) protocol = "notica" # The default protocol (this is secure for notica) secure_protocol = "noticas" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/notica/" # Notica URL notify_url = "https://notica.us/?{token}" # Notica does not support a title title_maxlen = 0 # Define object templates templates = ( "{schema}://{token}", # Self-hosted notica servers "{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}", # Self-hosted notica servers (with custom path) "{schema}://{host}{path}/{token}", "{schema}://{host}:{port}/{path}/{token}", "{schema}://{user}@{host}/{path}/{token}", "{schema}://{user}@{host}:{port}{path}/{token}", "{schema}://{user}:{password}@{host}{path}/{token}", "{schema}://{user}:{password}@{host}:{port}/{path}/{token}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, "regex": r"^\?*(?P[^/]+)\s*$", }, "host": { "name": _("Hostname"), "type": "string", }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "path": { "name": _("Path"), "type": "string", "map_to": "fullpath", "default": "/", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, } def __init__(self, token, headers=None, **kwargs): """Initialize Notica Object.""" super().__init__(**kwargs) # Token (associated with project) self.token = validate_regex(token) if not self.token: msg = f"An invalid Notica Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # Setup our mode self.mode = NoticaMode.SELFHOSTED if self.host else NoticaMode.OFFICIAL # prepare our fullpath 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 send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Notica Notification.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", } # Prepare our payload payload = f"d:{body}" # Auth is used for SELFHOSTED queries auth = None if self.mode is NoticaMode.OFFICIAL: # prepare our notify url notify_url = self.notify_url.format(token=self.token) else: # Prepare our self hosted URL # Apply any/all header over-rides defined headers.update(self.headers) if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" # Prepare our notify_url notify_url = f"{schema}://{self.host}" if isinstance(self.port, int): notify_url += f":{self.port}" notify_url += f"{self.fullpath}?token={self.token}" self.logger.debug( "Notica POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Notica Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url.format(token=self.token), data=payload, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyNotica.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Notica 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 Notica notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Notica notification.", ) 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.mode, self.token, self.user, self.password, self.host, self.port, ) 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 self.mode == NoticaMode.OFFICIAL: # Official URLs are easy to assemble return "{schema}://{token}/?{params}".format( schema=self.protocol, token=self.pprint(self.token, privacy, safe=""), params=NotifyNotica.urlencode(params), ) # If we reach here then we are assembling a self hosted URL # Append URL parameters from our headers params.update({f"+{k}": v for k, v in self.headers.items()}) # Authorization can be used for self-hosted sollutions auth = "" # Determine Authentication if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyNotica.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyNotica.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}{token}/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, hostname=NotifyNotica.quote(self.host, safe=""), port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=NotifyNotica.quote(self.fullpath, safe="/"), token=self.pprint(self.token, privacy, safe=""), params=NotifyNotica.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 # Get unquoted entries entries = NotifyNotica.split_path(results["fullpath"]) if not entries: # If there are no path entries, then we're only dealing with the # official website results["mode"] = NoticaMode.OFFICIAL # Store our token using the host results["token"] = NotifyNotica.unquote(results["host"]) # Unset our host results["host"] = None else: # Otherwise we're running a self hosted instance results["mode"] = NoticaMode.SELFHOSTED # The last element in the list is our token results["token"] = entries.pop() # Re-assemble our full path results["fullpath"] = ( "/" if not entries else "/{}/".format("/".join(entries)) ) # 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"] = { NotifyNotica.unquote(x): NotifyNotica.unquote(y) for x, y in results["qsd+"].items() } return results @staticmethod def parse_native_url(url): """ Support https://notica.us/?abc123 """ result = re.match( r"^https?://notica\.us/?" r"\??(?P[^&]+)([&\s]*(?P.+))?$", url, re.I, ) if result: return NotifyNotica.parse_url( "{schema}://{token}/{params}".format( schema=NotifyNotica.protocol, token=result.group("token"), params=( "" if not result.group("params") else "?{}".format(result.group("params")) ), ) ) return None apprise-1.10.0/apprise/plugins/notifiarr.py000066400000000000000000000370441517341665700207560ustar00rootroot00000000000000# 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 from json import dumps import re import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, parse_list, validate_regex from .base import NotifyBase from .discord import USER_ROLE_DETECTION_RE # Used to break path apart into list of channels CHANNEL_LIST_DELIM = re.compile(r"[ \t\r\n,#\\/]+") CHANNEL_REGEX = re.compile(r"^\s*(\#|\%35)?(?P[0-9]+)", re.I) # For API Details see: # https://notifiarr.wiki/Client/Installation # Another good example: # https://notifiarr.wiki/en/Website/ \ # Integrations/Passthrough#payload-example-1 class NotifyNotifiarr(NotifyBase): """A wrapper for Notifiarr Notifications.""" # The default descriptive name associated with the Notification service_name = "Notifiarr" # The services URL service_url = "https://notifiarr.com/" # The default secure protocol secure_protocol = "notifiarr" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/notifiarr/" # The Notification URL notify_url = "https://notifiarr.com/api/v1/notification/apprise" # Notifiarr Throttling (knowing in advance reduces 429 responses) # define('NOTIFICATION_LIMIT_SECOND_USER', 5); # define('NOTIFICATION_LIMIT_SECOND_PATRON', 15); # Throttle requests ever so slightly request_rate_per_sec = 0.04 # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 # Define object templates templates = ("{schema}://{apikey}/{targets}",) # Define our apikeys; these are the minimum apikeys 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, **{ "apikey": { "name": _("Token"), "type": "string", "required": True, "private": True, }, "target_channel": { "name": _("Target Channel"), "type": "string", "prefix": "#", "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", }, "apikey": { "alias_of": "apikey", }, "event": { "name": _("Discord Event ID"), "type": "int", }, "image": { "name": _("Include Image"), "type": "bool", "default": False, "map_to": "include_image", }, "source": { "name": _("Source"), "type": "string", }, "from": {"alias_of": "source"}, "to": { "alias_of": "targets", }, }, ) def __init__( self, apikey=None, include_image=None, event=None, targets=None, source=None, **kwargs, ): """Initialize Notifiarr 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.apikey = apikey if not self.apikey: msg = f"An invalid Notifiarr APIKey ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Place a thumbnail image inline with the message body self.include_image = ( include_image if isinstance(include_image, bool) else self.template_args["image"]["default"] ) # Prepare our source (if set) self.source = validate_regex(source) self.event = 0 if event: try: self.event = int(event) except (ValueError, TypeError): msg = ( "An invalid Notifiarr Discord Event ID " f"({event}) was specified." ) self.logger.warning(msg) raise TypeError(msg) from None # Prepare our targets self.targets = { "channels": [], "invalid": [], } for target in parse_list(targets): result = CHANNEL_REGEX.match(target) if result: # Store role information self.targets["channels"].append(int(result.group("channel"))) continue self.logger.warning( f"Dropped invalid channel ({target}) specified.", ) self.targets["invalid"].append(target) 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 if self.secure else self.protocol, self.apikey, ) 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.source: params["source"] = self.source if self.event: params["event"] = self.event # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{apikey}/{targets}?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [ NotifyNotifiarr.quote(x, safe="+#@") for x in chain( # Channels [f"#{x}" for x in self.targets["channels"]], # Pass along the same invalid entries as were provided self.targets["invalid"], ) ] ), params=NotifyNotifiarr.urlencode(params), ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Notifiarr Notification.""" if not self.targets["channels"]: # There were no services to notify self.logger.warning("There were no Notifiarr channels to notify.") return False # No error to start with has_error = False # Acquire image_url image_url = self.image_url(notify_type) # Define our mentions mentions = { "pingUser": [], "pingRole": [], "content": [], } # parse for user id's <@123> and role IDs <@&456> results = USER_ROLE_DETECTION_RE.findall(body) if results: for is_role, no, value in results: if value: # @everybody, @admin, etc - unsupported mentions["content"].append(f"@{value}") elif is_role: mentions["pingRole"].append(no) mentions["content"].append(f"<@&{no}>") else: # is_user mentions["pingUser"].append(no) mentions["content"].append(f"<@{no}>") for _idx, channel in enumerate(self.targets["channels"]): # prepare Notifiarr Object payload = { "source": self.source if self.source else self.app_id, "type": notify_type.value, "notification": { "update": bool(self.event), "name": self.app_id, "event": str(self.event) if self.event else "", }, "discord": { "color": self.color(notify_type), "ping": { # Only 1 user is supported, so truncate the rest "pingUser": ( 0 if not mentions["pingUser"] else mentions["pingUser"][0] ), # Only 1 role is supported, so truncate the rest "pingRole": ( 0 if not mentions["pingRole"] else mentions["pingRole"][0] ), }, "text": { "title": title, "content": ( "" if not mentions["content"] else "πŸ‘‰ " + " ".join(mentions["content"]) ), "description": body, "footer": self.app_desc, }, "ids": { "channel": channel, }, }, } if self.include_image and image_url: payload["discord"]["text"]["icon"] = image_url payload["discord"]["images"] = { "thumbnail": image_url, } if not self._send(payload): has_error = True return not has_error def _send(self, payload): """Send notification.""" self.logger.debug( "Notifiarr POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Notifiarr Payload: {payload!s}") # Prepare HTTP Headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "text/plain", "X-api-Key": self.apikey, } # 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, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyNotifiarr.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Notifiarr %s notification: %serror=%s.", 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 Notifiarr notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Notifiarr " f"Chat notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets["channels"]) + len(self.targets["invalid"]) 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 # Get channels results["targets"] = NotifyNotifiarr.split_path(results["fullpath"]) if "event" in results["qsd"] and len(results["qsd"]["event"]): results["event"] = NotifyNotifiarr.unquote(results["qsd"]["event"]) # Include images with our message results["include_image"] = parse_bool( results["qsd"].get("image", False) ) # Track if we need to extract the hostname as a target host_is_potential_target = False if "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyNotifiarr.unquote( results["qsd"]["source"] ) elif "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyNotifiarr.unquote(results["qsd"]["from"]) # Set our apikey if found as an argument if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): results["apikey"] = NotifyNotifiarr.unquote( results["qsd"]["apikey"] ) host_is_potential_target = True elif "key" in results["qsd"] and len(results["qsd"]["key"]): results["apikey"] = NotifyNotifiarr.unquote(results["qsd"]["key"]) host_is_potential_target = True else: # Pop the first element (this is the api key) results["apikey"] = NotifyNotifiarr.unquote(results["host"]) if host_is_potential_target is True and results["host"]: results["targets"].append(NotifyNotifiarr.unquote(results["host"])) # 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"] += list( filter( bool, CHANNEL_LIST_DELIM.split( NotifyNotifiarr.unquote(results["qsd"]["to"]) ), ) ) return results apprise-1.10.0/apprise/plugins/notificationapi.py000066400000000000000000001064311517341665700221360ustar00rootroot00000000000000# 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. # Simple API Reference: # - https://www.notificationapi.com/docs/reference/server#send from __future__ import annotations import base64 from email.utils import formataddr from itertools import chain from json import dumps, loads import re import requests from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..conversion import convert_between from ..locale import gettext_lazy as _ from ..utils.parse import ( is_email, is_phone_no, parse_emails, parse_list, validate_regex, ) from .base import NotifyBase # Used to detect ID IS_VALID_ID_RE = re.compile(r"^\s*(@|%40)?(?P[\w_-]+)\s*$", re.I) class NotificationAPIRegion: """Regions.""" CA = "ca" US = "us" EU = "eu" # NotificationAPI endpoints NOTIFICATIONAPI_API_LOOKUP = { NotificationAPIRegion.US: "https://api.notificationapi.com", NotificationAPIRegion.CA: "https://api.ca.notificationapi.com", NotificationAPIRegion.EU: "https://api.eu.notificationapi.com", } # A List of our regions we can use for verification NOTIFICATIONAPI_REGIONS = ( NotificationAPIRegion.US, NotificationAPIRegion.CA, NotificationAPIRegion.EU, ) class NotificationAPIChannel: """Channels""" EMAIL = "email" SMS = "sms" INAPP = "inapp" WEB_PUSH = "web_push" MOBILE_PUSH = "mobile_push" SLACK = "slack" # A List of our channels we can use for verification NOTIFICATIONAPI_CHANNELS: frozenset[str] = frozenset( [ NotificationAPIChannel.EMAIL, NotificationAPIChannel.SMS, NotificationAPIChannel.INAPP, NotificationAPIChannel.WEB_PUSH, NotificationAPIChannel.MOBILE_PUSH, NotificationAPIChannel.SLACK, ] ) class NotificationAPIMode: """Modes""" TEMPLATE = "template" MESSAGE = "message" # A List of our channels we can use for verification NOTIFICATIONAPI_MODES: frozenset[str] = frozenset( [ NotificationAPIMode.TEMPLATE, NotificationAPIMode.MESSAGE, ] ) class NotifyNotificationAPI(NotifyBase): """ A wrapper for NotificationAPI Notifications """ # The default descriptive name associated with the Notification service_name = "NotificationAPI" # The services URL service_url = "https://www.notificationapi.com/" # The default secure protocol secure_protocol = ("napi", "notificationapi") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/notificationapi/" # If no NotificationAPI Message Type is specified, then the following is # used default_message_type = "apprise" # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.2 # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # Define object templates templates = ( "{schema}://{client_id}/{client_secret}/{targets}", "{schema}://{type}@{client_id}/{client_secret}/{targets}", ) # Explicit URL tokens we care about (all others from base are ignored) template_tokens = dict( NotifyBase.template_tokens, **{ "type": { "name": _("Message Type"), "type": "string", "regex": (r"^[A-Z0-9_-]+$", "i"), "required": True, "map_to": "message_type", }, "client_id": { "name": _("Client ID"), "type": "string", "required": True, }, "client_secret": { "name": _("Client Secret"), "type": "string", "required": True, "private": True, }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "target_id": { "name": _("Target ID"), "type": "string", "map_to": "targets", }, "target_sms": { "name": _("Target SMS"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Supported query args template_args = dict( NotifyBase.template_args, **{ "type": { "alias_of": "type", }, "channels": { "name": _("Channels"), "type": "list:string", "values": NOTIFICATIONAPI_CHANNELS, }, "region": { "name": _("Region Name"), "type": "choice:string", "values": NOTIFICATIONAPI_REGIONS, "default": NotificationAPIRegion.US, }, "mode": { "name": _("Mode"), "type": "choice:string", "values": NOTIFICATIONAPI_MODES, }, "reply": { "name": _("Reply To"), "type": "string", "map_to": "reply_to", }, "from": { "name": _("From Email"), "type": "string", "map_to": "from_addr", }, "id": { "alias_of": "client_id", }, "secret": { "alias_of": "client_secret", }, "to": { "alias_of": "targets", }, # Email Values "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) # Define our token control template_kwargs = { "tokens": { "name": _("Template Tokens"), "prefix": ":", }, } def __init__( self, client_id, client_secret, message_type=None, targets=None, cc=None, bcc=None, reply_to=None, channels=None, region=None, mode=None, from_addr=None, tokens=None, **kwargs, ): """ Initialize Notify NotificationAPI Object """ super().__init__(**kwargs) # Client ID self.client_id = validate_regex(client_id) if not self.client_id: msg = ( "An invalid NotificationAPI Client ID " "({}) was specified.".format(client_id) ) self.logger.warning(msg) raise TypeError(msg) # Client Secret self.client_secret = validate_regex(client_secret) if not self.client_secret: msg = ( "An invalid NotificationAPI Client Secret " "({}) was specified.".format(client_secret) ) self.logger.warning(msg) raise TypeError(msg) # For tracking our email -> name lookups self.names = {} # Prepare our From Address from_addr_ = [self.app_id, ""] self.from_addr = None if isinstance(from_addr, str): result = is_email(from_addr) if result: from_addr_ = ( result["name"] if result["name"] else from_addr_[0], result["full_email"], ) else: # Only update the string but use the already detected info from_addr_[0] = from_addr # Store our lookup self.from_addr = from_addr_[1] self.names[from_addr_[1]] = from_addr_[0] # Prepare our Reply-To Address self.reply_to = {} if isinstance(reply_to, str): result = is_email(reply_to) if result and "full_email" in result: self.reply_to = { "senderName": result["name"] if result["name"] else from_addr_[0], "senderEmail": result["full_email"], } # Our Targets are delimited by found ids self.targets = [] if mode and isinstance(mode, str): self.mode = next( (a for a in NOTIFICATIONAPI_MODES if a.startswith(mode)), None ) if self.mode not in NOTIFICATIONAPI_MODES: msg = ( f"The NotificationAPI mode specified ({mode}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) else: # Detect mode based on whether or not a message_type was provided self.mode = ( NotificationAPIMode.MESSAGE if not message_type else NotificationAPIMode.TEMPLATE ) if not message_type: # Assign a default message type self.message_type = self.default_message_type else: self.message_type = validate_regex( message_type, *self.template_tokens["type"]["regex"] ) if not self.message_type: msg = ( "An invalid NotificationAPI Message Type " "({}) was specified.".format(message_type) ) self.logger.warning(msg) raise TypeError(msg) # Precompute auth header # Ruby/docs show POST "/{client_id}/sender" with: # Basic base64(client_id:client_secret) # https://www.notificationapi.com/docs/reference/server token = base64.b64encode( f"{self.client_id}:{self.client_secret}".encode() ).decode("ascii") self.auth_header = f"Basic {token}" # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Store our region try: self.region = ( self.template_args["region"]["default"] if region is None else region.lower() ) if self.region not in NOTIFICATIONAPI_REGIONS: # allow the outer except to handle this common response raise IndexError() except (AttributeError, IndexError, TypeError): # Invalid region specified msg = ( f"The NotificationAPI region specified ({region}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) from None # Initialize an empty set of channels self.channels = set() for channel_ in parse_list(channels): channel = channel_.lower() if channel not in NOTIFICATIONAPI_CHANNELS: # Invalid channel specified msg = ( "The NotificationAPI forced channel specified " f"({channel}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) from None self.channels.add(channel) # Used for URL generation afterwards only self._invalid_targets = [] if targets: current_target = {} for entry in parse_list(targets, sort=False): result = is_email(entry) if result: if "email" not in current_target: current_target["email"] = result["full_email"] if not self.channels: self.channels.add(NotificationAPIChannel.EMAIL) self.logger.info( "The NotificationAPI default channel of " f"{NotificationAPIChannel.EMAIL} was set." ) continue elif "id" in current_target: # Store and move on self.targets.append(current_target) current_target = {"email": result["full_email"]} continue # if we got here, we have to many emails making it now # ambiguous as to who the sender intended to notify msg = ( "The NotificationAPI received too many emails " "creating an ambiguous situation; aborted at " f"'{entry}'." ) self.logger.warning(msg) raise TypeError(msg) from None result = is_phone_no(entry) if result: if "number" not in current_target: current_target["number"] = ( "+" if entry[0] == "+" else "" ) + result["full"] if not self.channels: self.channels.add(NotificationAPIChannel.SMS) self.logger.info( "The NotificationAPI default channel of " f"{NotificationAPIChannel.SMS} was set." ) continue elif "id" in current_target: # Store and move on self.targets.append(current_target) current_target = {"number": result["full"]} continue # if we got here, we have to many emails making it now # ambiguous as to who the sender intended to notify msg = ( "The NotificationAPI received too many phone no's " "creating an ambiguous situation; aborted at " f"'{entry}'." ) self.logger.warning(msg) raise TypeError(msg) from None result = IS_VALID_ID_RE.match(entry) if result: if "id" not in current_target: current_target["id"] = result.group("id") continue # Store id in next target and move on self.targets.append(current_target) current_target = {"id": result.group("id")} continue self.logger.warning( "Dropped invalid NotificationAPI target " f"({entry}) specified" ) self._invalid_targets.append(entry) continue if "id" in current_target: # Store our final entry self.targets.append(current_target) current_target = {} if current_target: # we have email or sms, but no id to go with it msg = ( "The NotificationAPI did not detect an id to " "correlate the following with {}".format( str(current_target) ) ) self.logger.warning(msg) raise TypeError(msg) from None # Validate recipients (cc:) and drop bad ones: for recipient in parse_emails(cc): result = is_email(recipient) if result: self.cc.add(result["full_email"]) if result["name"]: self.names[result["full_email"]] = result["name"] 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): result = is_email(recipient) if result: self.bcc.add(result["full_email"]) if result["name"]: self.names[result["full_email"]] = result["name"] continue self.logger.warning( "Dropped invalid Blind Carbon Copy email " "({}) specified.".format(recipient), ) # Template functionality self.tokens = {} if isinstance(tokens, dict): self.tokens.update(tokens) return @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[0], self.client_id, self.client_secret) def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ # Define any URL parameters params = { "mode": self.mode, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if len(self.cc) > 0: # 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 len(self.bcc) > 0: # 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 Address params["reply"] = formataddr( (self.reply_to["senderName"], self.reply_to["senderEmail"]), # Swap comma for its escaped url code (if detected) since # we're using that as a delimiter charset="utf-8", ) if self.channels: # Prepare our default channel params["channels"] = ",".join(self.channels) if self.region != self.template_args["region"]["default"]: # Prepare our default region params["region"] = self.region # handle from= if self.from_addr and self.names[self.from_addr] != self.app_id: params["from"] = self.names[self.from_addr] # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Store any template entries if specified params.update({f":{k}": v for k, v in self.tokens.items()}) targets = [] for target in self.targets: # ID is always present targets.append(f"@{target['id']}") if "number" in target: targets.append(f"{target['number']}") if "email" in target: targets.append(f"{target['email']}") mtype = ( f"{self.message_type}@" if self.message_type != self.default_message_type else "" ) return "{schema}://{mtype}{cid}/{secret}/{targets}?{params}".format( schema=self.secure_protocol[0], mtype=mtype, cid=self.pprint(self.client_id, privacy, safe=""), secret=self.pprint(self.client_secret, privacy, safe=""), targets=NotifyNotificationAPI.quote( "/".join(chain(targets, self._invalid_targets)), safe="/" ), params=NotifyNotificationAPI.urlencode(params), ) def __len__(self): """ Returns the number of targets associated with this notification """ return max(1, len(self.targets)) def gen_payload( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """ generates our NotificationAPI payload """ payload_ = { "type": self.message_type, } if self.mode == NotificationAPIMode.TEMPLATE: # Take a copy of our token dictionary parameters = self.tokens.copy() # Apply some defaults template values parameters["appBody"] = body parameters["appTitle"] = title parameters["appType"] = notify_type.value parameters["appId"] = self.app_id parameters["appDescription"] = self.app_desc parameters["appColor"] = self.color(notify_type) parameters["appImageUrl"] = self.image_url(notify_type) parameters["appUrl"] = self.app_url # A Simple Email Payload Template payload_.update( { "parameters": {**parameters}, } ) else: # Acquire text version of body if provided text_body = ( convert_between(NotifyFormat.HTML, NotifyFormat.TEXT, body) if self.notify_format == NotifyFormat.HTML else body ) for channel in self.channels: # Python v3.10 supports `match/case` but since Apprise aims to # be compatible with Python v3.9+, we must use if/else for the # time being if channel == NotificationAPIChannel.SMS: payload_.update( { NotificationAPIChannel.SMS: { "message": (title + "\n" + text_body) if title else text_body, }, } ) elif channel == NotificationAPIChannel.EMAIL: html_body = ( convert_between( NotifyFormat.TEXT, NotifyFormat.HTML, body ) if self.notify_format != NotifyFormat.HTML else body ) payload_.update( { NotificationAPIChannel.EMAIL: { "subject": title if title else self.app_id, "html": html_body, }, } ) if self.from_addr: payload_[NotificationAPIChannel.EMAIL].update( { "senderEmail": self.from_addr, "senderName": self.names[self.from_addr], } ) elif channel == NotificationAPIChannel.INAPP: payload_.update( { NotificationAPIChannel.INAPP: { "title": title if title else self.app_id, "image": self.image_url(notify_type), }, } ) elif channel == NotificationAPIChannel.WEB_PUSH: payload_.update( { NotificationAPIChannel.WEB_PUSH: { "title": title if title else self.app_id, "message": text_body, "icon": self.image_url(notify_type), }, } ) elif channel == NotificationAPIChannel.MOBILE_PUSH: payload_.update( { NotificationAPIChannel.MOBILE_PUSH: { "title": title if title else self.app_id, "message": text_body, }, } ) else: # channel == NotificationAPIChannel.SLACK payload_.update( { NotificationAPIChannel.SLACK: { "text": (title + "\n" + text_body) if title else text_body, }, } ) # Copy our list to work with targets = list(self.targets) if self.from_addr: payload_.update( { "options": { "email": { "fromAddress": self.from_addr, "fromName": self.names[self.from_addr], } } } ) elif self.cc or self.bcc: # Set up shell payload_.update({"options": {"email": {}}}) 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 if "email" in target: cc = self.cc - self.bcc - {target["email"]} bcc = self.bcc - {target["email"]} else: # Assume defaults cc = self.cc bcc = self.bcc # # Prepare our 'to' # payload["to"] = {**target} # Support cc/bcc if len(cc): payload["options"]["email"]["ccAddresses"] = list(cc) if len(bcc): payload["options"]["email"]["bccAddresses"] = list(bcc) yield payload def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """ Perform NotificationAPI Notification """ # error tracking (used for function return) has_error = False if not self.targets: # There is no one to email or send an sms message to; we're done self.logger.warning( "There are no NotificationAPI recipients to notify" ) return False # Prepare our URL url = ( f"{NOTIFICATIONAPI_API_LOOKUP[self.region]}/" f"{self.client_id}/sender" ) headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Authorization": self.auth_header, } for payload in self.gen_payload( body, title=title, notify_type=notify_type, **kwargs ): # Perform our post self.logger.debug( "NotificationAPI POST URL: {} (cert_verify={!r})".format( url, self.verify_certificate ) ) self.logger.debug( "NotificationAPI Payload: %s", payload["to"]["id"] ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) try: loads(r.content) except (AttributeError, TypeError, ValueError): # This gets thrown if we can't parse our JSON Response # - ValueError = r.content is Unparsable # - TypeError = r.content is None # - AttributeError = r is None self.logger.warning( "Invalid response from NotificationAPI server." ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Record our failure has_error = True continue # Reference status code status_code = r.status_code if status_code not in ( requests.codes.ok, requests.codes.accepted, ): # We had a problem status_str = ( NotifyNotificationAPI.http_response_code_lookup( status_code ) ) self.logger.warning( "Failed to send NotificationAPI notification to %s: " "%s%serror=%d", payload["to"]["id"], status_str, ", " if status_str else "", status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Record our failure has_error = True else: self.logger.info( "Sent NotificationAPI notification to %s.", payload["to"]["id"], ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending NotificationAPI " "notification to %s.", payload["to"]["id"], ) self.logger.debug("Socket Exception: {}".format(str(e))) # Record our failure has_error = True 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 load the results return results # Define our minimum requirements; defining them now saves us from # having to if/else all kinds of branches below... results["client_id"] = None results["client_secret"] = None # Prepare our targets (starting with our host) results["targets"] = [] if results["host"]: results["targets"].append( NotifyNotificationAPI.unquote(results["host"]) ) # For tracking email sources results["from_addr"] = None if "from" in results["qsd"] and len(results["qsd"]["from"]): results["from_addr"] = NotifyNotificationAPI.unquote( results["qsd"]["from"].rstrip() ) # First 2 elements are the client_id and client_secret # Following are targets results["targets"] += NotifyNotificationAPI.split_path( results["fullpath"] ) # check for our client id if "id" in results["qsd"] and len(results["qsd"]["id"]): # Store our Client ID results["client_id"] = NotifyNotificationAPI.unquote( results["qsd"]["id"] ) elif results["targets"]: # Store our Client ID results["client_id"] = results["targets"].pop(0) if "secret" in results["qsd"] and len(results["qsd"]["secret"]): # Store our Client Secret results["client_secret"] = NotifyNotificationAPI.unquote( results["qsd"]["secret"] ) elif results["targets"]: # Store our Client Secret results["client_secret"] = results["targets"].pop(0) if "region" in results["qsd"] and len(results["qsd"]["region"]): results["region"] = NotifyNotificationAPI.unquote( results["qsd"]["region"] ) if "channels" in results["qsd"] and len(results["qsd"]["channels"]): results["channels"] = NotifyNotificationAPI.unquote( results["qsd"]["channels"] ) if "mode" in results["qsd"] and len(results["qsd"]["mode"]): results["mode"] = NotifyNotificationAPI.unquote( results["qsd"]["mode"] ) if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = NotifyNotificationAPI.unquote( results["qsd"]["reply"] ) # Handling of Message Type if "type" in results["qsd"] and len(results["qsd"]["type"]): results["message_type"] = NotifyNotificationAPI.unquote( results["qsd"]["type"] ) elif results["user"]: # Pull from user results["message_type"] = NotifyNotificationAPI.unquote( results["user"] ) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append( NotifyNotificationAPI.unquote(results["qsd"]["to"]) ) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = NotifyNotificationAPI.unquote(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = NotifyNotificationAPI.unquote( results["qsd"]["bcc"] ) # Store our tokens results["tokens"] = results["qsd:"] return results apprise-1.10.0/apprise/plugins/notifico.py000066400000000000000000000305341517341665700205700ustar00rootroot00000000000000# 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. # Notifico allows you to relay notifications into IRC channels. # # 1. visit https://n.tkte.ch and sign up for an account # 2. create a project; either manually or sync with github # 3. from within the project, you can create a message hook # # the URL will look something like this: # https://n.tkte.ch/h/2144/uJmKaBW9WFk42miB146ci3Kj # ^ ^ # | | # project id message hook # # This plugin also supports taking the URL (as identified above) directly # as well. import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, validate_regex from .base import NotifyBase class NotificoFormat: # Resets all formatting Reset = "\x0f" # Formatting Bold = "\x02" Italic = "\x1d" Underline = "\x1f" BGSwap = "\x16" class NotificoColor: # Resets Color Reset = "\x03" # Colors White = "\x0300" Black = "\x0301" Blue = "\x0302" Green = "\x0303" Red = "\x0304" Brown = "\x0305" Purple = "\x0306" Orange = "\x0307" Yellow = ("\x0308",) LightGreen = "\x0309" Teal = "\x0310" LightCyan = "\x0311" LightBlue = "\x0312" Violet = "\x0313" Grey = "\x0314" LightGrey = "\x0315" class NotifyNotifico(NotifyBase): """A wrapper for Notifico Notifications.""" # The default descriptive name associated with the Notification service_name = "Notifico" # The services URL service_url = "https://n.tkte.ch" # The default protocol protocol = "notifico" # The default secure protocol secure_protocol = "notifico" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/notifico/" # Plain Text Notification URL notify_url = "https://n.tkte.ch/h/{proj}/{hook}" # The title is not used title_maxlen = 0 # The maximum allowable characters allowed in the body per message body_maxlen = 512 # Define object templates templates = ("{schema}://{project_id}/{msghook}",) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ # The Project ID is found as the first part of the URL # /1234/........................ "project_id": { "name": _("Project ID"), "type": "string", "required": True, "private": True, "regex": (r"^[0-9]+$", ""), }, # The Message Hook follows the Project ID # /..../AbCdEfGhIjKlMnOpQrStUvWX "msghook": { "name": _("Message Hook"), "type": "string", "required": True, "private": True, "regex": (r"^[a-z0-9]+$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ # You can optionally pass IRC colors into "color": { "name": _("IRC Colors"), "type": "bool", "default": True, }, # You can optionally pass IRC color into "prefix": { "name": _("Prefix"), "type": "bool", "default": True, }, }, ) def __init__(self, project_id, msghook, color=True, prefix=True, **kwargs): """Initialize Notifico Object.""" super().__init__(**kwargs) # Assign our message hook self.project_id = validate_regex( project_id, *self.template_tokens["project_id"]["regex"] ) if not self.project_id: msg = ( f"An invalid Notifico Project ID ({project_id}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Assign our message hook self.msghook = validate_regex( msghook, *self.template_tokens["msghook"]["regex"] ) if not self.msghook: msg = ( f"An invalid Notifico Message Token ({msghook}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Prefix messages with a [?] where ? identifies the message type # such as if it's an error, warning, info, or success self.prefix = prefix # Send colors self.color = color # Prepare our notification URL now: self.api_url = self.notify_url.format( proj=self.project_id, hook=self.msghook, ) 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.project_id, self.msghook) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "color": "yes" if self.color else "no", "prefix": "yes" if self.prefix else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{proj}/{hook}/?{params}".format( schema=self.secure_protocol, proj=self.pprint(self.project_id, privacy, safe=""), hook=self.pprint(self.msghook, privacy, safe=""), params=NotifyNotifico.urlencode(params), ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Wrapper to _send since we can alert more then one channel.""" # prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", } # Prepare our IRC Prefix color = "" token = "" if notify_type == NotifyType.INFO: color = NotificoColor.Teal token = "i" elif notify_type == NotifyType.SUCCESS: color = NotificoColor.LightGreen token = "βœ”" elif notify_type == NotifyType.WARNING: color = NotificoColor.Orange token = "!" elif notify_type == NotifyType.FAILURE: color = NotificoColor.Red token = "βœ—" if self.color: # Colors were specified, make sure we capture and correctly # allow them to exist inline in the message # \g<1> is less ambiguous than \1 body = re.sub(r"\\x03(\d{0,2})", r"\\x03\g<1>", body) else: # no colors specified, make sure we strip out any colors found # to make the string read-able body = re.sub(r"\\x03(\d{1,2}(,[0-9]{1,2})?)?", r"", body) # Prepare our payload payload = { "payload": ( body if not self.prefix else "{}[{}]{} {}{}{}: {}{}".format( # Token [?] at the head color if self.color else "", token, NotificoColor.Reset if self.color else "", # App ID NotificoFormat.Bold if self.color else "", self.app_id, NotificoFormat.Reset if self.color else "", # Message Body body, # Reset NotificoFormat.Reset if self.color else "", ) ), } self.logger.debug( "Notifico GET URL:" f" {self.api_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Notifico Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.get( self.api_url, params=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyNotifico.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Notifico 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 Notifico notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Notifico notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @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 first token is stored in the hostname results["project_id"] = NotifyNotifico.unquote(results["host"]) # Get Message Hook try: results["msghook"] = NotifyNotifico.split_path( results["fullpath"] )[0] except IndexError: results["msghook"] = None # Include Color results["color"] = parse_bool(results["qsd"].get("color", True)) # Include Prefix results["prefix"] = parse_bool(results["qsd"].get("prefix", True)) return results @staticmethod def parse_native_url(url): """ Support https://n.tkte.ch/h/PROJ_ID/MESSAGE_HOOK/ """ result = re.match( r"^https?://n\.tkte\.ch/h/" r"(?P[0-9]+)/" r"(?P[A-Z0-9]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: return NotifyNotifico.parse_url( "{schema}://{proj}/{hook}/{params}".format( schema=NotifyNotifico.secure_protocol, proj=result.group("proj"), hook=result.group("hook"), params=( "" if not result.group("params") else result.group("params") ), ) ) return None apprise-1.10.0/apprise/plugins/ntfy.py000066400000000000000000001000731517341665700177320ustar00rootroot00000000000000# 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 sources # - https://github.com/matrix-org/matrix-python-sdk # - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst # # Examples: # ntfys://my-topic # ntfy://ntfy.local.domain/my-topic # ntfys://ntfy.local.domain:8080/my-topic # ntfy://ntfy.local.domain/?priority=max from json import dumps, loads from os.path import basename import re from urllib.parse import quote import requests from ..attachment.base import AttachBase from ..attachment.memory import AttachMemory from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import ( is_hostname, is_ipaddr, parse_bool, parse_list, validate_regex, ) from .base import NotifyBase class NtfyMode: """Define ntfy Notification Modes.""" # App posts upstream to the developer API on ntfy's website CLOUD = "cloud" # Running a dedicated private ntfy Server PRIVATE = "private" NTFY_MODES = ( NtfyMode.CLOUD, NtfyMode.PRIVATE, ) # A Simple regular expression used to auto detect Auth mode if it isn't # otherwise specified: NTFY_AUTH_DETECT_RE = re.compile(r"tk_[^ \t]+", re.IGNORECASE) class NtfyAuth: """Define ntfy Authentication Modes.""" # Basic auth (user and password provided) BASIC = "basic" # Auth Token based TOKEN = "token" NTFY_AUTH = ( NtfyAuth.BASIC, NtfyAuth.TOKEN, ) class NtfyPriority: """Ntfy Priority Definitions.""" MAX = "max" HIGH = "high" NORMAL = "default" LOW = "low" MIN = "min" NTFY_PRIORITIES = ( NtfyPriority.MAX, NtfyPriority.HIGH, NtfyPriority.NORMAL, NtfyPriority.LOW, NtfyPriority.MIN, ) NTFY_PRIORITY_MAP = { # Maps against string 'low' but maps to Moderate to avoid # conflicting with actual ntfy mappings "l": NtfyPriority.LOW, # Maps against string 'moderate' "mo": NtfyPriority.LOW, # Maps against string 'normal' "n": NtfyPriority.NORMAL, # Maps against string 'high' "h": NtfyPriority.HIGH, # Maps against string 'emergency' "e": NtfyPriority.MAX, # Entries to additionally support (so more like Ntfy's API) # Maps against string 'min' "mi": NtfyPriority.MIN, # Maps against string 'max' "ma": NtfyPriority.MAX, # Maps against string 'default' "d": NtfyPriority.NORMAL, # support 1-5 values as well "1": NtfyPriority.MIN, # Maps against string 'moderate' "2": NtfyPriority.LOW, # Maps against string 'normal' "3": NtfyPriority.NORMAL, # Maps against string 'high' "4": NtfyPriority.HIGH, # Maps against string 'emergency' "5": NtfyPriority.MAX, } class NotifyNtfy(NotifyBase): """A wrapper for ntfy Notifications.""" # The default descriptive name associated with the Notification service_name = "ntfy" # The services URL service_url = "https://ntfy.sh/" # Insecure protocol (for those self hosted requests) protocol = "ntfy" # The default protocol secure_protocol = "ntfys" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/ntfy/" # Default upstream/cloud host if none is defined cloud_notify_url = "https://ntfy.sh" # Support attachments attachment_support = True # Maximum title length title_maxlen = 200 # Maximum body length body_maxlen = 7800 # Message size calculates title and body together overflow_amalgamate_title = True # Defines the number of bytes our JSON object can not exceed in size or we # know the upstream server will reject it. We convert these into # attachments ntfy_json_upstream_size_limit = 8000 # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 # Message time to live (if remote client isn't around to receive it) time_to_live = 2419200 # if our hostname matches the following we automatically enforce # cloud mode __auto_cloud_host = re.compile(r"ntfy\.sh", re.IGNORECASE) # Define object templates templates = ( "{schema}://{topic}", "{schema}://{host}/{targets}", "{schema}://{host}:{port}/{targets}", "{schema}://{user}@{host}/{targets}", "{schema}://{user}@{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{targets}", "{schema}://{token}@{host}/{targets}", "{schema}://{token}@{host}:{port}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", }, "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", "private": True, }, "topic": { "name": _("Topic"), "type": "string", "map_to": "targets", "regex": (r"^[a-z0-9_-]{1,64}$", "i"), }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "attach": { "name": _("Attach"), "type": "string", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "avatar_url": { "name": _("Avatar URL"), "type": "string", }, "filename": { "name": _("Attach Filename"), "type": "string", }, "click": { "name": _("Click"), "type": "string", }, "delay": { "name": _("Delay"), "type": "string", }, "email": { "name": _("Email"), "type": "string", }, "priority": { "name": _("Priority"), "type": "choice:string", "values": NTFY_PRIORITIES, "default": NtfyPriority.NORMAL, }, "xtags": { "name": _("Tags"), "type": "string", }, "actions": { "name": _("Actions"), "type": "string", }, "mode": { "name": _("Mode"), "type": "choice:string", "values": NTFY_MODES, "default": NtfyMode.PRIVATE, }, "token": { "alias_of": "token", }, "auth": { "name": _("Authentication Type"), "type": "choice:string", "values": NTFY_AUTH, "default": NtfyAuth.BASIC, }, "to": { "alias_of": "targets", }, }, ) def __init__( self, targets=None, attach=None, filename=None, click=None, delay=None, email=None, priority=None, xtags=None, actions=None, mode=None, include_image=True, avatar_url=None, auth=None, token=None, **kwargs, ): """Initialize ntfy Object.""" super().__init__(**kwargs) # Prepare our mode self.mode = ( mode.strip().lower() if isinstance(mode, str) else self.template_args["mode"]["default"] ) if self.mode not in NTFY_MODES: msg = f"An invalid ntfy Mode ({mode}) was specified." self.logger.warning(msg) raise TypeError(msg) # Show image associated with notification self.include_image = include_image # Prepare our authentication type self.auth = ( auth.strip().lower() if isinstance(auth, str) else self.template_args["auth"]["default"] ) if self.auth not in NTFY_AUTH: msg = ( f"An invalid ntfy Authentication type ({auth}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Attach a file (URL supported) self.attach = attach # Our filename (if defined) self.filename = filename # A clickthrough option for notifications # Support Internationalized URLs self.click = ( None if not isinstance(click, str) else ( click if not any(ord(char) > 127 for char in click) else quote(click, safe=":/?&=[]") ) ) # Time delay for notifications (various string formats) self.delay = delay # An email to forward notifications to self.email = email # Save our token self.token = token # The Priority of the message self.priority = ( NotifyNtfy.template_args["priority"]["default"] if not priority else next( ( v for k, v in NTFY_PRIORITY_MAP.items() if str(priority).lower().startswith(k) ), NotifyNtfy.template_args["priority"]["default"], ) ) # Any optional tags to attach to the notification self.__tags = parse_list(xtags) # Action buttons self.__actions = actions # Avatar URL # This allows a user to provide an over-ride to the otherwise # dynamically generated avatar url images self.avatar_url = avatar_url # Build list of topics topics = parse_list(targets) self.topics = [] for topic_ in topics: topic = validate_regex( topic_, *self.template_tokens["topic"]["regex"] ) if not topic: self.logger.warning( f"A specified ntfy topic ({topic_}) is invalid and will be" " ignored" ) continue self.topics.append(topic) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform ntfy Notification.""" # error tracking (used for function return) has_error = False if not len(self.topics): # We have nothing to notify; we're done self.logger.warning("There are no ntfy topics to notify") return False # Acquire image_url image_url = self.image_url(notify_type) if self.include_image and (image_url or self.avatar_url): image_url = self.avatar_url if self.avatar_url else image_url else: image_url = None # Create a copy of the topics topics = list(self.topics) while len(topics) > 0: # Retrieve our topic topic = topics.pop() if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for no, attachment in enumerate(attach): # First message only includes the text (if defined) body_ = body if not no and body else None title_ = title if not no and title else None # 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 self.logger.debug( "Preparing ntfy attachment" f" {attachment.url(privacy=True)}" ) okay, _response = self._send( topic, body=body_, title=title_, image_url=image_url, attach=attachment, ) if not okay: # We can't post our attachment; abort immediately return False else: # Send our Notification Message okay, _response = self._send( topic, body=body, title=title, image_url=image_url ) if not okay: # Mark our failure, but contiue to move on has_error = True return not has_error def _send( self, topic, body=None, title=None, attach=None, image_url=None, **kwargs, ): """Wrapper to the requests (post) object.""" # Prepare our headers headers = { "User-Agent": self.app_id, } # See https://ntfy.sh/docs/publish/#publish-as-json data = {} # Posting Parameters params = {} auth = None if self.mode == NtfyMode.CLOUD: # Cloud Service notify_url = self.cloud_notify_url else: # NotifyNtfy.PRVATE # Allow more settings to be applied now if self.auth == NtfyAuth.BASIC and self.user: auth = (self.user, self.password) elif self.auth == NtfyAuth.TOKEN: if not self.token: self.logger.warning("No Ntfy Token was specified") return False, None # Set Token headers["Authorization"] = f"Bearer {self.token}" # Prepare our ntfy Template URL schema = "https" if self.secure else "http" notify_url = f"{schema}://{self.host}" if isinstance(self.port, int): notify_url += f":{self.port}" if not attach: headers["Content-Type"] = "application/json" data["topic"] = topic virt_payload = data if self.attach: virt_payload["attach"] = self.attach if self.filename: virt_payload["filename"] = self.filename else: # Point our payload to our parameters virt_payload = params notify_url += f"/{topic}" # Prepare our Header virt_payload["filename"] = attach.name with attach as fp: data = fp.read() if image_url: headers["X-Icon"] = image_url if title: virt_payload["title"] = title if body: virt_payload["message"] = body if self.notify_format == NotifyFormat.MARKDOWN: # Support Markdown headers["X-Markdown"] = "yes" if self.priority != NtfyPriority.NORMAL: headers["X-Priority"] = self.priority if self.delay is not None: headers["X-Delay"] = self.delay if self.click is not None: headers["X-Click"] = quote(self.click, safe=":/?@&=#") if self.email is not None: headers["X-Email"] = self.email if self.__tags: headers["X-Tags"] = ",".join(self.__tags) if self.__actions: headers["X-Actions"] = self.__actions self.logger.debug( "ntfy POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) # Default response type response = None if not attach: data = dumps(data) if len(data) > self.ntfy_json_upstream_size_limit: # Convert to an attachment if self.notify_format == NotifyFormat.MARKDOWN: mimetype = "text/markdown" elif self.notify_format == NotifyFormat.TEXT: mimetype = "text/plain" else: # self.notify_format == NotifyFormat.HTML: mimetype = "text/html" attach = AttachMemory( mimetype=mimetype, content="{title}{body}".format( title=title + "\n" if title else "", body=body ), ) # Recursively send the message body as an attachment instead return self._send( topic=topic, body="", title="", attach=attach, image_url=image_url, **kwargs, ) self.logger.debug(f"ntfy Payload: {virt_payload!s}") self.logger.debug(f"ntfy Headers: {headers!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, params=params if params else None, data=data, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) 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 try: # Update our status response if we can response = loads(r.content) status_str = response.get("error", status_str) status_code = int(response.get("code", status_code)) 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. pass self.logger.warning( "Failed to send ntfy notification to topic '{}': " "{}{}error={}.".format( topic, status_str, ", " if status_str else "", status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return False, response # otherwise we were successful self.logger.info(f"Sent ntfy notification to '{notify_url}'.") return True, response except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending ntfy:{notify_url} " + "notification." ) self.logger.debug(f"Socket Exception: {e!s}") except OSError as e: self.logger.warning( "An I/O error occurred while handling {}.".format( attach.name if isinstance(attach, AttachBase) else virt_payload ) ) self.logger.debug(f"I/O Exception: {e!s}") return False, response @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. """ kwargs = [ ( self.secure_protocol if self.mode == NtfyMode.CLOUD else (self.secure_protocol if self.secure else self.protocol) ), self.host if self.mode == NtfyMode.PRIVATE else "", ( 443 if self.mode == NtfyMode.CLOUD else (self.port if self.port else (443 if self.secure else 80)) ), ] if self.mode == NtfyMode.PRIVATE: if self.auth == NtfyAuth.BASIC: kwargs.extend( [ self.user if self.user else None, self.password if self.password else None, ] ) elif self.token: # NtfyAuth.TOKEN also kwargs.append(self.token) return kwargs def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" default_port = 443 if self.secure else 80 params = { "priority": self.priority, "mode": self.mode, "image": "yes" if self.include_image else "no", "auth": self.auth, } if self.avatar_url: params["avatar_url"] = self.avatar_url if self.attach is not None: params["attach"] = self.attach if self.click is not None: params["click"] = self.click if self.delay is not None: params["delay"] = self.delay if self.email is not None: params["email"] = self.email if self.__tags: params["xtags"] = ",".join(self.__tags) if self.__actions: params["actions"] = self.__actions params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine Authentication auth = "" if self.auth == NtfyAuth.BASIC: if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyNtfy.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="", ), ) elif self.user: auth = "{user}@".format( user=NotifyNtfy.quote(self.user, safe=""), ) elif self.token: # NtfyAuth.TOKEN also auth = "{token}@".format( token=self.pprint(self.token, privacy, safe=""), ) if self.mode == NtfyMode.PRIVATE: return "{schema}://{auth}{host}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, host=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), targets="/".join( [NotifyNtfy.quote(x, safe="") for x in self.topics] ), params=NotifyNtfy.urlencode(params), ) else: # Cloud mode return "{schema}://{targets}?{params}".format( schema=self.secure_protocol, targets="/".join( [NotifyNtfy.quote(x, safe="") for x in self.topics] ), params=NotifyNtfy.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return 1 if not self.topics else len(self.topics) @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 # Set our priority if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyNtfy.unquote( results["qsd"]["priority"] ) if "attach" in results["qsd"] and len(results["qsd"]["attach"]): results["attach"] = NotifyNtfy.unquote(results["qsd"]["attach"]) results_ = NotifyBase.parse_url(results["attach"]) if results_: results["filename"] = ( None if results_["fullpath"] else basename(results_["fullpath"]) ) if "filename" in results["qsd"] and len( results["qsd"]["filename"] ): results["filename"] = basename( NotifyNtfy.unquote(results["qsd"]["filename"]) ) if "click" in results["qsd"] and len(results["qsd"]["click"]): results["click"] = NotifyNtfy.unquote(results["qsd"]["click"]) if "delay" in results["qsd"] and len(results["qsd"]["delay"]): results["delay"] = NotifyNtfy.unquote(results["qsd"]["delay"]) if "email" in results["qsd"] and len(results["qsd"]["email"]): results["email"] = NotifyNtfy.unquote(results["qsd"]["email"]) # Support both 'xtags' (canonical) and legacy 'tags'. # Storing as 'xtags' prevents the config/base parser from # misinterpreting this as an apprise-level tag filter. raw_xtags = ( results["qsd"].get("xtags") or results["qsd"].get("tags") or "" ) if raw_xtags: results["xtags"] = parse_list(NotifyNtfy.unquote(raw_xtags)) if "actions" in results["qsd"] and len(results["qsd"]["actions"]): results["actions"] = NotifyNtfy.unquote(results["qsd"]["actions"]) # Boolean to include an image or not results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyNtfy.template_args["image"]["default"] ) ) # Extract avatar url if it was specified if "avatar_url" in results["qsd"]: results["avatar_url"] = NotifyNtfy.unquote( results["qsd"]["avatar_url"] ) # Acquire our targets/topics results["targets"] = NotifyNtfy.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"] += NotifyNtfy.parse_list(results["qsd"]["to"]) # Token Specified if "token" in results["qsd"] and len(results["qsd"]["token"]): # Token presumed to be the one in use results["auth"] = NtfyAuth.TOKEN results["token"] = NotifyNtfy.unquote(results["qsd"]["token"]) # Auth override if "auth" in results["qsd"] and results["qsd"]["auth"]: results["auth"] = NotifyNtfy.unquote( results["qsd"]["auth"].strip().lower() ) if ( not results.get("auth") and results["user"] and not results["password"] ): # We can try to detect the authentication type on the formatting of # the username. Look for tk_.* # # This isn't a surfire way to do things though; it's best to # specify the auth= flag results["auth"] = ( NtfyAuth.TOKEN if NTFY_AUTH_DETECT_RE.match(results["user"]) else NtfyAuth.BASIC ) if results.get("auth") == NtfyAuth.TOKEN and not results.get("token"): if results["user"] and not results["password"]: # Make sure we properly set our token results["token"] = NotifyNtfy.unquote(results["user"]) elif results["password"]: # Make sure we properly set our token results["token"] = NotifyNtfy.unquote(results["password"]) # Mode override if "mode" in results["qsd"] and results["qsd"]["mode"]: results["mode"] = NotifyNtfy.unquote( results["qsd"]["mode"].strip().lower() ) else: # We can try to detect the mode based on the validity of the # hostname. # # This isn't a surfire way to do things though; it's best to # specify the mode= flag results["mode"] = ( NtfyMode.PRIVATE if ( ( is_hostname(results["host"]) or is_ipaddr(results["host"]) ) and results["targets"] ) else NtfyMode.CLOUD ) if results["mode"] == NtfyMode.CLOUD: # Store first entry as it can be a topic too in this case # But only if we also rule it out not being the words # ntfy.sh itself, something that starts wiht an non-alpha numeric # character: if not NotifyNtfy.__auto_cloud_host.search(results["host"]): # Add it to the front of the list for consistency results["targets"].insert(0, results["host"]) elif results["mode"] == NtfyMode.PRIVATE and not ( is_hostname(results["host"] or is_ipaddr(results["host"])) ): # Invalid Host for NtfyMode.PRIVATE return None return results @staticmethod def parse_native_url(url): """ Support https://ntfy.sh/topic """ # Quick lookup for users who want to just paste # the ntfy.sh url directly into Apprise result = re.match( r"^(http|ntfy)s?://ntfy\.sh" r"(?P/[^?]+)?" r"(?P\?.+)?$", url, re.I, ) if result: mode = f"mode={NtfyMode.CLOUD}" return NotifyNtfy.parse_url( "{schema}://{topics}{params}".format( schema=NotifyNtfy.secure_protocol, topics=( result.group("topics") if result.group("topics") else "" ), params=( f"?{mode}" if not result.group("params") else result.group("params") + f"&{mode}" ), ) ) return None apprise-1.10.0/apprise/plugins/octopush.py000066400000000000000000000405131517341665700206200ustar00rootroot00000000000000# 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 Octopush: # https://octopush.com/ # # The API reference used to build this plugin was documented here: # https://dev.octopush.com/en/sms-gateway-api-documentation/send-sms/ 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_email, is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase class OctopushType: """Octopush message types.""" PREMIUM = "sms_premium" LOW_COST = "sms_low_cost" OCTOPUSH_TYPE_MAP = { # Maps against string 'sms_premium' "p": OctopushType.PREMIUM, "sms_p": OctopushType.PREMIUM, "smsp": OctopushType.PREMIUM, "+": OctopushType.PREMIUM, # Maps against string 'sms_low_cost' "l": OctopushType.LOW_COST, "sms_l": OctopushType.LOW_COST, "smsl": OctopushType.LOW_COST, "-": OctopushType.LOW_COST, } OCTOPUSH_TYPES = ( OctopushType.PREMIUM, OctopushType.LOW_COST, ) class OctopushPurpose: """Octopush purposes.""" ALERT = "alert" WHOLESALE = "wholesale" OCTOPUSH_PURPOSES = ( OctopushPurpose.ALERT, OctopushPurpose.WHOLESALE, ) class NotifyOctopush(NotifyBase): """A wrapper for Octopush Notifications.""" # The default descriptive name associated with the Notification service_name = "Octopush" # The services URL service_url = "https://octopush.com/" # The default secure protocol secure_protocol = "octopush" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/octopush/" # Notification URLs notify_url = "https://api.octopush.com/v1/public/sms-campaign/send" # The maximum length of the body body_maxlen = 1224 # The maximum amount of phone numbers that can reside within a single # batch/frame transfer default_batch_size = 500 # 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}://{api_login}/{api_key}/{targets}", "{schema}://{sender}:{api_login}/{api_key}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "api_login": { "name": _("API Login"), "type": "string", "private": True, "required": True, }, "api_key": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, "sender": { "name": _("Sender"), "type": "string", }, "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, **{ "to": { "alias_of": "targets", }, "login": { "alias_of": "api_login", }, "key": { "alias_of": "api_key", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, "replies": { "name": _("Accept Replies"), "type": "bool", "default": False, }, "purpose": { "name": _("Purpose"), "type": "choice:string", "values": OCTOPUSH_PURPOSES, "default": OctopushPurpose.ALERT, }, "type": { "name": _("Type"), "type": "choice:string", "values": OCTOPUSH_TYPES, "default": OctopushType.PREMIUM, "map_to": "mtype", }, }, ) def __init__( self, api_login, api_key, targets=None, batch=False, sender=None, purpose=None, mtype=None, replies=False, **kwargs, ): """Initialize Octopush Object.""" super().__init__(**kwargs) # Store our API Login self.api_login = validate_regex(api_login) if not self.api_login or not is_email(self.api_login): msg = "An invalid Octopush API Login ({}) was specified.".format( api_login ) self.logger.warning(msg) raise TypeError(msg) # Store our API Key self.api_key = validate_regex(api_key) if not self.api_key: msg = "An invalid Octopush API Key ({}) was specified.".format( api_key ) self.logger.warning(msg) raise TypeError(msg) # Prepare Batch Mode Flag self.batch = batch # Prepare Replies Mode Flag self.replies = replies # The type of the message if mtype is None: self.mtype = self.template_args["type"]["default"] else: mtype = str(mtype).lower().strip() self.mtype = ( next( ( value for key, value in OCTOPUSH_TYPE_MAP.items() if mtype.startswith(key) ), None, ) if mtype else None ) if self.mtype is None: msg = "The Octopush type specified ({}) is invalid.".format(mtype) self.logger.warning(msg) raise TypeError(msg) self.purpose = ( self.template_args["purpose"]["default"] if purpose is None else validate_regex(purpose) ) if not self.purpose: msg = "The Octopush purpose specified ({}) is invalid.".format( purpose ) self.logger.warning(msg) raise TypeError(msg) self.purpose = self.purpose.lower() if self.purpose not in OCTOPUSH_PURPOSES: msg = "The Octopush purpose specified ({}) is invalid.".format( purpose ) self.logger.warning(msg) raise TypeError(msg) self.sender = None if sender: self.sender = validate_regex(sender) if not self.sender: msg = "An invalid Octopush sender ({}) was specified.".format( sender ) self.logger.warning(msg) raise TypeError(msg) # Initialize numbers list self.targets = [] # Validate targets and drop bad ones: for target in parse_phone_no(targets): result = is_phone_no(target) if result: # Store valid phone number in E.164 format self.targets.append("+{}".format(result["full"])) continue self.logger.warning( "Dropped invalid phone ({}) specified.".format(target), ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Octopush Notification.""" if not self.targets: self.logger.warning("No Octopush targets to notify.") return False # 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 # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json; charset=utf-8", "api-key": self.api_key, "api-login": self.api_login, "cache-control": "no-cache", } for index in range(0, len(self.targets), batch_size): recipients = [ {"phone_number": phone_no} for phone_no in self.targets[index : index + batch_size] ] payload = { "recipients": recipients, "text": body, "type": self.mtype, "purpose": self.purpose, "sender": self.sender if self.sender else self.app_id, "with_replies": self.replies, } p_targets = self.targets[index : index + batch_size] verbose_dest = ( ", ".join(p_targets) if len(p_targets) <= 3 else "{} recipients".format(len(p_targets)) ) # Always call throttle before any remote server i/o is made self.throttle() self.logger.debug( "Octopush POST URL: {} (cert_verify={})".format( self.notify_url, self.verify_certificate ) ) self.logger.debug("Octopush Payload: {}".format(payload)) try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.created: status_str = NotifyOctopush.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Octopush notification to {}: " "{}{}error={}.".format( verbose_dest, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) has_error = True continue self.logger.info( "Sent Octopush notification to {}.".format(verbose_dest) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Octopush:%s " "notification.", verbose_dest, ) self.logger.debug("Socket Exception: %s", str(e)) has_error = True continue return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique.""" return ( self.secure_protocol, self.api_login, self.api_key, self.sender, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = { "batch": "yes" if self.batch else "no", "replies": "yes" if self.replies else "no", "type": self.mtype, "purpose": self.purpose, } params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{sender}{api_login}/{api_key}/{targets}?{params}".format( schema=self.secure_protocol, sender="{}:".format(NotifyOctopush.quote(self.sender, safe="")) if self.sender else "", api_login=self.pprint(self.api_login, privacy, safe="@"), api_key=self.pprint( self.api_key, privacy, mode=PrivacyMode.Secret, safe="", ), targets="/".join( [NotifyOctopush.quote(x, safe="+") for x in self.targets] ), params=NotifyOctopush.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" 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: return results tokens = NotifyOctopush.split_path(results["fullpath"]) if "key" in results["qsd"] and len(results["qsd"]["key"]): results["api_key"] = NotifyOctopush.unquote(results["qsd"]["key"]) elif tokens: results["api_key"] = NotifyOctopush.unquote(tokens.pop(0)) # The remaining elements are the phone numbers we want to contact results["targets"] = tokens if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyOctopush.parse_phone_no( results["qsd"]["to"] ) if "login" in results["qsd"] and len(results["qsd"]["login"]): results["api_login"] = NotifyOctopush.unquote( results["qsd"]["login"] ) elif results["user"] or results["password"]: results["api_login"] = "{}@{}".format( NotifyOctopush.unquote(results["user"]) if not results["password"] else NotifyOctopush.unquote(results["password"]), NotifyOctopush.unquote(results["host"]), ) results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyOctopush.template_args["batch"]["default"] ) ) results["replies"] = parse_bool( results["qsd"].get( "replies", NotifyOctopush.template_args["replies"]["default"], ) ) if "type" in results["qsd"] and len(results["qsd"]["type"]): results["mtype"] = NotifyOctopush.unquote(results["qsd"]["type"]) if "purpose" in results["qsd"] and len(results["qsd"]["purpose"]): results["purpose"] = NotifyOctopush.unquote( results["qsd"]["purpose"] ) if "sender" in results["qsd"] and len(results["qsd"]["sender"]): results["sender"] = NotifyOctopush.unquote( results["qsd"]["sender"] ) elif results["user"] and results["password"]: results["sender"] = NotifyOctopush.unquote(results["user"]) return results apprise-1.10.0/apprise/plugins/office365.py000066400000000000000000001445101517341665700204470ustar00rootroot00000000000000# 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 Details: # https://docs.microsoft.com/en-us/previous-versions/office/\ # office-365-api/?redirectedfrom=MSDN # Information on sending an email: # https://docs.microsoft.com/en-us/graph/api/user-sendmail\ # ?view=graph-rest-1.0&tabs=http # # Org mode uses Application Permissions (not Delegated Permissions): # - Scopes required: Mail.Send # - For Large Attachments: Mail.ReadWrite # - For Email Lookups: User.Read.All # # Personal mode uses Delegated Permissions for consumer accounts # (@live.com, @hotmail.com, @outlook.com, etc.): # - Scopes required: Mail.Send offline_access # - Requires a pre-obtained refresh_token (via device code flow) # - See the plugin documentation for setup instructions # from datetime import datetime, timedelta import json import logging from uuid import uuid4 import requests from .. import exception from ..common import NotifyFormat, NotifyType, PersistentStoreMode from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_email, parse_emails, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase class Office365Mode: """Operating mode for the Office 365 plugin.""" # Organizational (app-only / daemon): client_credentials grant. # Requires a Microsoft Entra ID tenant admin. ORG = "org" # Personal consumer accounts: delegated refresh_token grant to # the /consumers endpoint. Works with @live.com, @hotmail.com, # @outlook.com, and other Microsoft consumer domains. PERSONAL = "personal" OFFICE365_MODES = ( Office365Mode.ORG, Office365Mode.PERSONAL, ) # Microsoft personal (consumer) account domains. Sources whose domain # matches an entry here are automatically routed through the personal # refresh_token OAuth flow rather than the organizational # client_credentials flow. Use mode= to override auto-detection. OFFICE365_PERSONAL_DOMAINS = frozenset( ( # outlook.com and regional variants "outlook.com", "outlook.at", "outlook.be", "outlook.ca", "outlook.cl", "outlook.co.nz", "outlook.co.uk", "outlook.com.ar", "outlook.com.au", "outlook.com.br", "outlook.com.mx", "outlook.cz", "outlook.de", "outlook.dk", "outlook.es", "outlook.fi", "outlook.fr", "outlook.hu", "outlook.ie", "outlook.in", "outlook.it", "outlook.jp", "outlook.kr", "outlook.lv", "outlook.my", "outlook.nl", "outlook.ph", "outlook.pt", "outlook.rs", "outlook.sa", "outlook.sg", "outlook.sk", # hotmail.com and regional variants "hotmail.com", "hotmail.at", "hotmail.be", "hotmail.ca", "hotmail.cl", "hotmail.co.uk", "hotmail.com.ar", "hotmail.com.au", "hotmail.com.br", "hotmail.com.mx", "hotmail.cz", "hotmail.de", "hotmail.dk", "hotmail.es", "hotmail.fi", "hotmail.fr", "hotmail.gr", "hotmail.hu", "hotmail.ie", "hotmail.it", "hotmail.nl", "hotmail.no", "hotmail.pt", "hotmail.rs", "hotmail.se", # live.com and regional variants "live.at", "live.be", "live.ca", "live.cl", "live.co.nz", "live.co.uk", "live.com", "live.com.ar", "live.com.au", "live.com.mx", "live.de", "live.dk", "live.es", "live.fi", "live.fr", "live.gr", "live.hu", "live.ie", "live.in", "live.it", "live.jp", "live.mx", "live.my", "live.nl", "live.no", "live.ph", "live.pt", "live.rs", "live.se", "live.sg", # other Microsoft consumer domains "msn.com", ) ) class NotifyOffice365(NotifyBase): """A wrapper for Office 365 Notifications.""" # The default descriptive name associated with the Notification service_name = "Office 365" # The services URL service_url = "https://office.com/" # The default protocol secure_protocol = ("azure", "o365") # 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/office365/" # URL to Microsoft Graph Server graph_url = "https://graph.microsoft.com" # Org mode authentication URL (client_credentials grant) auth_url = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" # Personal mode authentication URL (refresh_token grant). # /consumers services MSA (personal Microsoft accounts) only. personal_auth_url = ( "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" ) # Delegated scopes for personal mode. # offline_access is required to receive a rotating refresh_token. personal_scope = "https://graph.microsoft.com/Mail.Send offline_access" # Support attachments attachment_support = True # Our default is to no not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.AUTO # the maximum size an attachment can be for it to be allowed to be # uploaded inline with the current email going out (one http post) # Anything larger than this and a second PUT request is required to # the outlook server to post the content through reference. # Currently (as of 2025.10.06) this was documented to be 3MB outlook_attachment_inline_max = 3145728 # Use all the direct application permissions you have configured for # your app. The endpoint should issue a token for the ones associated # with the resource you want to use. # see https://docs.microsoft.com/en-us/azure/active-directory/develop/\ # v2-permissions-and-consent#the-default-scope scope = ".default" # Default Notify Format notify_format = NotifyFormat.HTML # Define object templates templates = ( # Org mode (app-only / daemon auth): tenant required "{schema}://{source}/{tenant}/{client_id}/{secret}", "{schema}://{source}/{tenant}/{client_id}/{secret}/{targets}", # Personal mode (delegated refresh_token auth): no tenant "{schema}://{source}/{client_id}/{secret}", "{schema}://{source}/{client_id}/{secret}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "tenant": { "name": _("Tenant Domain"), "type": "string", "required": True, "private": True, "regex": (r"^[a-z0-9-]+$", "i"), }, "source": { "name": _("Account Email or Object ID"), "type": "string", "required": True, }, "client_id": { "name": _("Client ID"), "type": "string", "required": True, "private": True, "regex": (r"^[a-z0-9-]+$", "i"), }, "secret": { "name": _("Client Secret"), "type": "string", "private": True, "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, **{ "oauth_id": { "alias_of": "client_id", }, "oauth_secret": { "alias_of": "secret", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, "reply_to": { "name": _("Reply To"), "type": "list:string", }, "mode": { "name": _("Mode"), "type": "choice:string", "values": OFFICE365_MODES, "default": Office365Mode.ORG, }, }, ) def __init__( self, tenant=None, client_id=None, secret=None, source=None, targets=None, cc=None, bcc=None, reply_to=None, mode=None, **kwargs, ): """Initialize Office 365 Object.""" super().__init__(**kwargs) # Resolve mode β€” explicit parameter wins, then auto-detect from domain if mode and isinstance(mode, str): _mode = mode.lower() if _mode not in OFFICE365_MODES: msg = f"The Office 365 mode specified ({mode}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.mode = _mode else: _src = is_email(source) if source else None self.mode = ( Office365Mode.PERSONAL if _src and _src["domain"].lower() in OFFICE365_PERSONAL_DOMAINS else Office365Mode.ORG ) # Store our email/ObjectID Source self.source = source # Client Key (associated with generated OAuth2 Login) self.client_id = validate_regex( client_id, *self.template_tokens["client_id"]["regex"] ) if not self.client_id: msg = ( "An invalid Office 365 Client OAuth2 ID " f"({client_id}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Client Secret (org) or seed Refresh Token (personal) self.secret = validate_regex(secret) if not self.secret: msg = ( "An invalid Office 365 Client OAuth2 Secret " f"({secret}) was specified." ) self.logger.warning(msg) raise TypeError(msg) if self.mode == Office365Mode.ORG: # Tenant identifier β€” required for org mode self.tenant = validate_regex( tenant, *self.template_tokens["tenant"]["regex"] ) if not self.tenant: msg = f"An invalid Office 365 Tenant ({tenant}) was specified." self.logger.warning(msg) raise TypeError(msg) else: # Personal mode requires no tenant self.tenant = None # For tracking our email -> name lookups self.names = {} # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Parse our targets self.targets = [] if targets: for recipient in parse_emails(targets): # Validate recipients (to:) and drop bad ones: result = is_email(recipient) if result: # Add our email to our target list self.targets.append( ( result["name"] if result["name"] else False, result["full_email"], ) ) continue self.logger.warning( f"Dropped invalid To email ({recipient}) specified." ) else: result = is_email(self.source) if not result: self.logger.warning("No Target Office 365 Email Detected") else: # If our target email list is empty we want to add ourselves # to it self.targets.append((False, self.source)) # 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( f"Dropped invalid Carbon Copy email ({recipient}) specified.", ) # 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 " f"({recipient}) specified.", ) # Acquire Reply-To addresses self.reply_to = set() 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( f"Dropped invalid Reply-To email ({recipient}) specified.", ) # Our access token is acquired upon a successful authentication self.token = None # Presume that our token has expired 'now' self.token_expiry = datetime.now() # Set up sender identity. # Personal mode: source must be an email β€” resolved directly. # Org mode: source may be an Object ID β€” resolved via API on first # send when necessary. result = is_email(self.source) if self.mode == Office365Mode.PERSONAL: if not result: msg = ( "A valid source email address is required for personal " f"mode; got ({self.source})." ) self.logger.warning(msg) raise TypeError(msg) self.from_email = result["full_email"] self.from_name = result["name"] or None self.source_is_object_id = False else: # Org mode: use cached from_email; fall back to API lookup self.from_email = self.store.get("from") if result: self.from_email = result["full_email"] self.from_name = result["name"] or self.store.get("name") else: self.from_name = self.store.get("name") self.source_is_object_id = bool( not result and validate_regex( self.source, r"^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$", "i", ) ) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Office 365 Notification.""" # error tracking (used for function return) has_error = False if not self.targets: # There is no one to email; we're done self.logger.warning("There are no Email recipients to notify") return False # For org mode, perform from_email lookup when source is an Object ID. # Personal mode always has from_email set in __init__. if self.mode == Office365Mode.ORG and self.from_email is None: if not self.authenticate(): # We could not authenticate ourselves; we're done return False # Acquire our from_email url = f"https://graph.microsoft.com/v1.0/users/{self.source}" postokay, response = self._fetch(url=url, method="GET") if not postokay: if getattr(self, "source_is_object_id", False): self.logger.warning( "The specifieg Object ID could not be resolved in " "this tenant. Verify the Object ID exists in the " "selected directory, or use a email address instead." ) return False self.logger.warning( "Could not acquire From email address; ensure " '"User.Read.All" Application scope is set!' ) else: # Acquire our from_email (if possible) from_email = response.get("mail") or response.get( "userPrincipalName" ) result = is_email(from_email) if not result: self.logger.warning( "Could not get From email from the Azure endpoint." ) # Prevent re-occuring upstream fetches for info that # isn't there self.from_email = False else: # Store our email for future reference self.from_email = result["full_email"] self.store.set("from", result["full_email"]) self.from_name = response.get("displayName") if self.from_name: self.store.set("name", self.from_name) # Setup our Content Type content_type = ( "HTML" if self.notify_format == NotifyFormat.HTML else "Text" ) # Prepare our payload payload = { "message": { "subject": title, "body": { "contentType": content_type, "content": body, }, }, # Below takes a string (not bool) of either 'true' or 'false' "saveToSentItems": "true", } if self.from_email: # Apply from email if it is known payload["message"].update( { "from": { "emailAddress": { "address": self.from_email, "name": self.from_name or self.app_id, } }, } ) if self.reply_to: reply_to_list = [] for addr in self.reply_to: payload_ = {"address": addr} if self.names.get(addr): payload_["name"] = self.names[addr] reply_to_list.append({"emailAddress": payload_}) payload["message"]["replyTo"] = reply_to_list # Create a copy of the email list emails = list(self.targets) # Define our URL to post to if self.mode == Office365Mode.PERSONAL: url = f"{self.graph_url}/v1.0/me/sendMail" else: url = f"{self.graph_url}/v1.0/users/{self.source}/sendMail" # Prepare our Draft URL if self.mode == Office365Mode.PERSONAL: draft_url = f"{self.graph_url}/v1.0/me/messages" else: draft_url = f"{self.graph_url}/v1.0/users/{self.source}/messages" small_attachments = [] large_attachments = [] # draft emails drafts = [] 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 Office 365 attachment" f" {attachment.url(privacy=True)}." ) return False if len(attachment) > self.outlook_attachment_inline_max: # Messages larger then xMB need to be uploaded after; a # draft email must be prepared; below is our session large_attachments.append( { "obj": attachment, "name": ( attachment.name if attachment.name else f"file{no:03}.dat" ), } ) continue try: # Prepare our Attachment in Base64 small_attachments.append( { "@odata.type": "#microsoft.graph.fileAttachment", # Name of attachment (as it appears in email) "name": ( attachment.name if attachment.name else f"file{no:03}.dat" ), # MIME type of the attachment "contentType": "attachment.mimetype", # Base64 Content "contentBytes": attachment.base64(), } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Office 365 attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Office 365 attachment" f" {attachment.url(privacy=True)}" ) if small_attachments: # Store Attachments payload["message"]["attachments"] = small_attachments while len(emails): # authenticate ourselves if we aren't already; but this function # also tracks if our token we have is still valid and will # re-authenticate ourselves if nessisary. if not self.authenticate(): # We could not authenticate ourselves; we're done return False # Get our email to notify to_name, to_addr = emails.pop(0) # Strip target out of cc list if in To or Bcc cc = self.cc - self.bcc - {to_addr} # Strip target out of bcc list if in To bcc = self.bcc - {to_addr} # Prepare our email payload["message"]["toRecipients"] = [ {"emailAddress": {"address": to_addr}} ] if to_name: # Apply our To Name payload["message"]["toRecipients"][0]["emailAddress"][ "name" ] = to_name self.logger.debug( "{}Email To: {}".format( "Draft" if large_attachments else "", to_addr ) ) if cc: # Prepare our CC list payload["message"]["ccRecipients"] = [] for addr in cc: payload_ = {"address": addr} if self.names.get(addr): payload_["name"] = self.names[addr] # Store our address in our payload payload["message"]["ccRecipients"].append( {"emailAddress": payload_} ) self.logger.debug( "{}Email Cc: {}".format( "Draft" if large_attachments else "", ", ".join( [ "{}{}".format( ( "" if self.names.get(e) else f"{self.names[e]}: " ), e, ) for e in cc ] ), ) ) if bcc: # Prepare our BCC list payload["message"]["bccRecipients"] = [] for addr in bcc: payload_ = {"address": addr} if self.names.get(addr): payload_["name"] = self.names[addr] # Store our address in our payload payload["message"]["bccRecipients"].append( {"emailAddress": payload_} ) self.logger.debug( "{}Email Bcc: {}".format( "Draft" if large_attachments else "", ", ".join( [ "{}{}".format( ( "" if self.names.get(e) else f"{self.names[e]}: " ), e, ) for e in bcc ] ), ) ) # Perform upstream post postokay, response = self._fetch( url=url if not large_attachments else draft_url, payload=payload, ) # Test if we were okay if not postokay: has_error = True elif large_attachments: # We have large attachments now to upload and associate with # our message. We need to prepare a draft message; acquire # the message-id associated with it and then attach the file # via this means. # Acquire our Draft ID to work with message_id = response.get("id") if not message_id: self.logger.warning( "Email Draft ID could not be retrieved" ) has_error = True continue self.logger.debug(f"Email Draft ID: {message_id}") # In future, the below could probably be called via async has_attach_error = False for attachment in large_attachments: if not self.upload_attachment( attachment["obj"], message_id, attachment["name"] ): self.logger.warning( "Could not prepare attachment session for %s", attachment["name"], ) has_error = True has_attach_error = True # Take early exit break if has_attach_error: continue # Send off our draft if self.mode == Office365Mode.PERSONAL: attach_url = ( "https://graph.microsoft.com/v1.0" f"/me/messages/{message_id}/send" ) else: attach_url = ( "https://graph.microsoft.com/v1.0/users" f"/{self.source}/messages/{message_id}/send" ) # Trigger our send postokay, response = self._fetch(url=attach_url) if not postokay: self.logger.warning( "Could not send drafted email id: {} ", message_id ) has_error = True continue # Memory management del small_attachments del large_attachments del drafts return not has_error def upload_attachment(self, attachment, message_id, name=None): """Uploads an attachment to a session.""" # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Office 365 attachment" f" {attachment.url(privacy=True)}." ) return False # Our Session URL if self.mode == Office365Mode.PERSONAL: url = ( f"{self.graph_url}/v1.0/me/message/{message_id}" "/attachments/createUploadSession" ) else: url = ( f"{self.graph_url}/v1.0/users/{self.source}" f"/message/{message_id}" "/attachments/createUploadSession" ) file_size = len(attachment) payload = { "AttachmentItem": { "attachmentType": "file", "name": ( name if name else ( attachment.name if attachment.name else f"{uuid4()!s}.dat" ) ), # MIME type of the attachment "contentType": attachment.mimetype, "size": file_size, } } if not self.authenticate(): # We could not authenticate ourselves; we're done return False # Get our Upload URL postokay, response = self._fetch(url, payload) if not postokay: return False upload_url = response.get("uploadUrl") if not upload_url: return False start_byte = 0 postokay = False response = None for chunk in attachment.chunk(): end_byte = start_byte + len(chunk) - 1 # Define headers for this chunk headers = { "User-Agent": self.app_id, "Content-Length": str(len(chunk)), "Content-Range": ( f"bytes {start_byte}-{end_byte}/{file_size}" ), } # Upload the chunk postokay, response = self._fetch( upload_url, chunk, headers=headers, content_type=None, method="PUT", ) if not postokay: return False # Return our Upload URL return postokay def authenticate(self): """Logs into and acquires us an authentication token to work with.""" if self.token and self.token_expiry > datetime.now(): # If we're already authenticated and our token is still valid self.logger.debug(f"Already authenticate with token {self.token}") return True # If we reach here, we've either expired, or we need to authenticate # for the first time. if self.mode == Office365Mode.PERSONAL: return self._personal_authenticate() return self._org_authenticate() def _org_authenticate(self): """Acquires an access token via client_credentials (org mode).""" # Prepare our payload payload = { "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.secret, "scope": f"{self.graph_url}/{self.scope}", } # Prepare our URL url = self.auth_url.format(tenant=self.tenant) # A response looks like the following: # { # "token_type": "Bearer", # "expires_in": 3599, # "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSzI1NiIsInNBXBP..." # } # # Where expires_in defines the number of seconds the key is valid for # before it must be renewed. # Alternatively, this could happen too... # { # "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" # } postokay, response = self._fetch( url=url, payload=payload, content_type="application/x-www-form-urlencoded", ) if not postokay: return False # Reset our token self.token = None try: # Extract our time from our response and subtract 10 seconds from # it to give us some wiggle/grace period to re-authenticate if we # need to self.token_expiry = datetime.now() + timedelta( seconds=int(response.get("expires_in")) - 10 ) except (ValueError, AttributeError, TypeError): # ValueError: expires_in wasn't an integer # TypeError: expires_in was None # AttributeError: we could not extract anything from response return False # Go ahead and store our token if it's available self.token = response.get("access_token") # We're authenticated return bool(self.token) def _personal_authenticate(self): """Acquires an access token via refresh_token (personal mode).""" # Prefer the stored (most recently rotated) token over the seed # value from the URL β€” ensures we always use the latest credential refresh_token = self.store.get("refresh_token") or self.secret payload = { "grant_type": "refresh_token", "client_id": self.client_id, "refresh_token": refresh_token, "scope": self.personal_scope, } postokay, response = self._fetch( url=self.personal_auth_url, payload=payload, content_type="application/x-www-form-urlencoded", ) if not postokay: return False self.token = None try: self.token_expiry = datetime.now() + timedelta( seconds=int(response.get("expires_in")) - 10 ) except (ValueError, AttributeError, TypeError): return False # Rotate the refresh token β€” store the newest one for next send. # url() will emit this stored token so configs stay current. new_refresh = response.get("refresh_token") if new_refresh: self.store.set("refresh_token", new_refresh) self.token = response.get("access_token") return bool(self.token) def _fetch( self, url, payload=None, headers=None, content_type="application/json", method="POST", ): """Wrapper to request object.""" # Prepare our headers: if not headers: headers = { "User-Agent": self.app_id, "Content-Type": content_type, } if self.token: # Are we authenticated? headers["Authorization"] = "Bearer " + self.token # Default content response object content = {} # 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( "Office 365 %s URL:" f" {url} (cert_verify={self.verify_certificate})", method, ) self.logger.debug( "Office 365 Payload: %s", sanitize_payload(payload) ) # Always call throttle before any remote server i/o is made self.throttle() # fetch function req = ( requests.post if method == "POST" else (requests.put if method == "PUT" else requests.get) ) try: r = req( url, data=( json.dumps(payload) if content_type and content_type.endswith("/json") else payload ), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.created, requests.codes.accepted, ): # We had a problem status_str = NotifyOffice365.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Office 365 %s to {}: {}error={}.".format( url, ", " if status_str else "", r.status_code ), method, ) # A Response could look like this if a Scope element was not # found: # { # "error": { # "code": "MissingClaimType", # "message":"The token is missing the claim type \'oid\'.", # "innerError": { # "oAuthEventOperationId":" 7abe20-339f-4659-9381-38f52", # "oAuthEventcV": "xsOSpAHSHVm3Tp4SNH5oIA.1.1", # "errorUrl": "https://url", # "requestId": "2328ea-ec9e-43a8-80f4-164c", # "date":"2024-12-01T02:03:13" # }} # } # Error 403; the below is returned if User.Read.All was not # granted and a user lookup was attempted. # { # "error": { # "code": "Authorization_RequestDenied", # "message": # "Insufficient privileges to complete the operation.", # "innerError": { # "date": "2024-12-06T00:15:57", # "request-id": # "48fdb3e7-2f1a-4f45-a5a0-99b8b851278b", # "client-request-id": "48f-2f1a-4f45-a5a0-99b8" # } # } # } # Another response type (error 415): # { # "error": { # "code": "RequestBodyRead", # "message": "A missing or empty content type header was \ # found when trying to read a message. The content \ # type header is required.", # } # } self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # Mark our failure return (False, content) try: content = json.loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} except requests.RequestException as e: self.logger.warning( f"Exception received when sending Office 365 %s to {url}: ", method, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure 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. """ if self.mode == Office365Mode.PERSONAL: return ( self.secure_protocol[0], self.source, self.client_id, self.secret, ) return ( self.secure_protocol[0], self.source, self.tenant, self.client_id, self.secret, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Extend our parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) # Include mode only for personal β€” org is the default and omitting # it keeps existing org URLs clean if self.mode == Office365Mode.PERSONAL: params["mode"] = self.mode if self.cc: # Handle our Carbon Copy Addresses params["cc"] = ",".join( [ "{}{}".format( "" if not self.names.get(e) else f"{self.names[e]}:", e, ) for e in sorted(self.cc) ] ) if self.bcc: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join( [ "{}{}".format( "" if not self.names.get(e) else f"{self.names[e]}:", e, ) for e in sorted(self.bcc) ] ) if self.reply_to: params["reply_to"] = ",".join( [ "{}{}".format( "" if not self.names.get(e) else f"{self.names[e]}:", e, ) for e in sorted(self.reply_to) ] ) targets_str = "/".join( [ NotifyOffice365.quote( "{}{}".format("" if not e[0] else f"{e[0]}:", e[1]), safe="@", ) for e in self.targets ] ) if self.mode == Office365Mode.PERSONAL: # Emit the most current refresh token β€” prefer the stored # (rotated) value over the original seed so configs stay valid current_secret = self.store.get("refresh_token") or self.secret return ( "{schema}://{source}/{client_id}/{secret}" "/{targets}/?{params}".format( schema=self.secure_protocol[0], source=self.source, client_id=self.pprint(self.client_id, privacy, safe=""), secret=self.pprint( current_secret, privacy, mode=PrivacyMode.Secret, safe="", ), targets=targets_str, params=NotifyOffice365.urlencode(params), ) ) return ( "{schema}://{source}/{tenant}/{client_id}/{secret}" "/{targets}/?{params}".format( schema=self.secure_protocol[0], tenant=self.pprint(self.tenant, privacy, safe=""), # email does not need to be escaped because it should # already be a valid host and username at this point source=self.source, client_id=self.pprint(self.client_id, privacy, safe=""), secret=self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ), targets=targets_str, params=NotifyOffice365.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, verify_host=False, plus_to_space=True ) if not results: # We're done early as we couldn't load the results return results # Now make a list of all our path entries # We need to read each entry back one at a time in reverse order # where each email found we mark as a target. Once we run out # of targets, the presume the remainder of the entries are part # of the secret key (since it can contain slashes in it) entries = NotifyOffice365.split_path(results["fullpath"]) # Initialize our tenant results["tenant"] = None # Legacy source alias in query-string form # From Email if "from" in results["qsd"] and len(results["qsd"]["from"]): # Extract the sending account's information results["source"] = NotifyOffice365.unquote(results["qsd"]["from"]) # If tenant is occupied, then the user defined makes up our source elif results["user"]: results["source"] = "{}@{}".format( NotifyOffice365.unquote(results["user"]), NotifyOffice365.unquote(results["host"]), ) else: # Object ID instead of email results["source"] = NotifyOffice365.unquote(results["host"]) # Detect mode β€” explicit ?mode= wins, then auto-detect from domain results["mode"] = None if "mode" in results["qsd"] and results["qsd"]["mode"]: _mode = results["qsd"]["mode"].lower() if _mode in OFFICE365_MODES: results["mode"] = _mode if results["mode"] is None: _src = is_email(results.get("source", "")) results["mode"] = ( Office365Mode.PERSONAL if _src and _src["domain"].lower() in OFFICE365_PERSONAL_DOMAINS else Office365Mode.ORG ) if results["mode"] == Office365Mode.ORG: # Org mode: first path segment is the tenant # Tenant if "tenant" in results["qsd"] and len(results["qsd"]["tenant"]): # Extract the Tenant from the argument results["tenant"] = NotifyOffice365.unquote( results["qsd"]["tenant"] ) elif entries: results["tenant"] = NotifyOffice365.unquote(entries.pop(0)) # OAuth2 ID (common to both modes; next path segment after tenant) if "oauth_id" in results["qsd"] and len(results["qsd"]["oauth_id"]): # Extract the API Key from an argument results["client_id"] = NotifyOffice365.unquote( results["qsd"]["oauth_id"] ) elif entries: # Get our client_id; it is the first entry on the path results["client_id"] = NotifyOffice365.unquote(entries.pop(0)) # # Prepare our target listing # results["targets"] = [] while entries: # Pop the last entry entry = NotifyOffice365.unquote(entries.pop(-1)) if is_email(entry): # Store our email and move on results["targets"].append(entry) continue # If we reach here, the entry we just popped is part of the # secret key, so put it back entries.append(NotifyOffice365.quote(entry, safe="")) # We're done break # OAuth2 Secret / Refresh Token if "oauth_secret" in results["qsd"] and len( results["qsd"]["oauth_secret"] ): # Extract the API Secret from an argument results["secret"] = NotifyOffice365.unquote( results["qsd"]["oauth_secret"] ) else: # Assemble our secret key which is a combination of the host # followed by all entries in the full path that follow up until # the first email results["secret"] = "/".join( [NotifyOffice365.unquote(x) for x in entries] ) # 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"] += NotifyOffice365.parse_list( results["qsd"]["to"] ) # 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_to" in results["qsd"] and len(results["qsd"]["reply_to"]): results["reply_to"] = results["qsd"]["reply_to"] return results apprise-1.10.0/apprise/plugins/one_signal.py000066400000000000000000000570271517341665700211020ustar00rootroot00000000000000# 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. # One Signal requires that you've signed up with the service and # generated yourself an API Key and APP ID. # Sources: # - https://documentation.onesignal.com/docs/accounts-and-keys # - https://documentation.onesignal.com/reference/create-notification from itertools import chain from json import dumps import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.base64 import decode_b64_dict, encode_b64_dict from ..utils.parse import is_email, parse_bool, parse_list, validate_regex from .base import NotifyBase class OneSignalCategory: """We define the different category types that we can notify via OneSignal.""" PLAYER = "include_player_ids" EMAIL = "include_email_tokens" USER = "include_external_user_ids" SEGMENT = "included_segments" ONESIGNAL_CATEGORIES = ( OneSignalCategory.PLAYER, OneSignalCategory.EMAIL, OneSignalCategory.USER, OneSignalCategory.SEGMENT, ) class NotifyOneSignal(NotifyBase): """A wrapper for OneSignal Notifications.""" # The default descriptive name associated with the Notification service_name = "OneSignal" # The services URL service_url = "https://onesignal.com" # The default protocol secure_protocol = "onesignal" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/onesignal/" # Notification notify_url = "https://api.onesignal.com/notifications" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # The maximum allowable batch sizes per message default_batch_size = 2000 # Define object templates templates = ( "{schema}://{app}@{apikey}/{targets}", "{schema}://{template}:{app}@{apikey}/{targets}", ) # Define our template template_tokens = dict( NotifyBase.template_tokens, **{ # The App_ID is a UUID # such as: 8250eaf6-1a58-489e-b136-7c74a864b434 "app": { "name": _("App ID"), "type": "string", "private": True, "required": True, }, "template": { "name": _("Template"), "type": "string", "private": True, }, "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, "target_player": { "name": _("Target Player ID"), "type": "string", "map_to": "targets", }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "target_user": { "name": _("Target User"), "type": "string", "prefix": "@", "map_to": "targets", }, "target_segment": { "name": _("Include Segment"), "type": "string", "prefix": "#", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) template_args = dict( NotifyBase.template_args, **{ "template": { "alias_of": "template", }, "subtitle": { "name": _("Subtitle"), "type": "string", }, "language": { "name": _("Language"), "type": "string", "default": "en", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "contents": { "name": _("Enable Contents"), "type": "bool", "default": True, "map_to": "use_contents", }, "decode": { "name": _("Decode Template Args"), "type": "bool", "default": False, "map_to": "decode_tpl_args", }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) # Define our token control template_kwargs = { "custom": { "name": _("Custom Data"), "prefix": ":", }, "postback": { "name": _("Postback Data"), "prefix": "+", }, } def __init__( self, app, apikey, targets=None, include_image=True, template=None, subtitle=None, language=None, batch=None, use_contents=None, decode_tpl_args=None, custom=None, postback=None, **kwargs, ): """Initialize OneSignal.""" super().__init__(**kwargs) # The apikey associated with the account self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid OneSignal API key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # The App ID associated with the account self.app = validate_regex(app) if not self.app: msg = f"An invalid OneSignal Application ID ({app}) was specified." self.logger.warning(msg) raise TypeError(msg) # Prepare Batch Mode Flag self.batch_size = ( self.default_batch_size if ( batch if batch is not None else self.template_args["batch"]["default"] ) else 1 ) # Prepare Use Contents Flag self.use_contents = bool( use_contents if use_contents is not None else self.template_args["contents"]["default"] ) # Prepare Decode Template Arguments Flag self.decode_tpl_args = bool( decode_tpl_args if decode_tpl_args is not None else self.template_args["decode"]["default"] ) # Place a thumbnail image inline with the message body self.include_image = include_image # Our Assorted Types of Targets self.targets = { OneSignalCategory.PLAYER: [], OneSignalCategory.EMAIL: [], OneSignalCategory.USER: [], OneSignalCategory.SEGMENT: [], } # Assign our template (if defined) self.template_id = template # Assign our subtitle (if defined) self.subtitle = subtitle # Our Language self.language = ( language.strip().lower()[0:2] if language else NotifyOneSignal.template_args["language"]["default"] ) if not self.language or len(self.language) != 2: msg = f"An invalid OneSignal Language ({language}) was specified." self.logger.warning(msg) raise TypeError(msg) # Sort our targets for target_ in parse_list(targets): target = target_.strip() if len(target) < 2: self.logger.debug(f"Ignoring OneSignal Entry: {target}") continue if target.startswith( NotifyOneSignal.template_tokens["target_user"]["prefix"] ): self.targets[OneSignalCategory.USER].append(target) self.logger.debug( "Detected OneSignal UserID:" f" {self.targets[OneSignalCategory.USER][-1]}" ) continue if target.startswith( NotifyOneSignal.template_tokens["target_segment"]["prefix"] ): self.targets[OneSignalCategory.SEGMENT].append(target) self.logger.debug( "Detected OneSignal Include Segment:" f" {self.targets[OneSignalCategory.SEGMENT][-1]}" ) continue result = is_email(target) if result: self.targets[OneSignalCategory.EMAIL].append( result["full_email"] ) self.logger.debug( "Detected OneSignal Email:" f" {self.targets[OneSignalCategory.EMAIL][-1]}" ) else: # Add element as Player ID self.targets[OneSignalCategory.PLAYER].append(target) self.logger.debug( "Detected OneSignal Player ID:" f" {self.targets[OneSignalCategory.PLAYER][-1]}" ) # Custom Data self.custom_data = {} if custom and isinstance(custom, dict): if self.decode_tpl_args: custom = decode_b64_dict(custom) self.custom_data.update(custom) elif custom: msg = ( "The specified OneSignal Custom Data " f"({custom}) are not identified as a dictionary." ) self.logger.warning(msg) raise TypeError(msg) # Postback Data self.postback_data = {} if postback and isinstance(postback, dict): self.postback_data.update(postback) elif postback: msg = ( "The specified OneSignal Postback Data " f"({postback}) are not identified as a dictionary." ) self.logger.warning(msg) raise TypeError(msg) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform OneSignal Notification.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", "Authorization": f"Basic {self.apikey}", } has_error = False sent_count = 0 payload = { "app_id": self.app, "contents": { self.language: body, }, # Sending true wakes your app from background to run custom native # code (Apple interprets this as content-available=1). # Note: Not applicable if the app is in the "force-quit" state # (i.e app was swiped away). Omit the contents field to # prevent displaying a visible notification. "content_available": True, } if self.template_id: # Store template information payload["template_id"] = self.template_id if not self.use_contents: # Only if a template is defined can contents be removed del payload["contents"] # Set our data if defined if self.custom_data: payload.update( { "custom_data": self.custom_data, } ) # Set our postback data if defined if self.postback_data: payload.update( { "data": self.postback_data, } ) if title: # Display our title if defined payload.update( { "headings": { self.language: title, } } ) if self.subtitle: payload.update( { "subtitle": { self.language: self.subtitle, }, } ) # Acquire our large_icon image URL (if set) image_url = ( None if not self.include_image else self.image_url(notify_type) ) if image_url: payload["large_icon"] = image_url # Acquire our small_icon image URL (if set) image_url = ( None if not self.include_image else self.image_url(notify_type, image_size=NotifyImageSize.XY_32) ) if image_url: payload["small_icon"] = image_url for category in ONESIGNAL_CATEGORIES: # Create a pointer to our list of targets for specified category targets = self.targets[category] for index in range(0, len(targets), self.batch_size): payload[category] = targets[index : index + self.batch_size] # Track our sent count sent_count += len(payload[category]) self.logger.debug( "OneSignal POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"OneSignal 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), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifyOneSignal.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send OneSignal 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], ) has_error = True else: self.logger.info("Sent OneSignal notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending OneSignal " "notification." ) self.logger.debug("Socket Exception: %s", e) has_error = True if not sent_count: # There is no one to notify; we need to capture this and not # return a valid self.logger.warning("There are no OneSignal targets to notify") return False 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.template_id, self.app, self.apikey, ) 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", "batch": "yes" if self.batch_size > 1 else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) custom_data, needs_decoding = encode_b64_dict(self.custom_data) # custom_data, needs_decoding = self.custom_data, False # Save our template data params.update({f":{k}": v for k, v in custom_data.items()}) # Save our postback data params.update({f"+{k}": v for k, v in self.postback_data.items()}) if self.use_contents != self.template_args["contents"]["default"]: params["contents"] = "yes" if self.use_contents else "no" if ( self.decode_tpl_args != self.template_args["decode"]["default"] or needs_decoding ): params["decode"] = ( "yes" if (self.decode_tpl_args or needs_decoding) else "no" ) return "{schema}://{tp_id}{app}@{apikey}/{targets}?{params}".format( schema=self.secure_protocol, tp_id=( "{}:".format(self.pprint(self.template_id, privacy, safe="")) if self.template_id else "" ), app=self.pprint(self.app, privacy, safe=""), apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( chain( [ NotifyOneSignal.quote(x) for x in self.targets[OneSignalCategory.PLAYER] ], [ NotifyOneSignal.quote(x) for x in self.targets[OneSignalCategory.EMAIL] ], [ NotifyOneSignal.quote( "{}{}".format( NotifyOneSignal.template_tokens["target_user"][ "prefix" ], x, ), safe="", ) for x in self.targets[OneSignalCategory.USER] ], [ NotifyOneSignal.quote( "{}{}".format( NotifyOneSignal.template_tokens[ "target_segment" ]["prefix"], x, ), safe="", ) for x in self.targets[OneSignalCategory.SEGMENT] ], ) ), params=NotifyOneSignal.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # if self.batch_size > 1: # Batches can only be sent by group (you can't combine groups into # a single batch) total_targets = 0 for _k, m in self.targets.items(): targets = len(m) total_targets += int(targets / self.batch_size) + ( 1 if targets % self.batch_size else 0 ) return total_targets # Normal batch count; just count the targets return sum(len(m) for _, m in self.targets.items()) @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"): # The APP ID identifier associated with the account results["app"] = NotifyOneSignal.unquote(results["user"]) else: # The APP ID identifier associated with the account results["app"] = NotifyOneSignal.unquote(results["password"]) # The Template ID results["template"] = NotifyOneSignal.unquote(results["user"]) # Get Image Boolean (if set) results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyOneSignal.template_args["image"]["default"] ) ) # Get Batch Boolean (if set) results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyOneSignal.template_args["batch"]["default"] ) ) # Get Use Contents Boolean (if set) results["use_contents"] = parse_bool( results["qsd"].get( "contents", NotifyOneSignal.template_args["contents"]["default"], ) ) # Get Use Contents Boolean (if set) results["decode_tpl_args"] = parse_bool( results["qsd"].get( "decode", NotifyOneSignal.template_args["decode"]["default"] ) ) # The API Key is stored in the hostname results["apikey"] = NotifyOneSignal.unquote(results["host"]) # Get our Targets results["targets"] = NotifyOneSignal.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"] += NotifyOneSignal.parse_list( results["qsd"]["to"] ) if "app" in results["qsd"] and len(results["qsd"]["app"]): results["app"] = NotifyOneSignal.unquote(results["qsd"]["app"]) if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): results["apikey"] = NotifyOneSignal.unquote( results["qsd"]["apikey"] ) if "template" in results["qsd"] and len(results["qsd"]["template"]): results["template"] = NotifyOneSignal.unquote( results["qsd"]["template"] ) if "subtitle" in results["qsd"] and len(results["qsd"]["subtitle"]): results["subtitle"] = NotifyOneSignal.unquote( results["qsd"]["subtitle"] ) if "lang" in results["qsd"] and len(results["qsd"]["lang"]): results["language"] = NotifyOneSignal.unquote( results["qsd"]["lang"] ) # Store our custom data results["custom"] = results["qsd:"] # Store our postback data results["postback"] = results["qsd+"] return results apprise-1.10.0/apprise/plugins/opsgenie.py000066400000000000000000000713251517341665700205720ustar00rootroot00000000000000# 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. # Signup @ https://www.opsgenie.com # # Generate your Integration API Key # https://app.opsgenie.com/settings/integration/add/API/ # Knowing this, you can build your Opsgenie URL as follows: # opsgenie://{apikey}/ # opsgenie://{apikey}/@{user} # opsgenie://{apikey}/*{schedule} # opsgenie://{apikey}/^{escalation} # opsgenie://{apikey}/#{team} # # You can mix and match what you want to notify freely # opsgenie://{apikey}/@{user}/#{team}/*{schedule}/^{escalation} # # If no target prefix is specified, then it is assumed to be a user. # # API Documentation: https://docs.opsgenie.com/docs/alert-api # API Integration Docs: https://docs.opsgenie.com/docs/api-integration import hashlib from json import dumps, loads import requests from ..common import NotifyType, PersistentStoreMode from ..locale import gettext_lazy as _ from ..utils.parse import is_uuid, parse_bool, parse_list, validate_regex from .base import NotifyBase class OpsgenieCategory(NotifyBase): """We define the different category types that we can notify.""" USER = "user" SCHEDULE = "schedule" ESCALATION = "escalation" TEAM = "team" OPSGENIE_CATEGORIES = ( OpsgenieCategory.USER, OpsgenieCategory.SCHEDULE, OpsgenieCategory.ESCALATION, OpsgenieCategory.TEAM, ) class OpsgenieAlertAction: """Defines the supported actions.""" # Use mapping (specify :key=arg to over-ride) MAP = "map" # Create new alert (default) NEW = "new" # Close Alert CLOSE = "close" # Delete Alert DELETE = "delete" # Acknowledge Alert ACKNOWLEDGE = "acknowledge" # Add note to alert NOTE = "note" OPSGENIE_ACTIONS = ( OpsgenieAlertAction.MAP, OpsgenieAlertAction.NEW, OpsgenieAlertAction.CLOSE, OpsgenieAlertAction.DELETE, OpsgenieAlertAction.ACKNOWLEDGE, OpsgenieAlertAction.NOTE, ) # Regions class OpsgenieRegion: US = "us" EU = "eu" # Opsgenie APIs OPSGENIE_API_LOOKUP = { OpsgenieRegion.US: "https://api.opsgenie.com/v2/alerts", OpsgenieRegion.EU: "https://api.eu.opsgenie.com/v2/alerts", } # A List of our regions we can use for verification OPSGENIE_REGIONS = ( OpsgenieRegion.US, OpsgenieRegion.EU, ) # Priorities class OpsgeniePriority: LOW = 1 MODERATE = 2 NORMAL = 3 HIGH = 4 EMERGENCY = 5 OPSGENIE_PRIORITIES = { # Note: This also acts as a reverse lookup mapping OpsgeniePriority.LOW: "low", OpsgeniePriority.MODERATE: "moderate", OpsgeniePriority.NORMAL: "normal", OpsgeniePriority.HIGH: "high", OpsgeniePriority.EMERGENCY: "emergency", } OPSGENIE_PRIORITY_MAP = { # Maps against string 'low' "l": OpsgeniePriority.LOW, # Maps against string 'moderate' "m": OpsgeniePriority.MODERATE, # Maps against string 'normal' "n": OpsgeniePriority.NORMAL, # Maps against string 'high' "h": OpsgeniePriority.HIGH, # Maps against string 'emergency' "e": OpsgeniePriority.EMERGENCY, # Entries to additionally support (so more like Opsgenie's API) "1": OpsgeniePriority.LOW, "2": OpsgeniePriority.MODERATE, "3": OpsgeniePriority.NORMAL, "4": OpsgeniePriority.HIGH, "5": OpsgeniePriority.EMERGENCY, # Support p-prefix "p1": OpsgeniePriority.LOW, "p2": OpsgeniePriority.MODERATE, "p3": OpsgeniePriority.NORMAL, "p4": OpsgeniePriority.HIGH, "p5": OpsgeniePriority.EMERGENCY, } class NotifyOpsgenie(NotifyBase): """A wrapper for Opsgenie Notifications.""" # The default descriptive name associated with the Notification service_name = "Opsgenie" # The services URL service_url = "https://opsgenie.com/" # All notification requests are secure secure_protocol = "opsgenie" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/opsgenie/" # The maximum length of the body body_maxlen = 15000 # Our default is to no not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.AUTO # If we don't have the specified min length, then we don't bother using # the body directive opsgenie_body_minlen = 130 # The default region to use if one isn't otherwise specified opsgenie_default_region = OpsgenieRegion.US # The maximum allowable targets within a notification default_batch_size = 50 # Defines our default message mapping opsgenie_message_map = { # Add a note to an existing alert NotifyType.INFO: OpsgenieAlertAction.NOTE, # Close existing alert NotifyType.SUCCESS: OpsgenieAlertAction.CLOSE, # Create notice NotifyType.WARNING: OpsgenieAlertAction.NEW, # Create notice NotifyType.FAILURE: OpsgenieAlertAction.NEW, } # Define object templates templates = ( "{schema}://{apikey}", "{schema}://{user}@{apikey}", "{schema}://{apikey}/{targets}", "{schema}://{user}@{apikey}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, "user": { "name": _("Username"), "type": "string", }, "target_escalation": { "name": _("Target Escalation"), "prefix": "^", "type": "string", "map_to": "targets", }, "target_schedule": { "name": _("Target Schedule"), "type": "string", "prefix": "*", "map_to": "targets", }, "target_user": { "name": _("Target User"), "type": "string", "prefix": "@", "map_to": "targets", }, "target_team": { "name": _("Target Team"), "type": "string", "prefix": "#", "map_to": "targets", }, "targets": { "name": _("Targets "), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "region": { "name": _("Region Name"), "type": "choice:string", "values": OPSGENIE_REGIONS, "default": OpsgenieRegion.US, "map_to": "region_name", }, "priority": { "name": _("Priority"), "type": "choice:int", "values": OPSGENIE_PRIORITIES, "default": OpsgeniePriority.NORMAL, }, "entity": { "name": _("Entity"), "type": "string", }, "alias": { "name": _("Alias"), "type": "string", }, "tags": { "name": _("Tags"), "type": "string", }, "action": { "name": _("Action"), "type": "choice:string", "values": OPSGENIE_ACTIONS, "default": OPSGENIE_ACTIONS[0], }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) # Map of key-value pairs to use as custom properties of the alert. template_kwargs = { "details": { "name": _("Details"), "prefix": "+", }, "mapping": { "name": _("Action Mapping"), "prefix": ":", }, } def __init__( self, apikey, targets, region_name=None, details=None, priority=None, alias=None, entity=None, batch=False, tags=None, action=None, mapping=None, **kwargs, ): """Initialize Opsgenie Object.""" super().__init__(**kwargs) # Notify users that this plugin will require them to switch soon self.logger.deprecate( "Opsgenie will soon be depricated and moved to Jira; " "visit https://atlassian.com/ for more details" ) # API Key (associated with project) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid Opsgenie API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # The Priority of the message self.priority = ( NotifyOpsgenie.template_args["priority"]["default"] if not priority else next( ( v for k, v in OPSGENIE_PRIORITY_MAP.items() if str(priority).lower().startswith(k) ), NotifyOpsgenie.template_args["priority"]["default"], ) ) # Store our region try: self.region_name = ( self.opsgenie_default_region if region_name is None else region_name.lower() ) if self.region_name not in OPSGENIE_REGIONS: # allow the outer except to handle this common response raise except: # Invalid region specified msg = f"The Opsgenie region specified ({region_name}) is invalid." self.logger.warning(msg) raise TypeError(msg) from None if action and isinstance(action, str): self.action = next( (a for a in OPSGENIE_ACTIONS if a.startswith(action)), None ) if self.action not in OPSGENIE_ACTIONS: msg = f"The Opsgenie action specified ({action}) is invalid." self.logger.warning(msg) raise TypeError(msg) else: self.action = self.template_args["action"]["default"] # Store our mappings self.mapping = self.opsgenie_message_map.copy() if mapping and isinstance(mapping, dict): for k_, v_ in mapping.items(): # Get our mapping k = next((t for t in NotifyType if t.startswith(k_)), None) if not k: msg = ( f"The Opsgenie mapping key specified ({k_}) " "is invalid." ) self.logger.warning(msg) raise TypeError(msg) v_lower = v_.lower() v = next( (v for v in OPSGENIE_ACTIONS[1:] if v.startswith(v_lower)), None, ) if not v: msg = ( f"The Opsgenie mapping value (assigned to {k}) " f"specified ({v_}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Update our mapping self.mapping[k] = v self.details = {} if details: # Store our extra details self.details.update(details) # Prepare Batch Mode Flag self.batch_size = self.default_batch_size if batch else 1 # Assign our tags (if defined) self.__tags = parse_list(tags) # Assign our entity (if defined) self.entity = entity # Assign our alias (if defined) self.alias = alias # Initialize our Targets self.targets = [] # Sort our targets for target_ in parse_list(targets): target = target_.strip() if len(target) < 2: self.logger.debug(f"Ignoring Opsgenie Entry: {target}") continue if target.startswith( NotifyOpsgenie.template_tokens["target_team"]["prefix"] ): self.targets.append( {"type": OpsgenieCategory.TEAM, "id": target[1:]} if is_uuid(target[1:]) else {"type": OpsgenieCategory.TEAM, "name": target[1:]} ) elif target.startswith( NotifyOpsgenie.template_tokens["target_schedule"]["prefix"] ): self.targets.append( {"type": OpsgenieCategory.SCHEDULE, "id": target[1:]} if is_uuid(target[1:]) else { "type": OpsgenieCategory.SCHEDULE, "name": target[1:], } ) elif target.startswith( NotifyOpsgenie.template_tokens["target_escalation"]["prefix"] ): self.targets.append( {"type": OpsgenieCategory.ESCALATION, "id": target[1:]} if is_uuid(target[1:]) else { "type": OpsgenieCategory.ESCALATION, "name": target[1:], } ) elif target.startswith( NotifyOpsgenie.template_tokens["target_user"]["prefix"] ): self.targets.append( {"type": OpsgenieCategory.USER, "id": target[1:]} if is_uuid(target[1:]) else { "type": OpsgenieCategory.USER, "username": target[1:], } ) else: # Ambiguious entry; treat it as a user but not before # displaying a warning to the end user first: self.logger.debug( "Treating ambigious Opsgenie target %s as a user", target ) self.targets.append( {"type": OpsgenieCategory.USER, "id": target} if is_uuid(target) else {"type": OpsgenieCategory.USER, "username": target} ) def _fetch(self, method, url, payload, params=None): """Performs server retrieval/update and returns JSON Response.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Authorization": f"GenieKey {self.apikey}", } # Some Debug Logging self.logger.debug( f"Opsgenie POST URL: {url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Opsgenie Payload: {payload}") # Initialize our response object content = {} # Always call throttle before any remote server i/o is made self.throttle() try: r = method( url, data=dumps(payload), params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # A Response might look like: # { # "result": "Request will be processed", # "took": 0.302, # "requestId": "43a29c5c-3dbf-4fa4-9c26-f4f71023e120" # } try: # Update our response object content = loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} if r.status_code not in ( requests.codes.accepted, requests.codes.ok, ): status_str = NotifyBase.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Opsgenie 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, content.get("requestId")) # If we reach here; the message was sent self.logger.info("Sent Opsgenie notification") return (True, content.get("requestId")) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Opsgenie notification." ) self.logger.debug(f"Socket Exception: {e!s}") return (False, content.get("requestId")) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Opsgenie Notification.""" # Get our Opsgenie Action action = ( self.mapping[notify_type] if self.action == OpsgenieAlertAction.MAP else self.action ) # Prepare our URL as it's based on our hostname notify_url = OPSGENIE_API_LOOKUP[self.region_name] # Initialize our has_error flag has_error = False # Default method is to post method = requests.post # For indexing in persistent store key = hashlib.sha1( ( self.entity if self.entity else ( self.alias if self.alias else (title if title else self.app_id) ) ).encode("utf-8") ).hexdigest()[0:10] # Get our Opsgenie Request IDs request_ids = self.store.get(key, []) if not isinstance(request_ids, list): request_ids = [] if action == OpsgenieAlertAction.NEW: # Create a copy ouf our details object details = self.details.copy() if "type" not in details: details["type"] = notify_type.value # Use body if title not set title_body = title if title else body # Prepare our payload payload = { "source": self.app_desc, "message": title_body, "description": body, "details": details, "priority": f"P{self.priority}", } # Use our body directive if we exceed the minimum message # limitation if len(payload["message"]) > self.opsgenie_body_minlen: payload["message"] = ( f"{title_body[: self.opsgenie_body_minlen - 3]}..." ) if self.__tags: payload["tags"] = self.__tags if self.entity: payload["entity"] = self.entity if self.alias: payload["alias"] = self.alias if self.user: payload["user"] = self.user # reset our request IDs - we will re-populate them request_ids = [] length = len(self.targets) if self.targets else 1 for index in range(0, length, self.batch_size): if self.targets: # If there were no targets identified, then we simply # just iterate once without the responders set payload["responders"] = self.targets[ index : index + self.batch_size ] # Perform our post success, request_id = self._fetch(method, notify_url, payload) if success and request_id: # Save our response request_ids.append(request_id) else: has_error = True # Store our entries for a maximum of 60 days self.store.set(key, request_ids, expires=60 * 60 * 24 * 60) elif request_ids: # Prepare our payload payload = { "source": self.app_desc, "note": body, } if self.user: payload["user"] = self.user # Prepare our Identifier type params = { "identifierType": "id", } for request_id in request_ids: if action == OpsgenieAlertAction.DELETE: # Update our URL url = f"{notify_url}/{request_id}" method = requests.delete elif action == OpsgenieAlertAction.ACKNOWLEDGE: url = f"{notify_url}/{request_id}/acknowledge" elif action == OpsgenieAlertAction.CLOSE: url = f"{notify_url}/{request_id}/close" else: # action == OpsgenieAlertAction.CLOSE: url = f"{notify_url}/{request_id}/notes" # Perform our post success, _ = self._fetch(method, url, payload, params) if not success: has_error = True if not has_error and action == OpsgenieAlertAction.DELETE: # Remove cached entry self.store.clear(key) else: self.logger.info( "No Opsgenie notification sent due to (nothing to %s) " "condition", self.action, ) 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.region_name, self.apikey) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "action": self.action, "region": self.region_name, "priority": ( OPSGENIE_PRIORITIES[self.template_args["priority"]["default"]] if self.priority not in OPSGENIE_PRIORITIES else OPSGENIE_PRIORITIES[self.priority] ), "batch": "yes" if self.batch_size > 1 else "no", } # Assign our entity value (if defined) if self.entity: params["entity"] = self.entity # Assign our alias value (if defined) if self.alias: params["alias"] = self.alias # Assign our tags (if specifed) if self.__tags: params["tags"] = ",".join(self.__tags) # Append our details into our parameters params.update({f"+{k}": v for k, v in self.details.items()}) # Append our assignment extra's into our parameters params.update({f":{k.value}": v for k, v in self.mapping.items()}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # A map allows us to map our target types so they can be correctly # placed back into your URL below. Hence map the 'user' -> '@' map_ = { OpsgenieCategory.USER: NotifyOpsgenie.template_tokens[ "target_user" ]["prefix"], OpsgenieCategory.SCHEDULE: NotifyOpsgenie.template_tokens[ "target_schedule" ]["prefix"], OpsgenieCategory.ESCALATION: NotifyOpsgenie.template_tokens[ "target_escalation" ]["prefix"], OpsgenieCategory.TEAM: NotifyOpsgenie.template_tokens[ "target_team" ]["prefix"], } return "{schema}://{user}{apikey}/{targets}?{params}".format( schema=self.secure_protocol, user=f"{self.user}@" if self.user else "", apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [ NotifyOpsgenie.quote( "{}{}".format( map_[x["type"]], x.get("id", x.get("name", x.get("username"))), ) ) for x in self.targets ] ), params=NotifyOpsgenie.urlencode(params, safe=":"), ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # targets = len(self.targets) if self.batch_size > 1: targets = int(targets / self.batch_size) + ( 1 if targets % self.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 API Key is stored in the hostname results["apikey"] = NotifyOpsgenie.unquote(results["host"]) # Get our Targets results["targets"] = NotifyOpsgenie.split_path(results["fullpath"]) # Add our Meta Detail keys results["details"] = { NotifyBase.unquote(x): NotifyBase.unquote(y) for x, y in results["qsd+"].items() } # Set our priority if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyOpsgenie.unquote( results["qsd"]["priority"] ) # Get Batch Boolean (if set) results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyOpsgenie.template_args["batch"]["default"] ) ) if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): results["apikey"] = NotifyOpsgenie.unquote( results["qsd"]["apikey"] ) if "tags" in results["qsd"] and len(results["qsd"]["tags"]): # Extract our tags results["tags"] = parse_list( NotifyOpsgenie.unquote(results["qsd"]["tags"]) ) if "region" in results["qsd"] and len(results["qsd"]["region"]): # Extract our region results["region_name"] = NotifyOpsgenie.unquote( results["qsd"]["region"] ) if "entity" in results["qsd"] and len(results["qsd"]["entity"]): # Extract optional entity field results["entity"] = NotifyOpsgenie.unquote( results["qsd"]["entity"] ) if "alias" in results["qsd"] and len(results["qsd"]["alias"]): # Extract optional alias field results["alias"] = NotifyOpsgenie.unquote(results["qsd"]["alias"]) # Handle 'to' email address if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append(results["qsd"]["to"]) # Store our action (if defined) if "action" in results["qsd"] and len(results["qsd"]["action"]): results["action"] = NotifyOpsgenie.unquote( results["qsd"]["action"] ) # store any custom mapping defined results["mapping"] = { NotifyOpsgenie.unquote(x): NotifyOpsgenie.unquote(y) for x, y in results["qsd:"].items() } return results apprise-1.10.0/apprise/plugins/pagerduty.py000066400000000000000000000447741517341665700207750ustar00rootroot00000000000000# 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 Refererence: # - https://developer.pagerduty.com/api-reference/\ # 368ae3d938c9e-send-an-event-to-pager-duty # from json import dumps import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool, validate_regex from .base import NotifyBase class PagerDutySeverity: """Defines the Pager Duty Severity Levels.""" INFO = "info" WARNING = "warning" ERROR = "error" CRITICAL = "critical" # Map all support Apprise Categories with the Pager Duty ones PAGERDUTY_SEVERITY_MAP = { NotifyType.INFO: PagerDutySeverity.INFO, NotifyType.SUCCESS: PagerDutySeverity.INFO, NotifyType.WARNING: PagerDutySeverity.WARNING, NotifyType.FAILURE: PagerDutySeverity.CRITICAL, } PAGERDUTY_SEVERITIES = ( PagerDutySeverity.INFO, PagerDutySeverity.WARNING, PagerDutySeverity.CRITICAL, PagerDutySeverity.ERROR, ) # Priorities class PagerDutyRegion: US = "us" EU = "eu" # SparkPost APIs PAGERDUTY_API_LOOKUP = { PagerDutyRegion.US: "https://events.pagerduty.com/v2/enqueue", PagerDutyRegion.EU: "https://events.eu.pagerduty.com/v2/enqueue", } # A List of our regions we can use for verification PAGERDUTY_REGIONS = ( PagerDutyRegion.US, PagerDutyRegion.EU, ) class NotifyPagerDuty(NotifyBase): """A wrapper for Pager Duty Notifications.""" # The default descriptive name associated with the Notification service_name = "Pager Duty" # The services URL service_url = "https://pagerduty.com/" # Secure Protocol secure_protocol = "pagerduty" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pagerduty/" # We don't support titles for Pager Duty notifications title_maxlen = 0 # Allows the user to specify the NotifyImageSize object; this is supported # through the webhook image_size = NotifyImageSize.XY_128 # Our event action type event_action = "trigger" # The default region to use if one isn't otherwise specified default_region = PagerDutyRegion.US # Define object templates templates = ( "{schema}://{integrationkey}@{apikey}", "{schema}://{integrationkey}@{apikey}/{source}", "{schema}://{integrationkey}@{apikey}/{source}/{component}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, # Optional but triggers V2 API "integrationkey": { "name": _("Integration Key"), "type": "string", "private": True, "required": True, }, "source": { # Optional Source Identifier (preferably a FQDN) "name": _("Source"), "type": "string", "default": "Apprise", }, "component": { # Optional Component Identifier "name": _("Component"), "type": "string", "default": "Notification", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "group": { "name": _("Group"), "type": "string", }, "class": { "name": _("Class"), "type": "string", "map_to": "class_id", }, "click": { "name": _("Click"), "type": "string", }, "region": { "name": _("Region Name"), "type": "choice:string", "values": PAGERDUTY_REGIONS, "default": PagerDutyRegion.US, "map_to": "region_name", }, # The severity is automatically determined, however you can # optionally over-ride its value and force it to be what you want "severity": { "name": _("Severity"), "type": "choice:string", "values": PAGERDUTY_SEVERITIES, "map_to": "severity", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, }, ) # Define any kwargs we're using template_kwargs = { "details": { "name": _("Custom Details"), "prefix": "+", }, } def __init__( self, apikey, integrationkey=None, source=None, component=None, group=None, class_id=None, include_image=True, click=None, details=None, region_name=None, severity=None, **kwargs, ): """Initialize Pager Duty Object.""" super().__init__(**kwargs) # Long-Lived Access token (generated from User Profile) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid Pager Duty API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) self.integration_key = validate_regex(integrationkey) if not self.integration_key: msg = ( "An invalid Pager Duty Routing Key " f"({integrationkey}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # An Optional Source self.source = self.template_tokens["source"]["default"] if source: self.source = validate_regex(source) if not self.source: msg = ( "An invalid Pager Duty Notification Source " f"({source}) was specified." ) self.logger.warning(msg) raise TypeError(msg) else: self.component = self.template_tokens["source"]["default"] # An Optional Component self.component = self.template_tokens["component"]["default"] if component: self.component = validate_regex(component) if not self.component: msg = ( "An invalid Pager Duty Notification Component " f"({component}) was specified." ) self.logger.warning(msg) raise TypeError(msg) else: self.component = self.template_tokens["component"]["default"] # Store our region try: self.region_name = ( self.default_region if region_name is None else region_name.lower() ) if self.region_name not in PAGERDUTY_REGIONS: # allow the outer except to handle this common response raise IndexError() except (AttributeError, IndexError, TypeError): # Invalid region specified msg = f"The PagerDuty region specified ({region_name}) is invalid." self.logger.warning(msg) raise TypeError(msg) from None # The severity (if specified) self.severity = ( None if severity is None else next( ( s for s in PAGERDUTY_SEVERITIES if str(s).lower().startswith(severity) ), False, ) ) if self.severity is False: # Invalid severity specified msg = f"The PagerDuty severity specified ({severity}) is invalid." self.logger.warning(msg) raise TypeError(msg) # A clickthrough option for notifications self.click = click # Store Class ID if specified self.class_id = class_id # Store Group if specified self.group = group self.details = {} if details: # Store our extra details self.details.update(details) # Display our Apprise Image self.include_image = include_image return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Send our PagerDuty Notification.""" # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Authorization": f"Token token={self.apikey}", } # Prepare our persistent_notification.create payload payload = { # Define our integration key "routing_key": self.integration_key, # Prepare our payload "payload": { "summary": body, # Set our severity "severity": ( PAGERDUTY_SEVERITY_MAP[notify_type] if not self.severity else self.severity ), # Our Alerting Source/Component "source": self.source, "component": self.component, }, "client": self.app_id, # Our Event Action "event_action": self.event_action, } if self.group: payload["payload"]["group"] = self.group if self.class_id: payload["payload"]["class"] = self.class_id if self.click: payload["links"] = [ { "href": self.click, } ] # Acquire our image url if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) if image_url: payload["images"] = [ { "src": image_url, "alt": notify_type.value, } ] if self.details: payload["payload"]["custom_details"] = {} # Apply any provided custom details for k, v in self.details.items(): payload["payload"]["custom_details"][k] = v # Prepare our URL based on region notify_url = PAGERDUTY_API_LOOKUP[self.region_name] self.logger.debug( "Pager Duty POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Pager Duty Payload: {payload!s}") # 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, ) if r.status_code not in ( requests.codes.ok, requests.codes.created, requests.codes.accepted, ): # We had a problem status_str = NotifyPagerDuty.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Pager Duty 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 Pager Duty notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Pager Duty " 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, self.integration_key, self.apikey, self.source, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "region": self.region_name, "image": "yes" if self.include_image else "no", } if self.class_id: params["class"] = self.class_id if self.group: params["group"] = self.group if self.click is not None: params["click"] = self.click if self.severity: params["severity"] = self.severity # Append our custom entries our parameters params.update({f"+{k}": v for k, v in self.details.items()}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) url = ( "{schema}://{integration_key}@{apikey}/" "{source}/{component}?{params}" ) return url.format( schema=self.secure_protocol, # never encode hostname since we're expecting it to be a valid one integration_key=self.pprint( self.integration_key, privacy, mode=PrivacyMode.Secret, safe="" ), apikey=self.pprint( self.apikey, privacy, mode=PrivacyMode.Secret, safe="" ), source=self.pprint(self.source, privacy, safe=""), component=self.pprint(self.component, privacy, safe=""), params=NotifyPagerDuty.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 # The 'apikey' makes it easier to use yaml configuration if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): results["apikey"] = NotifyPagerDuty.unquote( results["qsd"]["apikey"] ) else: results["apikey"] = NotifyPagerDuty.unquote(results["host"]) # The 'integrationkey' makes it easier to use yaml configuration if "integrationkey" in results["qsd"] and len( results["qsd"]["integrationkey"] ): results["integrationkey"] = NotifyPagerDuty.unquote( results["qsd"]["integrationkey"] ) else: results["integrationkey"] = NotifyPagerDuty.unquote( results["user"] ) if "click" in results["qsd"] and len(results["qsd"]["click"]): results["click"] = NotifyPagerDuty.unquote(results["qsd"]["click"]) if "group" in results["qsd"] and len(results["qsd"]["group"]): results["group"] = NotifyPagerDuty.unquote(results["qsd"]["group"]) if "class" in results["qsd"] and len(results["qsd"]["class"]): results["class_id"] = NotifyPagerDuty.unquote( results["qsd"]["class"] ) if "severity" in results["qsd"] and len(results["qsd"]["severity"]): results["severity"] = NotifyPagerDuty.unquote( results["qsd"]["severity"] ) # Acquire our full path fullpath = NotifyPagerDuty.split_path(results["fullpath"]) # Get our source if "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyPagerDuty.unquote( results["qsd"]["source"] ) else: results["source"] = fullpath.pop(0) if fullpath else None # Get our component if "component" in results["qsd"] and len(results["qsd"]["component"]): results["component"] = NotifyPagerDuty.unquote( results["qsd"]["component"] ) else: results["component"] = fullpath.pop(0) if fullpath else None # Add our custom details key/value pairs that the user can potentially # over-ride if they wish to to our returned result set and tidy # entries by unquoting them results["details"] = { NotifyPagerDuty.unquote(x): NotifyPagerDuty.unquote(y) for x, y in results["qsd+"].items() } if "region" in results["qsd"] and len(results["qsd"]["region"]): # Extract from name to associate with from address results["region_name"] = NotifyPagerDuty.unquote( results["qsd"]["region"] ) # Include images with our message results["include_image"] = parse_bool( results["qsd"].get("image", True) ) return results apprise-1.10.0/apprise/plugins/pagertree.py000066400000000000000000000336421517341665700207370ustar00rootroot00000000000000# 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 from uuid import uuid4 import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_list, validate_regex from .base import NotifyBase # Actions class PagerTreeAction: CREATE = "create" ACKNOWLEDGE = "acknowledge" RESOLVE = "resolve" # Urgencies class PagerTreeUrgency: SILENT = "silent" LOW = "low" MEDIUM = "medium" HIGH = "high" CRITICAL = "critical" PAGERTREE_ACTIONS = { PagerTreeAction.CREATE: "create", PagerTreeAction.ACKNOWLEDGE: "acknowledge", PagerTreeAction.RESOLVE: "resolve", } PAGERTREE_URGENCIES = { # Note: This also acts as a reverse lookup mapping PagerTreeUrgency.SILENT: "silent", PagerTreeUrgency.LOW: "low", PagerTreeUrgency.MEDIUM: "medium", PagerTreeUrgency.HIGH: "high", PagerTreeUrgency.CRITICAL: "critical", } # Extend HTTP Error Messages PAGERTREE_HTTP_ERROR_MAP = { 402: "Payment Required - Please subscribe or upgrade", 403: "Forbidden - Blocked", 404: "Not Found - Invalid Integration ID", 405: "Method Not Allowed - Integration Disabled", 429: "Too Many Requests - Rate Limit Exceeded", } class NotifyPagerTree(NotifyBase): """A wrapper for PagerTree Notifications.""" # The default descriptive name associated with the Notification service_name = "PagerTree" # The services URL service_url = "https://pagertree.com/" # All PagerTree requests are secure secure_protocol = "pagertree" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pagertree/" # PagerTree uses the http protocol with JSON requests notify_url = "https://api.pagertree.com/integration/{}" # Define object templates templates = ("{schema}://{integration}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "integration": { "name": _("Integration ID"), "type": "string", "private": True, "required": True, } }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "action": { "name": _("Action"), "type": "choice:string", "values": PAGERTREE_ACTIONS, "default": PagerTreeAction.CREATE, }, "thirdparty": { "name": _("Third Party ID"), "type": "string", }, "urgency": { "name": _("Urgency"), "type": "choice:string", "values": PAGERTREE_URGENCIES, }, "tags": { "name": _("Tags"), "type": "string", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload_extras": { "name": _("Payload Extras"), "prefix": ":", }, "meta_extras": { "name": _("Meta Extras"), "prefix": "-", }, } def __init__( self, integration, action=None, thirdparty=None, urgency=None, tags=None, headers=None, payload_extras=None, meta_extras=None, **kwargs, ): """Initialize PagerTree Object.""" super().__init__(**kwargs) # Integration ID (associated with account) self.integration = validate_regex( integration, r"^int_[a-zA-Z0-9\-_]{7,14}$" ) if not self.integration: msg = ( "An invalid PagerTree Integration ID " f"({integration}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # thirdparty (optional, in case they want to pass the # acknowledge or resolve action) self.thirdparty = None if thirdparty: # An id was specified, we want to validate it self.thirdparty = validate_regex(thirdparty) if not self.thirdparty: msg = ( "An invalid PagerTree third party ID " f"({thirdparty}) was specified." ) self.logger.warning(msg) raise TypeError(msg) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_extras = {} if payload_extras: # Store our extra payload entries self.payload_extras.update(payload_extras) self.meta_extras = {} if meta_extras: # Store our extra payload entries self.meta_extras.update(meta_extras) # Setup our action self.action = ( NotifyPagerTree.template_args["action"]["default"] if action not in PAGERTREE_ACTIONS else PAGERTREE_ACTIONS[action] ) # Setup our urgency self.urgency = PAGERTREE_URGENCIES.get(urgency) # Any optional tags to attach to the notification self.__tags = parse_list(tags) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform PagerTree Notification.""" # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Apply any/all header over-rides defined # For things like PagerTree Token headers.update(self.headers) # prepare JSON Object payload = { # Generate an ID (unless one was explicitly forced to be used) "id": self.thirdparty if self.thirdparty else str(uuid4()), "event_type": self.action, } if self.action == PagerTreeAction.CREATE: payload["title"] = title if title else self.app_desc payload["description"] = body payload["meta"] = self.meta_extras payload["tags"] = self.__tags if self.urgency is not None: payload["urgency"] = self.urgency # Apply any/all payload over-rides defined payload.update(self.payload_extras) # Prepare our URL based on integration notify_url = self.notify_url.format(self.integration) self.logger.debug( "PagerTree POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"PagerTree Payload: {payload!s}") # 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, ) if r.status_code not in ( requests.codes.ok, requests.codes.created, requests.codes.accepted, ): # We had a problem status_str = NotifyPagerTree.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send PagerTree 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 PagerTree notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending PagerTree " 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, self.integration) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "action": self.action, } if self.thirdparty: params["tid"] = self.thirdparty if self.urgency: params["urgency"] = self.urgency if self.__tags: params["tags"] = ",".join(list(self.__tags)) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Headers prefixed with a '+' sign # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Meta: {} prefixed with a '-' sign # Append our meta extras into our parameters params.update({f"-{k}": v for k, v in self.meta_extras.items()}) # Payload body extras prefixed with a ':' sign # Append our payload extras into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) return "{schema}://{integration}?{params}".format( schema=self.secure_protocol, # never encode hostname since we're expecting it to be a valid one integration=self.pprint(self.integration, privacy, safe=""), params=NotifyPagerTree.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 # 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"] = { NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) for x, y in results["qsd+"].items() } # store any additional payload extra's defined results["payload_extras"] = { NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) for x, y in results["qsd:"].items() } # store any additional meta extra's defined results["meta_extras"] = { NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) for x, y in results["qsd-"].items() } # Integration ID if "id" in results["qsd"] and len(results["qsd"]["id"]): # Shortened version of integration id results["integration"] = NotifyPagerTree.unquote( results["qsd"]["id"] ) elif "integration" in results["qsd"] and len( results["qsd"]["integration"] ): results["integration"] = NotifyPagerTree.unquote( results["qsd"]["integration"] ) else: results["integration"] = NotifyPagerTree.unquote(results["host"]) # Set our thirdparty if "tid" in results["qsd"] and len(results["qsd"]["tid"]): # Shortened version of thirdparty results["thirdparty"] = NotifyPagerTree.unquote( results["qsd"]["tid"] ) elif "thirdparty" in results["qsd"] and len( results["qsd"]["thirdparty"] ): results["thirdparty"] = NotifyPagerTree.unquote( results["qsd"]["thirdparty"] ) # Set our urgency if "action" in results["qsd"] and len(results["qsd"]["action"]): results["action"] = NotifyPagerTree.unquote( results["qsd"]["action"] ) # Set our urgency if "urgency" in results["qsd"] and len(results["qsd"]["urgency"]): results["urgency"] = NotifyPagerTree.unquote( results["qsd"]["urgency"] ) # Set our tags if "tags" in results["qsd"] and len(results["qsd"]["tags"]): results["tags"] = parse_list( NotifyPagerTree.unquote(results["qsd"]["tags"]) ) return results apprise-1.10.0/apprise/plugins/parseplatform.py000066400000000000000000000255401517341665700216360ustar00rootroot00000000000000# 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 re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase # Used to break path apart into list of targets TARGET_LIST_DELIM = re.compile(r"[ \t\r\n,\\/]+") # Priorities class ParsePlatformDevice: # All Devices ALL = "all" # Apple IOS (APNS) IOS = "ios" # Android/Firebase (FCM) ANDROID = "android" PARSE_PLATFORM_DEVICES = ( ParsePlatformDevice.ALL, ParsePlatformDevice.IOS, ParsePlatformDevice.ANDROID, ) class NotifyParsePlatform(NotifyBase): """A wrapper for Parse Platform Notifications.""" # The default descriptive name associated with the Notification service_name = "Parse Platform" # The services URL service_url = " https://parseplatform.org/" # insecure notifications (using http) protocol = "parsep" # Secure notifications (using https) secure_protocol = "parseps" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/parseplatform/" # Define object templates templates = ( "{schema}://{app_id}:{master_key}@{host}", "{schema}://{app_id}:{master_key}@{host}:{port}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "app_id": { "name": _("App ID"), "type": "string", "private": True, "required": True, }, "master_key": { "name": _("Master Key"), "type": "string", "private": True, "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "device": { "name": _("Device"), "type": "choice:string", "values": PARSE_PLATFORM_DEVICES, "default": ParsePlatformDevice.ALL, }, "app_id": { "alias_of": "app_id", }, "master_key": { "alias_of": "master_key", }, }, ) def __init__(self, app_id, master_key, device=None, **kwargs): """Initialize Parse Platform Object.""" super().__init__(**kwargs) self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "/" # Application ID self.application_id = validate_regex(app_id) if not self.application_id: msg = ( "An invalid Parse Platform Application ID " f"({app_id}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Master Key self.master_key = validate_regex(master_key) if not self.master_key: msg = ( "An invalid Parse Platform Master Key " f"({master_key}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Initialize Devices Array self.devices = [] if device: self.device = device.lower() if device not in PARSE_PLATFORM_DEVICES: msg = ( "An invalid Parse Platform device " f"({device}) was specified." ) self.logger.warning(msg) raise TypeError(msg) else: self.device = self.template_args["device"]["default"] if self.device == ParsePlatformDevice.ALL: self.devices = [ d for d in PARSE_PLATFORM_DEVICES if d != ParsePlatformDevice.ALL ] else: # Store our device self.devices.append(device) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Parse Platform Notification.""" # Prepare our headers: headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "X-Parse-Application-Id": self.application_id, "X-Parse-Master-Key": self.master_key, } # prepare our payload payload = { "where": { "deviceType": { "$in": self.devices, } }, "data": { "title": title, "alert": body, }, } # Set our schema schema = "https" if self.secure else "http" # Our Notification URL url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath.rstrip("/") + "/parse/push/" self.logger.debug( "Parse Platform POST URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Parse Platform Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyParsePlatform.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Parse Platform 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 Parse Platform notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occured sending Parse Platform " 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.application_id, self.master_key, self.host, self.port, self.fullpath, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any arguments set params = { "device": self.device, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) default_port = 443 if self.secure else 80 return ( "{schema}://{app_id}:{master_key}@" "{hostname}{port}{fullpath}/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, app_id=self.pprint(self.application_id, privacy, safe=""), master_key=self.pprint(self.master_key, privacy, safe=""), hostname=NotifyParsePlatform.quote(self.host, safe=""), port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=NotifyParsePlatform.quote(self.fullpath, safe="/"), params=NotifyParsePlatform.urlencode(params), ) ) @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) if not results: # We're done early as we couldn't load the results return results # App ID is retrieved from the user results["app_id"] = NotifyParsePlatform.unquote(results["user"]) # Master Key is retrieved from the password results["master_key"] = NotifyParsePlatform.unquote( results["password"] ) # Device support override if "device" in results["qsd"] and len(results["qsd"]["device"]): results["device"] = results["qsd"]["device"] # Allow app_id attribute over-ride if "app_id" in results["qsd"] and len(results["qsd"]["app_id"]): results["app_id"] = results["qsd"]["app_id"] # Allow master_key attribute over-ride if "master_key" in results["qsd"] and len( results["qsd"]["master_key"] ): results["master_key"] = results["qsd"]["master_key"] return results apprise-1.10.0/apprise/plugins/plivo.py000066400000000000000000000335011517341665700201040ustar00rootroot00000000000000# 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. # Create an account https://messagebird.com if you don't already have one # # Get your auth_id and auth token from the dashboard here: # - https://console.plivo.com/dashboard/ # from json import dumps 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 NotifyPlivo(NotifyBase): """A wrapper for Plivo Notifications.""" # The default descriptive name associated with the Notification service_name = "Plivo" # The services URL service_url = "https://plivo.com" # The default protocol secure_protocol = "plivo" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/plivo/" # Plivo uses the http protocol with JSON requests notify_url = "https://api.plivo.com/v1/Account/{auth_id}/Message/" # The maximum number of messages that can be sent in a single batch default_batch_size = 20 # The maximum length of the body body_maxlen = 140 # 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}://{auth_id}@{token}/{source}", "{schema}://{auth_id}@{token}/{source}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "auth_id": { "name": _("Auth ID"), "type": "string", "required": True, "regex": (r"^[a-z0-9]{20,30}$", "i"), }, "token": { "name": _("Auth Token"), "type": "string", "required": True, "regex": (r"^[a-z0-9]{30,50}$", "i"), }, "source": { "name": _("Source Phone No"), "type": "string", "prefix": "+", "required": True, "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", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "source", }, "token": { "alias_of": "token", }, "id": { "alias_of": "auth_id", }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, auth_id, token, source, targets=None, batch=None, **kwargs ): """Initialize Plivo Object.""" super().__init__(**kwargs) self.auth_id = validate_regex( auth_id, *self.template_tokens["auth_id"]["regex"] ) if not self.auth_id: msg = ( f"The Plivo authentication ID specified ({auth_id}) is " "invalid." ) self.logger.warning(msg) raise TypeError(msg) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = ( f"The Plivo authentication token specified ({token}) is " "invalid." ) self.logger.warning(msg) raise TypeError(msg) result = is_phone_no(source) if not result: msg = f"The Plivo source specified ({source}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Store our source; enforce E.164 format self.source = f"+{result['full']}" # Parse our targets self.targets = [] if targets: for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if result: # store valid phone number; enforce E.164 format self.targets.append(f"+{result['full']}") continue self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) else: # No sources specified, use our own phone no self.targets.append(self.source) # Set batch self.batch = ( batch if batch is not None else self.template_args["batch"]["default"] ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Plivo Notification.""" if not self.targets: # There were no services to notify self.logger.warning("There were no Plivo targets to notify.") return False # Initialize our has_error flag has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Prepare our authentication auth = (self.auth_id, self.token) # Prepare our payload payload = { "src": self.source, "dst": None, "text": body, } # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size for index in range(0, len(self.targets), batch_size): # Prepare our phone no (< delimits more then one) payload["recipients"] = ",".join( self.targets[index : index + batch_size] ) # Some Debug Logging self.logger.debug( "Plivo POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Plivo 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, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.accepted, ): # We had a problem status_str = NotifyPlivo.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send {} Plivo notification{}: " "{}{}error={}.".format( len(self.targets[index : index + batch_size]), ( f" to {self.targets[index]}" if 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( "Send {} Plivo notification{}".format( len(self.targets[index : index + batch_size]), ( f" to {self.targets[index]}" if batch_size == 1 else "(s)" ), ) ) except requests.RequestException as e: self.logger.warning( f"A Connection error occured sending Plivo:{self.targets} " + "notification." ) 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 if self.secure else self.protocol, self.auth_id, self.token, self.source, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any arguments set params = { "batch": "yes" if self.batch else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return ( "{schema}://{auth_id}@{token}/{source}/{targets}/?{params}".format( schema=self.secure_protocol, auth_id=self.pprint(self.auth_id, privacy, safe=""), token=self.pprint(self.token, privacy, safe=""), source=self.source, targets="/".join( [NotifyPlivo.quote(x, safe="+") for x in self.targets] ), params=NotifyPlivo.urlencode(params), ) ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # 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 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 # The Auth ID is in the username field if "id" in results["qsd"] and len(results["qsd"]["id"]): results["auth_id"] = NotifyPlivo.unquote(results["qsd"]["id"]) else: results["auth_id"] = NotifyPlivo.unquote(results["user"]) # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = NotifyPlivo.split_path(results["fullpath"]) if "token" in results["qsd"] and len(results["qsd"]["token"]): # Store token results["token"] = NotifyPlivo.unquote(results["qsd"]["token"]) # go ahead and put the host entry in the targets list if results["host"]: results["targets"].insert( 0, NotifyPlivo.unquote(results["host"]) ) else: # The hostname is our authentication key results["token"] = NotifyPlivo.unquote(results["host"]) if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyPlivo.unquote(results["qsd"]["from"]) else: try: # The first path entry is the source/originator results["source"] = results["targets"].pop(0) except IndexError: # No source specified... results["source"] = None pass # 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"] += NotifyPlivo.parse_phone_no( results["qsd"]["to"] ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyPlivo.template_args["batch"]["default"] ) ) return results apprise-1.10.0/apprise/plugins/popcorn_notify.py000066400000000000000000000255521517341665700220320ustar00rootroot00000000000000# 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 requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import ( is_email, is_phone_no, parse_bool, parse_list, validate_regex, ) from .base import NotifyBase class NotifyPopcornNotify(NotifyBase): """A wrapper for PopcornNotify Notifications.""" # The default descriptive name associated with the Notification service_name = "PopcornNotify" # The services URL service_url = "https://popcornnotify.com/" # The default protocol secure_protocol = "popcorn" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/popcornnotify/" # PopcornNotify uses the http protocol notify_url = "https://popcornnotify.com/notify" # The maximum targets to include when doing batch transfers default_batch_size = 10 # Define object templates templates = ("{schema}://{apikey}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "regex": (r"^[a-z0-9]+$", "i"), "required": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__(self, apikey, targets=None, batch=False, **kwargs): """Initialize PopcornNotify Object.""" super().__init__(**kwargs) # Access Token (associated with project) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid PopcornNotify API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Prepare Batch Mode Flag self.batch = batch # Parse our targets self.targets = [] for target in parse_list(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if result: # store valid phone number self.targets.append(result["full"]) continue result = is_email(target) if result: # store valid email self.targets.append(result["full_email"]) continue self.logger.warning( f"Dropped invalid target ({target}) specified.", ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform PopcornNotify Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning( "There were no PopcornNotify 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/x-www-form-urlencoded", } # Prepare our payload payload = { "message": body, "subject": title, } auth = (self.apikey, None) # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size for index in range(0, len(self.targets), batch_size): # Prepare our recipients payload["recipients"] = ",".join( self.targets[index : index + batch_size] ) self.logger.debug( "PopcornNotify POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"PopcornNotify Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, auth=auth, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyPopcornNotify.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send {} PopcornNotify notification{}: " "{}{}error={}.".format( len(self.targets[index : index + batch_size]), ( f" to {self.targets[index]}" if 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 {} PopcornNotify notification{}.".format( len(self.targets[index : index + batch_size]), ( f" to {self.targets[index]}" if batch_size == 1 else "(s)" ), ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occured sending" f" {len(self.targets[index : index + batch_size])} " "PopcornNotify notification(s)." ) 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.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", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{apikey}/{targets}/?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [NotifyPopcornNotify.quote(x, safe="") for x in self.targets] ), params=NotifyPopcornNotify.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 @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"] = NotifyPopcornNotify.split_path( results["fullpath"] ) # The hostname is our authentication key results["apikey"] = NotifyPopcornNotify.unquote(results["host"]) # 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"] += NotifyPopcornNotify.parse_list( results["qsd"]["to"] ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) return results apprise-1.10.0/apprise/plugins/postmark.py000066400000000000000000000526461517341665700206260ustar00rootroot00000000000000# 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. # Steps to get your Postmark Server API Token: # 1. Visit https://account.postmarkapp.com/ and sign in. # 2. Select the server you wish to send from (or create a new one). # 3. In the server settings, click "API Tokens". # 4. Copy the Server API Token shown on that page. # # Your sender address (From Email) must be a verified sender signature # or belong to a verified sending domain in Postmark. Visit: # https://account.postmarkapp.com/signature_domains # # Build your Apprise URL as follows: # postmark://{apikey}:{from_email} # postmark://{apikey}:{from_email}/{to_email} # postmark://{apikey}:{from_email}/{to_email1}/{to_email2}/{to_emailN} # # Use the optional query parameters to add CC, BCC, Reply-To: # postmark://{apikey}:{from_email}?bcc=bcc@example.com # postmark://{apikey}:{from_email}?cc=cc@example.com # postmark://{apikey}:{from_email}?reply=reply@example.com # postmark://{apikey}:{from_email}?name=Display+Name # # API Reference: # https://postmarkapp.com/developer/api/email-api from email.utils import formataddr from json import dumps import logging import requests from .. import exception from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_emails, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase # Provide some known codes Postmark uses and what they translate to. # Reference: https://postmarkapp.com/developer/api/overview#error-codes POSTMARK_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid or missing Server API Token.", 422: "Unprocessable Entity - Invalid payload or configuration.", 429: "Too Many Requests - Rate limit exceeded.", 500: "Internal Server Error.", } class NotifyPostmark(NotifyBase): """A wrapper for Postmark Notifications.""" # The default descriptive name associated with the Notification service_name = "Postmark" # The services URL service_url = "https://postmarkapp.com/" # The default secure protocol secure_protocol = "postmark" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/postmark/" # Default to HTML notify_format = NotifyFormat.HTML # The Postmark email API endpoint notify_url = "https://api.postmarkapp.com/email" # Support attachments attachment_support = True # Postmark practical send rate is ~50 messages/second. # 60 / 50 = 1.2 request_rate_per_sec = 1.2 # The default subject to use if one is not specified default_empty_subject = "" # Define object URL templates templates = ( "{schema}://{apikey}:{from_email}", "{schema}://{apikey}:{from_email}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, "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, **{ "name": { "name": _("From Name"), "type": "string", "map_to": "from_name", }, "reply": { "name": _("Reply To Email"), "type": "list: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, cc=None, bcc=None, from_name=None, reply_to=None, **kwargs, ): """Initialize Notify Postmark Object.""" super().__init__(**kwargs) # API Key (Server Token) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid Postmark API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Validate our from email address result = is_email(from_email) if not result: msg = ( f"An invalid Postmark From email ({from_email}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Store our from address as a (name, email) tuple self.from_addr = ( result["name"] if result["name"] is not None else False, result["full_email"], ) # from_name overrides any name embedded in the from email string if from_name: self.from_addr = (from_name, self.from_addr[1]) # For tracking email -> display name lookups self.names = {} # Acquire Targets (To Emails) self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Acquire Reply-To addresses self.reply_to = set() # Validate recipients (to:) and drop bad ones: if targets: for recipient in parse_emails(targets): result = is_email(recipient) if result: self.targets.append(result["full_email"]) # Index name if one exists self.names[result["full_email"]] = ( result["name"] if result["name"] else False ) continue self.logger.warning( "Dropped invalid Postmark To email " f"({recipient}) specified.", ) else: # Default to the from address when no targets are specified self.targets.append(self.from_addr[1]) # Validate recipients (cc:) and drop bad ones: for recipient in parse_emails(cc): result = is_email(recipient) if result: self.cc.add(result["full_email"]) # Index name if one exists self.names[result["full_email"]] = ( result["name"] if result["name"] else False ) continue self.logger.warning( "Dropped invalid Postmark Carbon Copy email " f"({recipient}) specified.", ) # Validate recipients (bcc:) and drop bad ones: for recipient in parse_emails(bcc): result = is_email(recipient) if result: self.bcc.add(result["full_email"]) continue self.logger.warning( "Dropped invalid Postmark Blind Carbon Copy " f"email ({recipient}) specified.", ) # Validate reply-to addresses and drop bad ones: for recipient in parse_emails(reply_to): result = is_email(recipient) if result: self.reply_to.add(result["full_email"]) # Index name if one exists self.names[result["full_email"]] = ( result["name"] if result["name"] else False ) continue self.logger.warning( "Dropped invalid Postmark Reply To 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_addr[1]) 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) # Include from display name if one is set if self.from_addr[0]: params["name"] = self.from_addr[0] if self.cc: # Handle our Carbon Copy Addresses params["cc"] = ",".join( [ formataddr( (self.names.get(e, False), e), charset="utf-8", ).replace(",", "%2C") for e in self.cc ] ) if self.bcc: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join(self.bcc) if self.reply_to: # Handle our Reply-To Addresses params["reply"] = ",".join( [ formataddr( (self.names.get(e, False), e), charset="utf-8", ).replace(",", "%2C") for e in self.reply_to ] ) # Determine whether to display target emails in the URL has_targets = not ( len(self.targets) == 1 and self.targets[0] == self.from_addr[1] ) 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 role in our hostname from_email=self.from_addr[1], targets=( "" if not has_targets else "/".join( [NotifyPostmark.quote(x, safe="@") for x in self.targets] ) ), params=NotifyPostmark.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 Postmark Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Postmark email recipients to notify" ) return False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", "X-Postmark-Server-Token": self.apikey, } # error tracking (used for function return) has_error = False # Prepare the From field (with optional display name) from_field = formataddr(self.from_addr, charset="utf-8") # Base payload template (shared across all targets) payload_ = { "From": from_field, "Subject": (title if title else self.default_empty_subject), } # Set body content based on the notify format if self.notify_format == NotifyFormat.HTML: payload_["HtmlBody"] = body else: payload_["TextBody"] = body if attach and self.attachment_support: # Prepare attachment list 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 Postmark attachment" f" {attachment.url(privacy=True)}." ) return False try: # Append base64-encoded attachment to list attachments.append( { "Name": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "Content": attachment.base64(), "ContentType": attachment.mimetype, } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Postmark attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Postmark attachment" f" {attachment.url(privacy=True)}" ) # Append our attachment list to the base payload payload_["Attachments"] = attachments # Iterate over each recipient target targets = list(self.targets) while len(targets) > 0: target = targets.pop(0) # Create a per-target copy of our base payload payload = payload_.copy() # Unique cc/bcc/reply-to management -- remove target from each cc = self.cc - self.bcc - {target} bcc = self.bcc - {target} reply_to = self.reply_to - {target} # Set our main recipient payload["To"] = target if cc: # Format CC addresses with optional display names payload["Cc"] = ",".join( [ formataddr( (self.names.get(a, False), a), charset="utf-8", ) for a in cc ] ) if bcc: # BCC addresses (plain, no display names) payload["Bcc"] = ",".join(bcc) if reply_to: # Format Reply-To addresses with optional display names payload["ReplyTo"] = ",".join( [ formataddr( (self.names.get(a, False), a), charset="utf-8", ) for a in reply_to ] ) # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments, output can be quite heavy; only # show the debug payload when debug logging is active. self.logger.debug( "Postmark POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "Postmark 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, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyPostmark.http_response_code_lookup( r.status_code, POSTMARK_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Postmark 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 Postmark notification to {target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Postmark " 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, verify_host=False) 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 user domain # (from email parts) # Handle apikey= query param override if "apikey" in results["qsd"] and results["qsd"]["apikey"]: results["apikey"] = NotifyPostmark.unquote( results["qsd"]["apikey"] ) else: # Fall back to the user field results["apikey"] = NotifyPostmark.unquote(results["user"]) # Our targets list results["targets"] = [] # Handle from= query param (alternative from address) if "from" in results["qsd"] and results["qsd"]["from"]: results["from_email"] = NotifyPostmark.unquote( results["qsd"]["from"] ) # Treat the host as a target when from= is given explicitly if results.get("host"): results["targets"].append( NotifyPostmark.unquote(results["host"]) ) else: # Reconstruct from_email from {password}@{host} results["from_email"] = "{}@{}".format( NotifyPostmark.unquote( results["password"] if results["password"] else results["user"] ), NotifyPostmark.unquote(results["host"]), ) # Handle from display name if "name" in results["qsd"] and results["qsd"]["name"]: results["from_name"] = NotifyPostmark.unquote( results["qsd"]["name"] ) # Acquire targets from the URL path results["targets"].extend( NotifyPostmark.split_path(results["fullpath"]) ) # Support ?to= for additional targets if "to" in results["qsd"] and results["qsd"]["to"]: results["targets"] += NotifyPostmark.parse_list( results["qsd"]["to"] ) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and results["qsd"]["cc"]: results["cc"] = NotifyPostmark.parse_list(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and results["qsd"]["bcc"]: results["bcc"] = NotifyPostmark.parse_list(results["qsd"]["bcc"]) # Handle Reply-To Addresses if "reply" in results["qsd"] and results["qsd"]["reply"]: results["reply_to"] = results["qsd"]["reply"] return results apprise-1.10.0/apprise/plugins/prowl.py000066400000000000000000000240231517341665700201150ustar00rootroot00000000000000# 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 requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase # Priorities class ProwlPriority: LOW = -2 MODERATE = -1 NORMAL = 0 HIGH = 1 EMERGENCY = 2 PROWL_PRIORITIES = { # Note: This also acts as a reverse lookup mapping ProwlPriority.LOW: "low", ProwlPriority.MODERATE: "moderate", ProwlPriority.NORMAL: "normal", ProwlPriority.HIGH: "high", ProwlPriority.EMERGENCY: "emergency", } PROWL_PRIORITY_MAP = { # Maps against string 'low' "l": ProwlPriority.LOW, # Maps against string 'moderate' "m": ProwlPriority.MODERATE, # Maps against string 'normal' "n": ProwlPriority.NORMAL, # Maps against string 'high' "h": ProwlPriority.HIGH, # Maps against string 'emergency' "e": ProwlPriority.EMERGENCY, # Entries to additionally support (so more like Prowl's API) "-2": ProwlPriority.LOW, "-1": ProwlPriority.MODERATE, "0": ProwlPriority.NORMAL, "1": ProwlPriority.HIGH, "2": ProwlPriority.EMERGENCY, } # Provide some known codes Prowl uses and what they translate to: PROWL_HTTP_ERROR_MAP = { 406: "IP address has exceeded API limit", 409: "Request not aproved.", } class NotifyProwl(NotifyBase): """A wrapper for Prowl Notifications.""" # The default descriptive name associated with the Notification service_name = "Prowl" # The services URL service_url = "https://www.prowlapp.com/" # The default secure protocol secure_protocol = "prowl" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/prowl/" # Prowl uses the http protocol with JSON requests notify_url = "https://api.prowlapp.com/publicapi/add" # Disable throttle rate for Prowl requests since they are normally # local anyway request_rate_per_sec = 0 # The maximum allowable characters allowed in the body per message body_maxlen = 10000 # Defines the maximum allowable characters in the title title_maxlen = 1024 # Define object templates templates = ( "{schema}://{apikey}", "{schema}://{apikey}/{providerkey}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Za-z0-9]{40}$", "i"), }, "providerkey": { "name": _("Provider Key"), "type": "string", "private": True, "regex": (r"^[A-Za-z0-9]{40}$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "priority": { "name": _("Priority"), "type": "choice:int", "values": PROWL_PRIORITIES, "default": ProwlPriority.NORMAL, }, }, ) def __init__(self, apikey, providerkey=None, priority=None, **kwargs): """Initialize Prowl Object.""" super().__init__(**kwargs) # The Priority of the message self.priority = ( NotifyProwl.template_args["priority"]["default"] if not priority else next( ( v for k, v in PROWL_PRIORITY_MAP.items() if str(priority).lower().startswith(k) ), NotifyProwl.template_args["priority"]["default"], ) ) # API Key (associated with project) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid Prowl API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Store the provider key (if specified) if providerkey: self.providerkey = validate_regex( providerkey, *self.template_tokens["providerkey"]["regex"] ) if not self.providerkey: msg = ( "An invalid Prowl Provider Key " f"({providerkey}) was specified." ) self.logger.warning(msg) raise TypeError(msg) else: # No provider key was set self.providerkey = None return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Prowl Notification.""" headers = { "User-Agent": self.app_id, "Content-type": "application/x-www-form-urlencoded", } # prepare JSON Object payload = { "apikey": self.apikey, "application": self.app_id, "event": title, "description": body, "priority": self.priority, } if self.providerkey: payload["providerkey"] = self.providerkey self.logger.debug( "Prowl POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Prowl Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code, PROWL_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Prowl 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 Prowl notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Prowl notification." ) 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, self.apikey, self.providerkey) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "priority": ( PROWL_PRIORITIES[self.template_args["priority"]["default"]] if self.priority not in PROWL_PRIORITIES else PROWL_PRIORITIES[self.priority] ), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{apikey}/{providerkey}/?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), providerkey=self.pprint(self.providerkey, privacy, safe=""), params=NotifyProwl.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 # Set the API Key results["apikey"] = NotifyProwl.unquote(results["host"]) # Optionally try to find the provider key with contextlib.suppress(IndexError): results["providerkey"] = NotifyProwl.split_path( results["fullpath"] )[0] # Set our priority if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyProwl.unquote( results["qsd"]["priority"] ) return results apprise-1.10.0/apprise/plugins/pushbullet.py000066400000000000000000000376151517341665700211540ustar00rootroot00000000000000# 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 import requests from ..attachment.base import AttachBase from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_list, validate_regex from .base import NotifyBase # Flag used as a placeholder to sending to all devices PUSHBULLET_SEND_TO_ALL = "ALL_DEVICES" # Provide some known codes Pushbullet uses and what they translate to: PUSHBULLET_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", } class NotifyPushBullet(NotifyBase): """A wrapper for PushBullet Notifications.""" # The default descriptive name associated with the Notification service_name = "Pushbullet" # The services URL service_url = "https://www.pushbullet.com/" # The default secure protocol secure_protocol = "pbul" # Allow 50 requests per minute (Tier 2). # 60/50 = 0.2 request_rate_per_sec = 1.2 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushbullet/" # PushBullet uses the http protocol with JSON requests notify_url = "https://api.pushbullet.com/v2/{}" # Support attachments attachment_support = True # Define object templates templates = ( "{schema}://{accesstoken}", "{schema}://{accesstoken}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "accesstoken": { "name": _("Access Token"), "type": "string", "private": True, "required": True, }, "target_device": { "name": _("Target Device"), "type": "string", "map_to": "targets", }, "target_channel": { "name": _("Target Channel"), "type": "string", "prefix": "#", "map_to": "targets", }, "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, **{ "to": { "alias_of": "targets", }, }, ) def __init__(self, accesstoken, targets=None, **kwargs): """Initialize PushBullet Object.""" super().__init__(**kwargs) # Access Token (associated with project) self.accesstoken = validate_regex(accesstoken) if not self.accesstoken: msg = ( "An invalid PushBullet Access Token " f"({accesstoken}) was specified." ) self.logger.warning(msg) raise TypeError(msg) self.targets = parse_list(targets) if len(self.targets) == 0: self.targets = (PUSHBULLET_SEND_TO_ALL,) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform PushBullet Notification.""" # error tracking (used for function return) has_error = False # Build a list of our attachments attachments = [] if attach and self.attachment_support: # 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 self.logger.debug( "Preparing PushBullet attachment" f" {attachment.url(privacy=True)}" ) # prepare payload payload = { "file_name": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "file_type": attachment.mimetype, } # First thing we need to do is make a request so that we can # get a URL to post our request to. # see: https://docs.pushbullet.com/#upload-request okay, response = self._send( self.notify_url.format("upload-request"), payload ) if not okay: # We can't post our attachment return False # If we get here, our output will look something like this: # { # "file_name": "cat.jpg", # "file_type": "image/jpeg", # "file_url": "https://dl.pushb.com/abc/cat.jpg", # "upload_url": "https://upload.pushbullet.com/abcd123" # } # - The file_url is where the file will be available after it # is uploaded. # - The upload_url is where to POST the file to. The file must # be posted using multipart/form-data encoding. # Prepare our attachment payload; we'll use this if we # successfully upload the content below for later on. try: # By placing this in a try/except block we can validate # our response at the same time as preparing our payload payload = { # PushBullet v2/pushes file type: "type": "file", "file_name": response["file_name"], "file_type": response["file_type"], "file_url": response["file_url"], } if response["file_type"].startswith("image/"): # Allow image to be displayed inline (if image type) payload["image_url"] = response["file_url"] upload_url = response["upload_url"] except (KeyError, TypeError): # A method of verifying our content exists return False okay, response = self._send(upload_url, attachment) if not okay: # We can't post our attachment return False # Save our pre-prepared payload for attachment posting attachments.append(payload) # Create a copy of the targets list targets = list(self.targets) while len(targets): recipient = targets.pop(0) # prepare payload payload = { "type": "note", "title": title, "body": body, } # Check if an email was defined match = is_email(recipient) if match: payload["email"] = match["full_email"] self.logger.debug( f"PushBullet recipient {recipient} parsed as an email" " address" ) elif recipient is PUSHBULLET_SEND_TO_ALL: # Send to all pass elif recipient[0] == "#": payload["channel_tag"] = recipient[1:] self.logger.debug( f"PushBullet recipient {recipient} parsed as a channel" ) else: payload["device_iden"] = recipient self.logger.debug( f"PushBullet recipient {recipient} parsed as a device" ) if body: okay, response = self._send( self.notify_url.format("pushes"), payload ) if not okay: has_error = True continue self.logger.info( f'Sent PushBullet notification to "{recipient}".' ) for attach_payload in attachments: # Send our attachments to our same user (already prepared as # our payload object) okay, response = self._send( self.notify_url.format("pushes"), attach_payload ) if not okay: has_error = True continue self.logger.info( 'Sent PushBullet attachment ({}) to "{}".'.format( attach_payload["file_name"], recipient ) ) return not has_error def _send(self, url, payload, **kwargs): """Wrapper to the requests (post) object.""" headers = { "User-Agent": self.app_id, } # Some default values for our request object to which we'll update # depending on what our payload is files = None data = None if not isinstance(payload, AttachBase): # Send our payload as a JSON object headers["Content-Type"] = "application/json" data = dumps(payload) if payload else None auth = (self.accesstoken, "") self.logger.debug( "PushBullet POST URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"PushBullet Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() # Default response type response = None try: # Open our attachment path if required: if isinstance(payload, AttachBase): files = { "file": ( payload.name, # file handle is safely closed in `finally`; inline # open is intentional open(payload.path, "rb"), # noqa: SIM115 ) } r = requests.post( url, data=data, headers=headers, files=files, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) try: response = loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None # Fall back to the existing unparsed value response = r.content if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifyPushBullet.http_response_code_lookup( r.status_code, PUSHBULLET_HTTP_ERROR_MAP ) self.logger.warning( "Failed to deliver payload to PushBullet:" "{}{}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, response # otherwise we were successful return True, response except requests.RequestException as e: self.logger.warning( "A Connection error occurred communicating with PushBullet." ) self.logger.debug(f"Socket Exception: {e!s}") return False, response 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, response finally: # Close our file (if it's open) stored in the second element # of our files tuple (index 1) if files: files["file"][1].close() @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.accesstoken) 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) targets = "/".join([NotifyPushBullet.quote(x) for x in self.targets]) if targets == PUSHBULLET_SEND_TO_ALL: # keyword is reserved for internal usage only; it's safe to remove # it from the recipients list targets = "" return "{schema}://{accesstoken}/{targets}/?{params}".format( schema=self.secure_protocol, accesstoken=self.pprint(self.accesstoken, privacy, safe=""), targets=targets, params=NotifyPushBullet.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, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Fetch our targets results["targets"] = NotifyPushBullet.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"] += NotifyPushBullet.parse_list( results["qsd"]["to"] ) # Setup the token; we store it in Access Token for global # plugin consistency with naming conventions results["accesstoken"] = NotifyPushBullet.unquote(results["host"]) return results apprise-1.10.0/apprise/plugins/pushdeer.py000066400000000000000000000161371517341665700206000ustar00rootroot00000000000000# 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 requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase # Syntax: # schan://{key}/ class NotifyPushDeer(NotifyBase): """A wrapper for PushDeer Notifications.""" # The default descriptive name associated with the Notification service_name = "PushDeer" # The services URL service_url = "https://www.pushdeer.com/" # Insecure Protocol Access protocol = "pushdeer" # Secure Protocol secure_protocol = "pushdeers" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushdeer/" # Default hostname default_hostname = "api2.pushdeer.com" # PushDeer API notify_url = "{schema}://{host}:{port}/message/push?pushkey={pushKey}" # Define object templates templates = ( "{schema}://{pushkey}", "{schema}://{host}/{pushkey}", "{schema}://{host}:{port}/{pushkey}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "pushkey": { "name": _("Pushkey"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, }, ) def __init__(self, pushkey, **kwargs): """Initialize PushDeer Object.""" super().__init__(**kwargs) # PushKey (associated with project) self.push_key = validate_regex( pushkey, *self.template_tokens["pushkey"]["regex"] ) if not self.push_key: msg = f"An invalid PushDeer API Pushkey ({pushkey}) was specified." self.logger.warning(msg) raise TypeError(msg) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform PushDeer Notification.""" # Prepare our persistent_notification.create payload payload = { "text": title if title else body, "type": "text", "desp": body if title else "", } # Set our schema schema = "https" if self.secure else "http" # Set host host = self.default_hostname if self.host: host = self.host # Set port port = 443 if self.secure else 80 if self.port: port = self.port # Our Notification URL notify_url = self.notify_url.format( schema=schema, host=host, port=port, pushKey=self.push_key ) # Some Debug Logging self.logger.debug( "PushDeer URL:" f" {notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"PushDeer Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=payload, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyPushDeer.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send PushDeer 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 PushDeer notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occured sending PushDeer notification." ) self.logger.debug(f"Socket Exception: {e!s}") 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.push_key, self.host, self.port, ) def url(self, privacy=False): """Returns the URL built dynamically based on specified arguments.""" if self.host: url = "{schema}://{host}{port}/{pushkey}" else: url = "{schema}://{pushkey}" return url.format( schema=self.secure_protocol if self.secure else self.protocol, host=self.host, port="" if not self.port else f":{self.port}", pushkey=self.pprint(self.push_key, privacy, safe=""), ) @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 parse the URL return results fullpaths = NotifyPushDeer.split_path(results["fullpath"]) if len(fullpaths) == 0: results["pushkey"] = results["host"] results["host"] = None else: results["pushkey"] = fullpaths.pop() return results apprise-1.10.0/apprise/plugins/pushed.py000066400000000000000000000306531517341665700202500ustar00rootroot00000000000000# 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 from json import dumps import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_list, validate_regex from .base import NotifyBase # Used to detect and parse channels IS_CHANNEL = re.compile(r"^#?(?P[A-Za-z0-9]+)$") # Used to detect and parse a users push id IS_USER_PUSHED_ID = re.compile(r"^@(?P[A-Za-z0-9]+)$") class NotifyPushed(NotifyBase): """A wrapper to Pushed Notifications.""" # The default descriptive name associated with the Notification service_name = "Pushed" # The services URL service_url = "https://pushed.co/" # The default secure protocol secure_protocol = "pushed" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushed/" # Pushed uses the http protocol with JSON requests notify_url = "https://api.pushed.co/1/push" # A title can not be used for Pushed Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # The maximum allowable characters allowed in the body per message body_maxlen = 160 # Define object templates templates = ( "{schema}://{app_key}/{app_secret}", "{schema}://{app_key}/{app_secret}@{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "app_key": { "name": _("Application Key"), "type": "string", "private": True, "required": True, }, "app_secret": { "name": _("Application Secret"), "type": "string", "private": True, "required": True, }, "target_user": { "name": _("Target User"), "prefix": "@", "type": "string", "map_to": "targets", }, "target_channel": { "name": _("Target Channel"), "type": "string", "prefix": "#", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, }, ) def __init__(self, app_key, app_secret, targets=None, **kwargs): """Initialize Pushed Object.""" super().__init__(**kwargs) # Application Key (associated with project) self.app_key = validate_regex(app_key) if not self.app_key: msg = ( f"An invalid Pushed Application Key ({app_key}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Access Secret (associated with project) self.app_secret = validate_regex(app_secret) if not self.app_secret: msg = ( "An invalid Pushed Application Secret " f"({app_secret}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Initialize channel list self.channels = [] # Initialize user list self.users = [] # Get our targets targets = parse_list(targets) if targets: # Validate recipients and drop bad ones: for target in targets: result = IS_CHANNEL.match(target) if result: # store valid device self.channels.append(result.group("name")) continue result = IS_USER_PUSHED_ID.match(target) if result: # store valid room self.users.append(result.group("name")) continue self.logger.warning( f"Dropped invalid channel/userid ({target}) specified.", ) if len(self.channels) + len(self.users) == 0: # We have no valid channels or users to notify after # explicitly identifying at least one. msg = "No Pushed targets to notify." self.logger.warning(msg) raise TypeError(msg) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Pushed Notification.""" # Initiaize our error tracking has_error = False # prepare JSON Object payload = { "app_key": self.app_key, "app_secret": self.app_secret, "target_type": "app", "content": body, } # So the logic is as follows: # - if no user/channel was specified, then we just simply notify the # app. # - if there are user/channels specified, then we only alert them # while respecting throttle limits (in the event there are a lot of # entries. if len(self.channels) + len(self.users) == 0: # Just notify the app return self._send( payload=payload, notify_type=notify_type, **kwargs ) # If our code reaches here, we want to target channels and users (by # their Pushed_ID instead... # Generate a copy of our original list channels = list(self.channels) users = list(self.users) # Copy our payload payload_ = dict(payload) payload_["target_type"] = "channel" while len(channels) > 0: # Get Channel payload_["target_alias"] = channels.pop(0) if not self._send( payload=payload_, notify_type=notify_type, **kwargs ): # toggle flag has_error = True # Copy our payload payload_ = dict(payload) payload_["target_type"] = "pushed_id" # Send all our defined User Pushed ID's while len(users): # Get User's Pushed ID payload_["pushed_id"] = users.pop(0) if not self._send( payload=payload_, notify_type=notify_type, **kwargs ): # toggle flag has_error = True return not has_error def _send(self, payload, notify_type, **kwargs): """A lower level call that directly pushes a payload to the Pushed Notification servers. This should never be called directly; it is referenced automatically through the send() function. """ headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } self.logger.debug( "Pushed POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Pushed 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), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyPushed.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Pushed 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 Pushed notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Pushed notification." ) 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, self.app_key, self.app_secret) 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) return "{schema}://{app_key}/{app_secret}/{targets}/?{params}".format( schema=self.secure_protocol, app_key=self.pprint(self.app_key, privacy, safe=""), app_secret=self.pprint( self.app_secret, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( [ NotifyPushed.quote(x) for x in chain( # Channels are prefixed with a pound/hashtag symbol [f"#{x}" for x in self.channels], # Users are prefixed with an @ symbol [f"@{x}" for x in self.users], ) ] ), params=NotifyPushed.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.channels) + len(self.users) 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 first token is stored in the hostname app_key = NotifyPushed.unquote(results["host"]) entries = NotifyPushed.split_path(results["fullpath"]) # Now fetch the remaining tokens try: app_secret = entries.pop(0) except IndexError: # Force some bad values that will get caught # in parsing later app_secret = None app_key = None # Get our recipients (based on remaining entries) results["targets"] = entries # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyPushed.parse_list(results["qsd"]["to"]) results["app_key"] = app_key results["app_secret"] = app_secret return results apprise-1.10.0/apprise/plugins/pushjet.py000066400000000000000000000226101517341665700204340ustar00rootroot00000000000000# 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 requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import validate_regex from .base import NotifyBase class NotifyPushjet(NotifyBase): """A wrapper for Pushjet Notifications.""" # The default descriptive name associated with the Notification service_name = "Pushjet" # The default protocol protocol = "pjet" # The default secure protocol secure_protocol = "pjets" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushjet/" # Disable throttle rate for Pushjet requests since they are normally # local anyway (the remote/online service is no more) request_rate_per_sec = 0 # Define object templates templates = ( "{schema}://{host}:{port}/{secret_key}", "{schema}://{host}/{secret_key}", "{schema}://{user}:{password}@{host}:{port}/{secret_key}", "{schema}://{user}:{password}@{host}/{secret_key}", ) # Define our tokens template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "secret_key": { "name": _("Secret Key"), "type": "string", "required": True, "private": True, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) template_args = dict( NotifyBase.template_args, **{ "secret": { "alias_of": "secret_key", }, }, ) def __init__(self, secret_key, **kwargs): """Initialize Pushjet Object.""" super().__init__(**kwargs) # Secret Key (associated with project) self.secret_key = validate_regex(secret_key) if not self.secret_key: msg = ( f"An invalid Pushjet Secret Key ({secret_key}) was specified." ) self.logger.warning(msg) raise TypeError(msg) 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 if self.secure else self.protocol, self.user, self.password, self.host, self.port, self.secret_key, ) 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) default_port = 443 if self.secure else 80 # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyPushjet.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{hostname}{port}/{secret}/?{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}" ), secret=self.pprint( self.secret_key, privacy, mode=PrivacyMode.Secret, safe="" ), params=NotifyPushjet.urlencode(params), ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Pushjet Notification.""" params = { "secret": self.secret_key, } # prepare Pushjet Object payload = { "message": body, "title": title, "link": None, "level": None, } headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", } auth = None if self.user: auth = (self.user, self.password) notify_url = "{schema}://{host}{port}/message/".format( schema="https" if self.secure else "http", host=self.host, port=f":{self.port}" if self.port else "", ) self.logger.debug( "Pushjet POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Pushjet Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, params=params, data=dumps(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyPushjet.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Pushjet 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 Pushjet notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Pushjet " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object. Syntax: pjet://hostname/secret_key pjet://hostname:port/secret_key pjet://user:pass@hostname/secret_key pjet://user:pass@hostname:port/secret_key pjets://hostname/secret_key pjets://hostname:port/secret_key pjets://user:pass@hostname/secret_key pjets://user:pass@hostname:port/secret_key """ results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results try: # Retrieve our secret_key from the first entry in the url path results["secret_key"] = NotifyPushjet.split_path( results["fullpath"] )[0] except IndexError: # no secret key specified results["secret_key"] = None # Allow over-riding the secret by specifying it as an argument # this allows people who have http-auth infront to login # through it in addition to supporting the secret key if "secret" in results["qsd"] and len(results["qsd"]["secret"]): results["secret_key"] = NotifyPushjet.unquote( results["qsd"]["secret"] ) return results apprise-1.10.0/apprise/plugins/pushme.py000066400000000000000000000164101517341665700202540ustar00rootroot00000000000000# 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 requests from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, validate_regex from .base import NotifyBase class NotifyPushMe(NotifyBase): """A wrapper for PushMe Notifications.""" # The default descriptive name associated with the Notification service_name = "PushMe" # The services URL service_url = "https://push.i-i.me/" # Insecure protocol (for those self hosted requests) protocol = "pushme" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushme/" # PushMe URL notify_url = "https://push.i-i.me/" # Define object templates templates = ("{schema}://{token}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, "push_key": { "alias_of": "token", }, "status": { "name": _("Show Status"), "type": "bool", "default": True, }, }, ) def __init__(self, token, status=None, **kwargs): """Initialize PushMe Object.""" super().__init__(**kwargs) # Token (associated with project) self.token = validate_regex(token) if not self.token: msg = f"An invalid PushMe Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # Set Status type self.status = status return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform PushMe Notification.""" headers = { "User-Agent": self.app_id, } # Prepare our payload params = { "push_key": self.token, "title": ( title if not self.status else f"{self.asset.ascii(notify_type)} {title}" ), "content": body, "type": ( "markdown" if self.notify_format == NotifyFormat.MARKDOWN else "text" ), } self.logger.debug( "PushMe POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"PushMe Payload: {params!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyPushMe.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send PushMe 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 PushMe notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending PushMe notification.", ) 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, self.token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "status": "yes" if self.status else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Official URLs are easy to assemble return "{schema}://{token}/?{params}".format( schema=self.protocol, token=self.pprint(self.token, privacy, safe=""), params=NotifyPushMe.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 # Store our token using the host results["token"] = NotifyPushMe.unquote(results["host"]) # The 'token' makes it easier to use yaml configuration if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyPushMe.unquote(results["qsd"]["token"]) elif "push_key" in results["qsd"] and len(results["qsd"]["push_key"]): # Support 'push_key' if specified results["token"] = NotifyPushMe.unquote(results["qsd"]["push_key"]) # Get status switch results["status"] = parse_bool(results["qsd"].get("status", True)) return results apprise-1.10.0/apprise/plugins/pushover.py000066400000000000000000000603531517341665700206330ustar00rootroot00000000000000# 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 re import requests from ..attachment.base import AttachBase from ..common import NotifyFormat, NotifyType from ..conversion import convert_between from ..locale import gettext_lazy as _ from ..utils.parse import parse_list, validate_regex from .base import NotifyBase # Flag used as a placeholder to sending to all devices PUSHOVER_SEND_TO_ALL = "ALL_DEVICES" # Used to detect a Device VALIDATE_DEVICE = re.compile(r"^\s*(?P[a-z0-9_-]{1,25})\s*$", re.I) # Used to detect a Group key (same format as user key: alphanumeric, prefixed # with # or its URL-encoded equivalent %23) VALIDATE_GROUP = re.compile(r"^\s*(%23|#)(?P[a-z0-9]+)\s*$", re.I) # Priorities class PushoverPriority: LOW = -2 MODERATE = -1 NORMAL = 0 HIGH = 1 EMERGENCY = 2 # Sounds class PushoverSound: PUSHOVER = "pushover" BIKE = "bike" BUGLE = "bugle" CASHREGISTER = "cashregister" CLASSICAL = "classical" COSMIC = "cosmic" FALLING = "falling" GAMELAN = "gamelan" INCOMING = "incoming" INTERMISSION = "intermission" MAGIC = "magic" MECHANICAL = "mechanical" PIANOBAR = "pianobar" SIREN = "siren" SPACEALARM = "spacealarm" TUGBOAT = "tugboat" ALIEN = "alien" CLIMB = "climb" PERSISTENT = "persistent" ECHO = "echo" UPDOWN = "updown" NONE = "none" PUSHOVER_SOUNDS = ( PushoverSound.PUSHOVER, PushoverSound.BIKE, PushoverSound.BUGLE, PushoverSound.CASHREGISTER, PushoverSound.CLASSICAL, PushoverSound.COSMIC, PushoverSound.FALLING, PushoverSound.GAMELAN, PushoverSound.INCOMING, PushoverSound.INTERMISSION, PushoverSound.MAGIC, PushoverSound.MECHANICAL, PushoverSound.PIANOBAR, PushoverSound.SIREN, PushoverSound.SPACEALARM, PushoverSound.TUGBOAT, PushoverSound.ALIEN, PushoverSound.CLIMB, PushoverSound.PERSISTENT, PushoverSound.ECHO, PushoverSound.UPDOWN, PushoverSound.NONE, ) PUSHOVER_PRIORITIES = { # Note: This also acts as a reverse lookup mapping PushoverPriority.LOW: "low", PushoverPriority.MODERATE: "moderate", PushoverPriority.NORMAL: "normal", PushoverPriority.HIGH: "high", PushoverPriority.EMERGENCY: "emergency", } PUSHOVER_PRIORITY_MAP = { # Maps against string 'low' "l": PushoverPriority.LOW, # Maps against string 'moderate' "m": PushoverPriority.MODERATE, # Maps against string 'normal' "n": PushoverPriority.NORMAL, # Maps against string 'high' "h": PushoverPriority.HIGH, # Maps against string 'emergency' "e": PushoverPriority.EMERGENCY, # Entries to additionally support (so more like Pushover's API) "-2": PushoverPriority.LOW, "-1": PushoverPriority.MODERATE, "0": PushoverPriority.NORMAL, "1": PushoverPriority.HIGH, "2": PushoverPriority.EMERGENCY, } # Extend HTTP Error Messages PUSHOVER_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", } class NotifyPushover(NotifyBase): """A wrapper for Pushover Notifications.""" # The default descriptive name associated with the Notification service_name = "Pushover" # The services URL service_url = "https://pushover.net/" # All pushover requests are secure secure_protocol = "pover" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushover/" # Pushover uses the http protocol with JSON requests notify_url = "https://api.pushover.net/1/messages.json" # Support attachments attachment_support = True # The maximum allowable characters allowed in the body per message body_maxlen = 1024 # Default Pushover sound default_pushover_sound = PushoverSound.PUSHOVER # 5MB is the maximum supported image filesize as per documentation # here: https://pushover.net/api#limits (Oct 5th, 2025) attach_max_size_bytes = 5242880 # The regular expression of the current attachment supported mime types # At this time it is only images attach_supported_mime_type = r"^image/.*" # Define object templates templates = ( "{schema}://{user_key}@{token}", "{schema}://{user_key}@{token}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user_key": { "name": _("User Key"), "type": "string", "private": True, "required": True, }, "token": { "name": _("Access Token"), "type": "string", "private": True, "required": True, }, "target_device": { "name": _("Target Device"), "type": "string", "regex": (r"^[a-z0-9_-]{1,25}$", "i"), "map_to": "targets", }, "target_group": { "name": _("Target Group"), "type": "string", "prefix": "#", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "priority": { "name": _("Priority"), "type": "choice:int", "values": PUSHOVER_PRIORITIES, "default": PushoverPriority.NORMAL, }, "sound": { "name": _("Sound"), "type": "string", "regex": (r"^[a-z]{1,12}$", "i"), "default": PushoverSound.PUSHOVER, }, "url": { "name": _("URL"), "map_to": "supplemental_url", "type": "string", }, "url_title": { "name": _("URL Title"), "map_to": "supplemental_url_title", "type": "string", }, "retry": { "name": _("Retry"), "type": "int", "min": 30, "default": 900, # 15 minutes }, "expire": { "name": _("Expire"), "type": "int", "min": 0, "max": 10800, "default": 3600, # 1 hour }, "to": { "alias_of": "targets", }, }, ) def __init__( self, user_key, token, targets=None, priority=None, sound=None, retry=None, expire=None, supplemental_url=None, supplemental_url_title=None, **kwargs, ): """Initialize Pushover Object.""" super().__init__(**kwargs) # Access Token (associated with project) self.token = validate_regex(token) if not self.token: msg = f"An invalid Pushover Access Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # User Key (associated with project) self.user_key = validate_regex(user_key) if not self.user_key: msg = f"An invalid Pushover User Key ({user_key}) was specified." self.logger.warning(msg) raise TypeError(msg) # Track our valid devices and groups separately targets = parse_list(targets) # Track any invalid entries self.invalid_targets = [] self.devices = [] self.groups = [] if not targets: # No targets specified at all: send to all devices of user self.devices = [PUSHOVER_SEND_TO_ALL] else: for target in targets: result = VALIDATE_GROUP.match(target) if result: self.groups.append(result.group("group")) continue result = VALIDATE_DEVICE.match(target) if result: self.devices.append(result.group("device")) continue self.logger.warning( f"Dropped invalid Pushover target ({target}) specified.", ) self.invalid_targets.append(target) # Setup supplemental url self.supplemental_url = supplemental_url self.supplemental_url_title = supplemental_url_title # Setup our sound self.sound = ( NotifyPushover.default_pushover_sound if not isinstance(sound, str) else sound.lower() ) if self.sound and self.sound not in PUSHOVER_SOUNDS: msg = f"Using custom sound specified ({sound}). " self.logger.debug(msg) # The Priority of the message self.priority = int( NotifyPushover.template_args["priority"]["default"] if priority is None else next( ( v for k, v in PUSHOVER_PRIORITY_MAP.items() if str(priority).lower().startswith(k) ), NotifyPushover.template_args["priority"]["default"], ) ) # The following are for emergency alerts if self.priority == PushoverPriority.EMERGENCY: # How often to resend notification, in seconds self.retry = self.template_args["retry"]["default"] with contextlib.suppress(ValueError, TypeError): # Get our retry value self.retry = int(retry) # How often to resend notification, in seconds self.expire = self.template_args["expire"]["default"] with contextlib.suppress(ValueError, TypeError): # Acquire our expiry value self.expire = int(expire) if self.retry < 30: msg = "Pushover retry must be at least 30 seconds." self.logger.warning(msg) raise TypeError(msg) if self.expire < 0 or self.expire > 10800: msg = ( "Pushover expire must reside in the range of " "0 to 10800 seconds." ) self.logger.warning(msg) raise TypeError(msg) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Pushover Notification.""" if not self.devices and not self.groups: # There were no services to notify self.logger.warning("There were no Pushover targets to notify.") return False # Build the base payload shared across all sends base_payload = { "token": self.token, "priority": str(self.priority), "title": title if title else self.app_desc, "message": body, "sound": self.sound, } if self.supplemental_url: base_payload["url"] = self.supplemental_url if self.supplemental_url_title: base_payload["url_title"] = self.supplemental_url_title if self.notify_format == NotifyFormat.HTML: # https://pushover.net/api#html base_payload["html"] = 1 elif self.notify_format == NotifyFormat.MARKDOWN: base_payload["message"] = convert_between( NotifyFormat.MARKDOWN, NotifyFormat.HTML, body ) base_payload["html"] = 1 if self.priority == PushoverPriority.EMERGENCY: base_payload.update({"retry": self.retry, "expire": self.expire}) # Build per-target payloads: # - devices: one call with user=user_key, device=dev1,dev2,... # - groups: one call per group with user=group_key payloads = [] if self.devices: payloads.append( { **base_payload, "user": self.user_key, "device": ",".join(self.devices), } ) for group_key in self.groups: payloads.append( { **base_payload, "user": group_key, } ) has_error = False for payload in payloads: if attach and self.attachment_support: # Create a copy of our payload payload_ = payload.copy() # Send with attachments for no, attachment in enumerate(attach): if no or not body: # To handle multiple attachments, clean up our message payload_["message"] = attachment.name if not self._send(payload_, attachment): # Mark our failure has_error = True # Clear our title if previously set payload_["title"] = "" # No need to alarm for each consecutive attachment # uploaded afterwards payload_["sound"] = PushoverSound.NONE else: if not self._send(payload): has_error = True return not has_error def _send(self, payload, attach=None): """Wrapper to the requests (post) object.""" if isinstance(attach, AttachBase): # Perform some simple error checking if not attach: # We could not access the attachment self.logger.error( f"Could not access attachment {attach.url(privacy=True)}." ) return False # Perform some basic checks as we want to gracefully skip # over unsupported mime types. if not re.match( self.attach_supported_mime_type, attach.mimetype, re.I ): # No problem; we just don't support this attachment # type; gracefully move along self.logger.debug( "Ignored unsupported Pushover attachment" f" ({attach.mimetype}): {attach.url(privacy=True)}" ) attach = None else: # If we get here, we're dealing with a supported image. # Verify that the filesize is okay though. file_size = len(attach) if not ( file_size > 0 and file_size <= self.attach_max_size_bytes ): # File size is no good self.logger.warning( f"Pushover attachment size ({file_size}B) exceeds" f" limit: {attach.url(privacy=True)}" ) return False self.logger.debug( f"Posting Pushover attachment {attach.url(privacy=True)}" ) # Default Header headers = { "User-Agent": self.app_id, } # Authentication auth = (self.token, "") # Some default values for our request object to which we'll update # depending on what our payload is files = None self.logger.debug( "Pushover POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Pushover Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: # Open our attachment path if required: if attach: files = { "attachment": ( attach.name, # file handle is safely closed in `finally`; inline # open is intentional; attach.open() dispatches to # BytesIO for memory attachments attach.open(), ) } r = requests.post( self.notify_url, data=payload, headers=headers, files=files, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyPushover.http_response_code_lookup( r.status_code, PUSHOVER_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Pushover notification to {}: " "{}{}error={}.".format( payload.get("device") or f"group:{payload['user']}", 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 Pushover notification to {}.".format( payload.get("device") or f"group:{payload['user']}" ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Pushover" ":{} notification.".format( payload.get("device") or "group:{}".format(payload["user"]) ) ) self.logger.debug(f"Socket Exception: {e!s}") return False except OSError as e: self.logger.warning( "An I/O error occurred while reading {}.".format( attach.name if attach else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: # Close our file (if it's open) stored in the second element # of our files tuple (index 1) if files: files["attachment"][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, self.user_key, self.token) def __len__(self): """Returns the number of HTTP requests this instance will make. Devices are batched into a single call; each group requires its own. """ # At least 1 if there are any valid targets return max(1, (1 if self.devices else 0) + len(self.groups)) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "priority": ( PUSHOVER_PRIORITIES[self.template_args["priority"]["default"]] if self.priority not in PUSHOVER_PRIORITIES else PUSHOVER_PRIORITIES[self.priority] ), "sound": self.sound, } if self.supplemental_url: params["url"] = self.supplemental_url if self.supplemental_url_title: params["url_title"] = self.supplemental_url_title # Only add expire and retry for emergency messages, # pushover ignores for all other priorities if self.priority == PushoverPriority.EMERGENCY: params.update({"expire": self.expire, "retry": self.retry}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Assemble targets: devices (excluding sentinel), groups (%23-encoded), # then any invalid entries so the URL round-trips faithfully targets = "/".join( [ NotifyPushover.quote(d, safe="") for d in self.devices if d != PUSHOVER_SEND_TO_ALL ] + [NotifyPushover.quote(f"#{g}", safe="") for g in self.groups] + [NotifyPushover.quote(x, safe="") for x in self.invalid_targets] ) return "{schema}://{user_key}@{token}/{targets}/?{params}".format( schema=self.secure_protocol, user_key=self.pprint(self.user_key, privacy, safe=""), token=self.pprint(self.token, privacy, safe=""), targets=targets, params=NotifyPushover.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" # Encode any literal # in the path so group key prefixes survive # Python's urlparse, which treats # as a fragment separator and # silently drops everything after it. if isinstance(url, str): q_idx = url.find("?") _path = url if q_idx < 0 else url[:q_idx] _rest = "" if q_idx < 0 else url[q_idx:] url = _path.replace("#", "%23") + _rest results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Set our priority if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyPushover.unquote( results["qsd"]["priority"] ) # Retrieve all of our targets results["targets"] = NotifyPushover.split_path(results["fullpath"]) # User Key is retrieved from the user results["user_key"] = NotifyPushover.unquote(results["user"]) # Get the sound if "sound" in results["qsd"] and len(results["qsd"]["sound"]): results["sound"] = NotifyPushover.unquote(results["qsd"]["sound"]) # Get the supplementary url if "url" in results["qsd"] and len(results["qsd"]["url"]): results["supplemental_url"] = NotifyPushover.unquote( results["qsd"]["url"] ) if "url_title" in results["qsd"] and len(results["qsd"]["url_title"]): results["supplemental_url_title"] = NotifyPushover.unquote( results["qsd"]["url_title"] ) # Get expire and retry if "expire" in results["qsd"] and len(results["qsd"]["expire"]): results["expire"] = results["qsd"]["expire"] if "retry" in results["qsd"] and len(results["qsd"]["retry"]): results["retry"] = results["qsd"]["retry"] # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyPushover.parse_list( results["qsd"]["to"] ) # Token results["token"] = NotifyPushover.unquote(results["host"]) return results apprise-1.10.0/apprise/plugins/pushplus.py000066400000000000000000000611351517341665700206420ustar00rootroot00000000000000# # 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. # PushPlus is a Chinese notification platform that delivers messages via # WeChat and several other channels. You can find its API documentation at: # https://www.pushplus.plus/doc/guide/api.html # # To obtain your personal token: # 1. Register or sign in at https://www.pushplus.plus/ # 2. Copy the token shown on the dashboard under the "Push" section. # # Group (topic) sending is also supported. After creating a group under # "Group Push" in the PushPlus console, use the group code as the topic: # https://www.pushplus.plus/doc/guide/group.html # # Basic Apprise URL forms: # # Personal notification (WeChat default): # pushplus://{token} # # Group topic -- one notification per topic: # pushplus://{token}/{topic} # pushplus://{token}/{topic1}/{topic2} # # Select a delivery channel (mode= and channel= are synonyms): # pushplus://{token}?channel=mail # pushplus://{token}?mode=cp # # Topic + delivery channel: # pushplus://{token}/{topic}?channel=mail # # Webhook channel with a named endpoint -- two equivalent forms: # pushplus://{token}?channel=webhook&name={webhook_name} # pushplus://{webhook_name}@{token} # # When the schema://{name}@{token} form is used and no explicit channel= # is given, the webhook channel is implied automatically. # # Native PushPlus API URL (also accepted by parse_native_url): # https://www.pushplus.plus/send?token={token} # # Schema alias for WeCom users: # wecom://{token} -- identical to pushplus://{token}?channel=cp # # Note: wechat:// is reserved for a future direct WeChat Official Account # API plugin and is not supported here. # # API References: # https://www.pushplus.plus/doc/guide/api.html # https://www.pushplus.plus/doc/guide/group.html import json import re 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 # PushPlus application-level response codes. # The HTTP status is always 200; the real result lives in the JSON body. # Reference: https://www.pushplus.plus/doc/guide/api.html PUSHPLUS_RESPONSE_CODES = { 200: "Request succeeded.", 900: "System exception.", 903: "Sending failed.", 905: "Request parameter error.", 907: "Token does not exist.", 908: "User is blocked.", 909: "Content requires review before sending.", 912: "No available service package.", } # Map Apprise's standard notify_format values to the PushPlus template # identifiers. PushPlus uses these to render the body server-side before # delivery -- the content itself does not change; only the rendering hint. PUSHPLUS_FORMAT_MAP = { NotifyFormat.HTML: "html", NotifyFormat.MARKDOWN: "markdown", NotifyFormat.TEXT: "txt", } # Default PushPlus template when the format has no explicit mapping PUSHPLUS_FORMAT_DEFAULT = "html" class PushPlusChannel: """Defines the PushPlus delivery channels. The channel controls where the rendered notification is delivered. It is supplied as the channel= (or mode=) query parameter in the Apprise URL, e.g. pushplus://{token}?channel=mail. """ # Deliver via WeChat (PushPlus default) WECHAT = "wechat" # Deliver via a configured webhook endpoint WEBHOOK = "webhook" # Deliver via WeCom (WeChat Work / Enterprise WeChat). # The API value "cp" is PushPlus's internal identifier for this channel. # Both "cp" and the friendly alias "wecom" are accepted; both resolve here. WECOM = "cp" # Deliver via email MAIL = "mail" # Deliver via SMS SMS = "sms" # All valid PushPlus delivery channels (these are the API values) PUSHPLUS_CHANNELS = ( PushPlusChannel.WECHAT, PushPlusChannel.WEBHOOK, PushPlusChannel.WECOM, PushPlusChannel.MAIL, PushPlusChannel.SMS, ) # The PushPlus default delivery channel (WeChat) PUSHPLUS_CHANNEL_DEFAULT = PushPlusChannel.WECHAT # A group topic has no special prefix; alphanumeric codes up to 50 chars. IS_TOPIC = re.compile( r"^(?P[a-z0-9_-]{1,50})$", re.I, ) # Schema names that auto-select a specific delivery channel when used # in place of the default "pushplus" schema. Mirrors the Kodi/XBMC pattern. # Note: wechat:// is intentionally omitted -- it is reserved for a future # direct WeChat Official Account API integration. PUSHPLUS_SCHEMA_MAP = { # wecom:// forces channel=cp (WeCom / Enterprise WeChat) "wecom": PushPlusChannel.WECOM, } class NotifyPushplus(NotifyBase): """A wrapper for PushPlus Notifications.""" # The default descriptive name associated with the Notification service_name = _("Pushplus") # The services URL service_url = "https://www.pushplus.plus/" # The default protocol; wecom:// is accepted as a schema alias. # Note: wechat:// is reserved for a future direct WeChat Official # Account API plugin and is intentionally not listed here. secure_protocol = ("pushplus", "wecom") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushplus/" # URL used to POST notifications notify_url = "https://www.pushplus.plus/send" # Maximum body length documented by PushPlus body_maxlen = 20000 # Title is capped at a safe limit (not explicitly documented by PushPlus) title_maxlen = 200 # Default to HTML since PushPlus renders HTML by default notify_format = NotifyFormat.HTML # Define object URL templates templates = ( # No topics: personal notification with optional ?channel= override "{schema}://{token}", # Topics in path: one API call per topic "{schema}://{token}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("User Token"), "type": "string", "private": True, "required": True, # PushPlus tokens are 32-64 alphanumeric/underscore/dash chars "regex": (r"^[a-z0-9_-]{32,64}$", "i"), }, # Group topics go directly in the URL path with no prefix "targets": { "name": _("Group Topics"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ # Allow the token to be supplied as a query parameter "token": { "alias_of": "token", }, # ?to= is the standard Apprise alias for targets (topics) "to": { "alias_of": "targets", }, # Delivery channel -- selects where the message is delivered. # One of the PUSHPLUS_CHANNELS values (wechat is the default). "channel": { "name": _("Channel"), "type": "choice:string", "values": PUSHPLUS_CHANNELS, "default": PUSHPLUS_CHANNEL_DEFAULT, }, # mode= is an alias for channel=; the two are fully synonymous. "mode": { "alias_of": "channel", }, # ?topic= backward-compat alias that maps to targets "topic": { "alias_of": "targets", }, # Webhook endpoint name; only meaningful when channel=webhook. # The URL parameter is ?name= but the __init__ kwarg is webhook= # to avoid shadowing URLBase.name. "name": { "name": _("Webhook Name"), "type": "string", "map_to": "webhook", }, }, ) def __init__( self, token, targets=None, channel=None, webhook=None, **kwargs ): """Initialize Pushplus Object.""" super().__init__(**kwargs) # Validate the required user token self.token = validate_regex( token, *self.template_tokens["token"]["regex"], ) if not self.token: msg = "The Pushplus token ({}) is invalid.".format(token) self.logger.warning(msg) raise TypeError(msg) # Resolve the delivery channel from the schema alias when the URL # was entered as wechat:// or wecom:// instead of pushplus://. # self.schema is set by NotifyBase from the parsed URL protocol. schema_channel = PUSHPLUS_SCHEMA_MAP.get((self.schema or "").lower()) # Determine the active delivery channel: # 1. Explicit channel= / mode= argument (already resolved by # the caller since mode is an alias_of channel in template_args) # 2. Schema-implied channel (wechat:// or wecom://) # 3. Default (WeChat) if channel: # Normalise the 'wecom' friendly alias to the API value 'cp' if channel.lower() == "wecom": channel = PushPlusChannel.WECOM self.channel = next( (c for c in PUSHPLUS_CHANNELS if c == channel.lower()), None, ) if not self.channel: msg = "The Pushplus channel ({}) is not valid.".format(channel) self.logger.warning(msg) raise TypeError(msg) elif schema_channel: # Schema-implied channel (wechat:// or wecom://) self.channel = schema_channel else: # Default to WeChat self.channel = PUSHPLUS_CHANNEL_DEFAULT # Resolved group topics -- one API call is made per topic self.topics = [] # Preserve unrecognised targets for round-trip fidelity in url() self.invalid_targets = [] # Parse each target from the list -- only plain topics are valid here for target in parse_list(targets): result = IS_TOPIC.match(target) if result: self.topics.append(result.group("topic")) continue # Unrecognised entry -- log and preserve self.logger.warning("Dropped invalid Pushplus topic: %s", target) self.invalid_targets.append(target) # Store the webhook name; only meaningful when channel=webhook. # Kept as self.webhook (not self.name) to avoid shadowing URLBase.name. # Arrives via the ?name= URL parameter (mapped to webhook= by map_to). self.webhook = ( webhook if isinstance(webhook, str) and webhook else None ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform PushPlus Notification.""" # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Derive the PushPlus rendering template from Apprise's standard # notify_format. This is a server-side rendering hint only; the # content payload does not change. pp_template = PUSHPLUS_FORMAT_MAP.get( self.notify_format, PUSHPLUS_FORMAT_DEFAULT ) # When no topics are configured, fall back to a single personal send # by iterating over a list containing None as the sole entry. topics_to_notify = self.topics if self.topics else [None] # Track whether any individual send failed has_error = False for topic in topics_to_notify: # Build the payload for this particular topic payload = { # Authentication token "token": self.token, # Fall back to the body when no title is provided "title": title if title else body, # Notification body content "content": body, # Rendering template derived from notify_format "template": pp_template, # Delivery channel "channel": self.channel, } # Add the group topic when sending to a specific group if topic: payload["topic"] = topic # Add the webhook name when the webhook channel is selected if self.channel == PushPlusChannel.WEBHOOK and self.webhook: payload["webhook"] = self.webhook # Debug logging so the caller can inspect what will be sent self.logger.debug( "PushPlus POST URL: %s (cert_verify=%r)", self.notify_url, self.verify_certificate, ) self.logger.debug("PushPlus Payload: %r", payload) # Always throttle before each remote server I/O call self.throttle() try: r = requests.post( self.notify_url, headers=headers, # Encode explicitly for non-ASCII (e.g. Chinese) chars data=json.dumps(payload, ensure_ascii=False).encode( "utf-8" ), verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # HTTP-level failure -- log it and move on status_str = NotifyPushplus.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send PushPlus 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 and continue with the next topic has_error = True continue # PushPlus always returns HTTP 200; the real result is in # the JSON body where code == 200 means success. try: content = json.loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is unparsable # TypeError = r.content is None # AttributeError = r is None content = {} # Check the application-level status code api_code = content.get("code") if content else None if api_code != 200: # Application-level failure error_str = PUSHPLUS_RESPONSE_CODES.get( api_code, # Fall back to the msg field, then a generic string ( content.get("msg", "Unknown error") if content else "Unknown error" ), ) self.logger.warning( "Failed to send PushPlus notification: " "code={}: {}.".format(api_code, error_str) ) self.logger.debug( "Response Details:\r\n%r", content if content else (r.content or b"")[:2000], ) # Mark our failure and continue with the next topic has_error = True continue except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending" " PushPlus notification." ) self.logger.debug("Socket Exception: %s", str(e)) # Mark our failure and continue with the next topic has_error = True continue # Notification delivered for this topic self.logger.info( "Sent PushPlus notification%s.", " to topic {}".format(topic) if topic else "", ) return not has_error @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. """ # The token is the sole connection identity for PushPlus return (self.secure_protocol[0], self.token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # When channel=webhook with a named endpoint, use the compact # {name}@pushplus://{token} form. Both ?channel=webhook and ?name= # are implied by the user@ prefix and omitted from the query string. webhook_prefix = ( self.channel == PushPlusChannel.WEBHOOK and self.webhook ) # Start with an empty params dict params = {} # When not using the webhook prefix form, include ?channel= when it # differs from the default. The schema alias (wechat:// / wecom://) # is never emitted here -- we always normalise back to pushplus:// # plus ?channel= for clarity. if not webhook_prefix and self.channel != PUSHPLUS_CHANNEL_DEFAULT: params["channel"] = self.channel # Merge in standard Apprise URL parameters (verify, format, etc.) params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Build the targets path from group topics and any invalid entries targets = list(self.topics) + list(self.invalid_targets) # Masked token for the URL token_str = self.pprint( self.token, privacy, mode=PrivacyMode.Secret, safe="", ) # When using the webhook prefix form, the endpoint name goes in the # user@ position (schema://name@token) -- channel=webhook and ?name= # are both implied by the user@ presence and omitted from params. if webhook_prefix: name_str = NotifyPushplus.quote(self.webhook, safe="") if targets: return "{schema}://{name}@{token}/{targets}/?{params}".format( schema=self.secure_protocol[0], name=name_str, token=token_str, targets="/".join( NotifyPushplus.quote(t, safe="") for t in targets ), params=NotifyPushplus.urlencode(params), ) return "{schema}://{name}@{token}/?{params}".format( schema=self.secure_protocol[0], name=name_str, token=token_str, params=NotifyPushplus.urlencode(params), ) if targets: # One or more topics: include them in the URL path return "{schema}://{token}/{targets}/?{params}".format( schema=self.secure_protocol[0], token=token_str, targets="/".join( NotifyPushplus.quote(t, safe="") for t in targets ), params=NotifyPushplus.urlencode(params), ) # No topics -- simple personal notification URL return "{schema}://{token}/?{params}".format( schema=self.secure_protocol[0], token=token_str, params=NotifyPushplus.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 # Prefer ?token= query parameter over the URL host field if "token" in results["qsd"] and results["qsd"]["token"]: # Token was supplied as a query parameter results["token"] = NotifyPushplus.unquote(results["qsd"]["token"]) else: # Token is the URL host results["token"] = NotifyPushplus.unquote(results["host"]) # Collect group topics from the URL path results["targets"] = NotifyPushplus.split_path(results["fullpath"]) # ?to= appends additional targets (comma/space delimited supported) if "to" in results["qsd"] and results["qsd"]["to"]: results["targets"] += NotifyPushplus.parse_list( results["qsd"]["to"] ) # ?topic= backward-compat alias also appends topics if "topic" in results["qsd"] and results["qsd"]["topic"]: results["targets"] += NotifyPushplus.parse_list( results["qsd"]["topic"] ) # Extract the delivery channel from ?channel= or its alias ?mode= # mode= takes lower priority; channel= wins if both are present. if "mode" in results["qsd"] and results["qsd"]["mode"]: results["channel"] = NotifyPushplus.unquote(results["qsd"]["mode"]) if "channel" in results["qsd"] and results["qsd"]["channel"]: results["channel"] = NotifyPushplus.unquote( results["qsd"]["channel"] ) # Extract the webhook name -- users specify it as ?name= in the URL. # We store it internally as 'webhook' to avoid shadowing URLBase.name. if "name" in results["qsd"] and results["qsd"]["name"]: results["webhook"] = NotifyPushplus.unquote(results["qsd"]["name"]) # Support the {webhook_name}@pushplus://{token} short form. # When user@ is present, it identifies the webhook endpoint name. # If no channel was explicitly given, webhook is the implied channel. if results.get("user"): # Use user@ as the webhook name when ?name= was not also supplied if "webhook" not in results: results["webhook"] = NotifyPushplus.unquote(results["user"]) # Imply webhook channel when no explicit channel was specified if "channel" not in results: results["channel"] = PushPlusChannel.WEBHOOK return results @staticmethod def parse_native_url(url): """Support native PushPlus API URLs of the form: https://www.pushplus.plus/send?token=TOKEN[&other_params] """ result = re.match( r"^https?://www\.pushplus\.plus/send" r"(?:\?(?P[^#]+))?$", url, re.I, ) if result: params = result.group("params") or "" # The token must be present as a query parameter tok = re.search( r"(?:(?:^|&))token=(?P[a-z0-9_-]+)", params, re.I, ) if tok: # Re-build as an Apprise URL. Preserve all existing params # so that topic, channel, and name all round-trip correctly. return NotifyPushplus.parse_url( "{schema}://{token}/?{params}".format( schema=NotifyPushplus.secure_protocol[0], token=tok.group("token"), params=params, ) ) return None apprise-1.10.0/apprise/plugins/pushsafer.py000066400000000000000000000673641517341665700207710ustar00rootroot00000000000000# 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 import requests from .. import exception from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_list, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase class PushSaferSound: """Defines all of the supported PushSafe sounds.""" # Silent SILENT = 0 # Ahem (IM) AHEM = 1 # Applause (Mail) APPLAUSE = 2 # Arrow (Reminder) ARROW = 3 # Baby (SMS) BABY = 4 # Bell (Alarm) BELL = 5 # Bicycle (Alarm2) BICYCLE = 6 # Boing (Alarm3) BOING = 7 # Buzzer (Alarm4) BUZZER = 8 # Camera (Alarm5) CAMERA = 9 # Car Horn (Alarm6) CAR_HORN = 10 # Cash Register (Alarm7) CASH_REGISTER = 11 # Chime (Alarm8) CHIME = 12 # Creaky Door (Alarm9) CREAKY_DOOR = 13 # Cuckoo Clock (Alarm10) CUCKOO_CLOCK = 14 # Disconnect (Call) DISCONNECT = 15 # Dog (Call2) DOG = 16 # Doorbell (Call3) DOORBELL = 17 # Fanfare (Call4) FANFARE = 18 # Gun Shot (Call5) GUN_SHOT = 19 # Honk (Call6) HONK = 20 # Jaw Harp (Call7) JAW_HARP = 21 # Morse (Call8) MORSE = 22 # Electricity (Call9) ELECTRICITY = 23 # Radio Tuner (Call10) RADIO_TURNER = 24 # Sirens SIRENS = 25 # Military Trumpets MILITARY_TRUMPETS = 26 # Ufo UFO = 27 # Whah Whah Whah LONG_WHAH = 28 # Man Saying Goodbye GOODBYE = 29 # Man Saying Hello HELLO = 30 # Man Saying No NO = 31 # Man Saying Ok OKAY = 32 # Man Saying Ooohhhweee OOOHHHWEEE = 33 # Man Saying Warning WARNING = 34 # Man Saying Welcome WELCOME = 35 # Man Saying Yeah YEAH = 36 # Man Saying Yes YES = 37 # Beep short BEEP1 = 38 # Weeeee short WEEE = 39 # Cut in and out short CUTINOUT = 40 # Finger flicking glas short FLICK_GLASS = 41 # Wa Wa Waaaa short SHORT_WHAH = 42 # Laser short LASER = 43 # Wind Chime short WIND_CHIME = 44 # Echo short ECHO = 45 # Zipper short ZIPPER = 46 # HiHat short HIHAT = 47 # Beep 2 short BEEP2 = 48 # Beep 3 short BEEP3 = 49 # Beep 4 short BEEP4 = 50 # The Alarm is armed ALARM_ARMED = 51 # The Alarm is disarmed ALARM_DISARMED = 52 # The Backup is ready BACKUP_READY = 53 # The Door is closed DOOR_CLOSED = 54 # The Door is opend DOOR_OPENED = 55 # The Window is closed WINDOW_CLOSED = 56 # The Window is open WINDOW_OPEN = 57 # The Light is off LIGHT_ON = 58 # The Light is on LIGHT_OFF = 59 # The Doorbell rings DOORBELL_RANG = 60 PUSHSAFER_SOUND_MAP = { # Device Default, "silent": PushSaferSound.SILENT, "ahem": PushSaferSound.AHEM, "applause": PushSaferSound.APPLAUSE, "arrow": PushSaferSound.ARROW, "baby": PushSaferSound.BABY, "bell": PushSaferSound.BELL, "bicycle": PushSaferSound.BICYCLE, "bike": PushSaferSound.BICYCLE, "boing": PushSaferSound.BOING, "buzzer": PushSaferSound.BUZZER, "camera": PushSaferSound.CAMERA, "carhorn": PushSaferSound.CAR_HORN, "horn": PushSaferSound.CAR_HORN, "cashregister": PushSaferSound.CASH_REGISTER, "chime": PushSaferSound.CHIME, "creakydoor": PushSaferSound.CREAKY_DOOR, "cuckooclock": PushSaferSound.CUCKOO_CLOCK, "cuckoo": PushSaferSound.CUCKOO_CLOCK, "disconnect": PushSaferSound.DISCONNECT, "dog": PushSaferSound.DOG, "doorbell": PushSaferSound.DOORBELL, "fanfare": PushSaferSound.FANFARE, "gunshot": PushSaferSound.GUN_SHOT, "honk": PushSaferSound.HONK, "jawharp": PushSaferSound.JAW_HARP, "morse": PushSaferSound.MORSE, "electric": PushSaferSound.ELECTRICITY, "radiotuner": PushSaferSound.RADIO_TURNER, "sirens": PushSaferSound.SIRENS, "militarytrumpets": PushSaferSound.MILITARY_TRUMPETS, "military": PushSaferSound.MILITARY_TRUMPETS, "trumpets": PushSaferSound.MILITARY_TRUMPETS, "ufo": PushSaferSound.UFO, "whahwhah": PushSaferSound.LONG_WHAH, "whah": PushSaferSound.SHORT_WHAH, "goodye": PushSaferSound.GOODBYE, "hello": PushSaferSound.HELLO, "no": PushSaferSound.NO, "okay": PushSaferSound.OKAY, "ok": PushSaferSound.OKAY, "ooohhhweee": PushSaferSound.OOOHHHWEEE, "warn": PushSaferSound.WARNING, "warning": PushSaferSound.WARNING, "welcome": PushSaferSound.WELCOME, "yeah": PushSaferSound.YEAH, "yes": PushSaferSound.YES, "beep": PushSaferSound.BEEP1, "beep1": PushSaferSound.BEEP1, "weee": PushSaferSound.WEEE, "wee": PushSaferSound.WEEE, "cutinout": PushSaferSound.CUTINOUT, "flickglass": PushSaferSound.FLICK_GLASS, "laser": PushSaferSound.LASER, "windchime": PushSaferSound.WIND_CHIME, "echo": PushSaferSound.ECHO, "zipper": PushSaferSound.ZIPPER, "hihat": PushSaferSound.HIHAT, "beep2": PushSaferSound.BEEP2, "beep3": PushSaferSound.BEEP3, "beep4": PushSaferSound.BEEP4, "alarmarmed": PushSaferSound.ALARM_ARMED, "armed": PushSaferSound.ALARM_ARMED, "alarmdisarmed": PushSaferSound.ALARM_DISARMED, "disarmed": PushSaferSound.ALARM_DISARMED, "backupready": PushSaferSound.BACKUP_READY, "dooropen": PushSaferSound.DOOR_OPENED, "dopen": PushSaferSound.DOOR_OPENED, "doorclosed": PushSaferSound.DOOR_CLOSED, "dclosed": PushSaferSound.DOOR_CLOSED, "windowopen": PushSaferSound.WINDOW_OPEN, "wopen": PushSaferSound.WINDOW_OPEN, "windowclosed": PushSaferSound.WINDOW_CLOSED, "wclosed": PushSaferSound.WINDOW_CLOSED, "lighton": PushSaferSound.LIGHT_ON, "lon": PushSaferSound.LIGHT_ON, "lightoff": PushSaferSound.LIGHT_OFF, "loff": PushSaferSound.LIGHT_OFF, "doorbellrang": PushSaferSound.DOORBELL_RANG, } # Priorities class PushSaferPriority: LOW = -2 MODERATE = -1 NORMAL = 0 HIGH = 1 EMERGENCY = 2 PUSHSAFER_PRIORITIES = ( PushSaferPriority.LOW, PushSaferPriority.MODERATE, PushSaferPriority.NORMAL, PushSaferPriority.HIGH, PushSaferPriority.EMERGENCY, ) PUSHSAFER_PRIORITY_MAP = { # short for 'low' "low": PushSaferPriority.LOW, # short for 'medium' "medium": PushSaferPriority.MODERATE, # short for 'normal' "normal": PushSaferPriority.NORMAL, # short for 'high' "high": PushSaferPriority.HIGH, # short for 'emergency' "emergency": PushSaferPriority.EMERGENCY, } # Identify the priority ou want to designate as the fall back DEFAULT_PRIORITY = "normal" # Vibrations class PushSaferVibration: """Defines the acceptable vibration settings for notification.""" # x1 LOW = 1 # x2 NORMAL = 2 # x3 HIGH = 3 # Identify all of the vibrations in one place PUSHSAFER_VIBRATIONS = ( PushSaferVibration.LOW, PushSaferVibration.NORMAL, PushSaferVibration.HIGH, ) # At this time, the following pictures can be attached to each notification # at one time. When more are supported, just add their argument below PICTURE_PARAMETER = ( "p", "p2", "p3", ) # Flag used as a placeholder to sending to all devices PUSHSAFER_SEND_TO_ALL = "a" class NotifyPushSafer(NotifyBase): """A wrapper for PushSafer Notifications.""" # The default descriptive name associated with the Notification service_name = "Pushsafer" # The services URL service_url = "https://www.pushsafer.com/" # The default insecure protocol protocol = "psafer" # The default secure protocol secure_protocol = "psafers" # Support attachments attachment_support = True # Number of requests to a allow per second request_rate_per_sec = 1.2 # The icon ID of 25 looks like a megaphone default_pushsafer_icon = 25 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushsafer/" # Defines the hostname to post content to; since this service supports # both insecure and secure methods, we set the {schema} just before we # post the message upstream. notify_url = "{schema}://www.pushsafer.com/api" # Define object templates templates = ( "{schema}://{privatekey}", "{schema}://{privatekey}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "privatekey": { "name": _("Private Key"), "type": "string", "private": True, "required": True, }, "target_device": { "name": _("Target Device"), "type": "string", "map_to": "targets", }, "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, **{ "priority": { "name": _("Priority"), "type": "choice:int", "values": PUSHSAFER_PRIORITIES, }, "sound": { "name": _("Sound"), "type": "choice:string", "values": PUSHSAFER_SOUND_MAP, }, "vibration": { "name": _("Vibration"), "type": "choice:int", "values": PUSHSAFER_VIBRATIONS, }, "to": { "alias_of": "targets", }, }, ) def __init__( self, privatekey, targets=None, priority=None, sound=None, vibration=None, **kwargs, ): """Initialize PushSafer Object.""" super().__init__(**kwargs) # # Priority # try: # Acquire our priority if we can: # - We accept both the integer form as well as a string # representation self.priority = int(priority) except TypeError: # NoneType means use Default; this is an okay exception self.priority = None except ValueError: # Input is a string; attempt to get the lookup from our # priority mapping priority = priority.lower().strip() # This little bit of black magic allows us to match against # low, lo, l (for low); # normal, norma, norm, nor, no, n (for normal) # ... etc match = ( next( ( key for key in PUSHSAFER_PRIORITY_MAP if key.startswith(priority) ), None, ) if priority else None ) # Now test to see if we got a match if not match: msg = ( "An invalid PushSafer priority " f"({priority}) was specified." ) self.logger.warning(msg) raise TypeError(msg) from None # store our successfully looked up priority self.priority = PUSHSAFER_PRIORITY_MAP[match] if ( self.priority is not None and self.priority not in PUSHSAFER_PRIORITY_MAP.values() ): msg = f"An invalid PushSafer priority ({priority}) was specified." self.logger.warning(msg) raise TypeError(msg) from None # # Sound # try: # Acquire our sound if we can: # - We accept both the integer form as well as a string # representation self.sound = int(sound) except TypeError: # NoneType means use Default; this is an okay exception self.sound = None except ValueError: # Input is a string; attempt to get the lookup from our # sound mapping sound = sound.lower().strip() # This little bit of black magic allows us to match against # against multiple versions of the same string # ... etc match = ( next( ( key for key in PUSHSAFER_SOUND_MAP if key.startswith(sound) ), None, ) if sound else None ) # Now test to see if we got a match if not match: msg = f"An invalid PushSafer sound ({sound}) was specified." self.logger.warning(msg) raise TypeError(msg) from None # store our successfully looked up sound self.sound = PUSHSAFER_SOUND_MAP[match] if ( self.sound is not None and self.sound not in PUSHSAFER_SOUND_MAP.values() ): msg = f"An invalid PushSafer sound ({sound}) was specified." self.logger.warning(msg) raise TypeError(msg) from None # # Vibration # try: # Use defined integer as is if defined, no further error checking # is performed self.vibration = int(vibration) except TypeError: # NoneType means use Default; this is an okay exception self.vibration = None except ValueError: msg = ( f"An invalid PushSafer vibration ({vibration}) was specified." ) self.logger.warning(msg) raise TypeError(msg) from None if self.vibration and self.vibration not in PUSHSAFER_VIBRATIONS: msg = ( f"An invalid PushSafer vibration ({vibration}) was specified." ) self.logger.warning(msg) raise TypeError(msg) from None # # Private Key (associated with project) # self.privatekey = validate_regex(privatekey) if not self.privatekey: msg = ( "An invalid PushSafer Private Key " f"({privatekey}) was specified." ) self.logger.warning(msg) raise TypeError(msg) self.targets = parse_list(targets) if len(self.targets) == 0: self.targets = (PUSHSAFER_SEND_TO_ALL,) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform PushSafer Notification.""" # error tracking (used for function return) has_error = False # Initialize our list of attachments attachments = [] if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for no, attachment in enumerate(attach, start=1): # prepare payload if not attachment: # We could not access the attachment self.logger.error( "Could not access PushSafer attachment" f" {attachment.url(privacy=True)}." ) return False if not attachment.mimetype.startswith("image/"): # Attachment not supported; continue peacefully self.logger.debug( "Ignoring unsupported PushSafer attachment" f" {attachment.url(privacy=True)}." ) continue self.logger.debug( "Posting PushSafer attachment" f" {attachment.url(privacy=True)}" ) try: # Output must be in a DataURL format (that's what # PushSafer calls it): attachments.append( ( ( attachment.name if attachment.name else f"file{no:03}.dat" ), f"data:{attachment.mimetype};base64,{attachment.base64()}", ) ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access PushSafer attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending PushSafer attachment" f" {attachment.url(privacy=True)}" ) # Create a copy of the targets list targets = list(self.targets) while len(targets): recipient = targets.pop(0) # prepare payload payload = { "t": title, "m": body, # Our default icon to use "i": self.default_pushsafer_icon, # Notification Color "c": self.color(notify_type), # Target Recipient "d": recipient, } if self.sound is not None: # Only apply sound setting if it was specified payload["s"] = str(self.sound) if self.vibration is not None: # Only apply vibration setting payload["v"] = str(self.vibration) if not attachments: okay, _response = self._send(payload) if not okay: has_error = True continue self.logger.info( f'Sent PushSafer notification to "{recipient}".' ) else: # Create a copy of our payload object payload_ = payload.copy() for idx in range(0, len(attachments), len(PICTURE_PARAMETER)): # Send our attachments to our same user (already prepared # as our payload object) for c, attachment in enumerate( attachments[idx : idx + len(PICTURE_PARAMETER)] ): # Get our attachment information filename, dataurl = attachment payload_.update({PICTURE_PARAMETER[c]: dataurl}) self.logger.debug( f'Added attachment ({filename}) to "{recipient}".' ) okay, _response = self._send(payload_) if not okay: has_error = True continue self.logger.info( f"Sent PushSafer attachment ({filename}) to" f' "{recipient}".' ) # More then the maximum messages shouldn't cause all of # the text to loop on future iterations payload_ = payload.copy() payload_["t"] = "" payload_["m"] = "..." return not has_error def _send(self, payload, **kwargs): """Wrapper to the requests (post) object.""" headers = { "User-Agent": self.app_id, } # Prepare the notification URL to post to notify_url = self.notify_url.format( schema="https" if self.secure else "http" ) # Store the payload key payload["k"] = self.privatekey # 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( "PushSafer POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug( "PushSafer Payload: %s", sanitize_payload(payload) ) # Always call throttle before any remote server i/o is made self.throttle() # Default response type response = None # Initialize our Pushsafer expected responses code = None str_ = "Unknown" try: # Open our attachment path if required: r = requests.post( notify_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) try: response = loads(r.content) code = response.get("status") str_ = ( response.get("success", str_) if code == 1 else response.get("error", str_) ) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None # Fall back to the existing unparsed value response = r.content if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifyPushSafer.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to deliver payload to PushSafer:" "{}{}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, response elif code != 1: # It's a bit backwards, but: # 1 is returned if we succeed # 0 is returned if we fail self.logger.warning( f"Failed to deliver payload to PushSafer; error={str_}." ) self.logger.debug(f"Response Details:\r\n{r.content}") return False, response # otherwise we were successful return True, response except requests.RequestException as e: self.logger.warning( "A Connection error occurred communicating with PushSafer." ) self.logger.debug(f"Socket Exception: {e!s}") return False, response @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.privatekey, ) 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 self.priority is not None: # Store our priority; but only if it was specified params["priority"] = next( ( key for key, value in PUSHSAFER_PRIORITY_MAP.items() if value == self.priority ), DEFAULT_PRIORITY, ) # pragma: no cover if self.sound is not None: # Store our sound; but only if it was specified params["sound"] = next( ( key for key, value in PUSHSAFER_SOUND_MAP.items() if value == self.sound ), "", ) # pragma: no cover if self.vibration is not None: # Store our vibration; but only if it was specified params["vibration"] = str(self.vibration) targets = "/".join([NotifyPushSafer.quote(x) for x in self.targets]) if targets == PUSHSAFER_SEND_TO_ALL: # keyword is reserved for internal usage only; it's safe to remove # it from the recipients list targets = "" return "{schema}://{privatekey}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, privatekey=self.pprint(self.privatekey, privacy, safe=""), targets=targets, params=NotifyPushSafer.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, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Fetch our targets results["targets"] = NotifyPushSafer.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"] += NotifyPushSafer.parse_list( results["qsd"]["to"] ) # Setup the token; we store it in Private Key for global # plugin consistency with naming conventions results["privatekey"] = NotifyPushSafer.unquote(results["host"]) if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyPushSafer.unquote( results["qsd"]["priority"] ) if "sound" in results["qsd"] and len(results["qsd"]["sound"]): results["sound"] = NotifyPushSafer.unquote(results["qsd"]["sound"]) if "vibration" in results["qsd"] and len(results["qsd"]["vibration"]): results["vibration"] = NotifyPushSafer.unquote( results["qsd"]["vibration"] ) return results apprise-1.10.0/apprise/plugins/pushy.py000066400000000000000000000311671517341665700201310ustar00rootroot00000000000000# 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://pushy.me/docs/api/send-notifications from itertools import chain from json import dumps, loads import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_list, validate_regex from .base import NotifyBase # Used to detect a Device and Topic VALIDATE_DEVICE = re.compile(r"^@(?P[a-z0-9]+)$", re.I) VALIDATE_TOPIC = re.compile(r"^[#]?(?P[a-z0-9]+)$", re.I) # Extend HTTP Error Messages PUSHY_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", } class NotifyPushy(NotifyBase): """A wrapper for Pushy Notifications.""" # The default descriptive name associated with the Notification service_name = "Pushy" # The services URL service_url = "https://pushy.me/" # All Pushy requests are secure secure_protocol = "pushy" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/pushy/" # Pushy uses the http protocol with JSON requests notify_url = "https://api.pushy.me/push?api_key={apikey}" # The maximum allowable characters allowed in the body per message body_maxlen = 4096 # Define object templates templates = ("{schema}://{apikey}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("Secret API Key"), "type": "string", "private": True, "required": True, }, "target_device": { "name": _("Target Device"), "type": "string", "prefix": "@", "map_to": "targets", }, "target_topic": { "name": _("Target Topic"), "type": "string", "prefix": "#", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "sound": { # Specify something like ping.aiff "name": _("Sound"), "type": "string", }, "badge": { "name": _("Badge"), "type": "int", "min": 0, }, "to": { "alias_of": "targets", }, "key": { "alias_of": "apikey", }, }, ) def __init__(self, apikey, targets=None, sound=None, badge=None, **kwargs): """Initialize Pushy Object.""" super().__init__(**kwargs) # Access Token (associated with project) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid Pushy Secret API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Get our targets self.devices = [] self.topics = [] for target in parse_list(targets): result = VALIDATE_TOPIC.match(target) if result: self.topics.append(result.group("topic")) continue result = VALIDATE_DEVICE.match(target) if result: self.devices.append(result.group("device")) continue self.logger.warning( f"Dropped invalid topic/device ({target}) specified.", ) # Setup our sound self.sound = sound # 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 Pushy badge ({}) is not valid ", badge ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Pushy Notification.""" if len(self.topics) + len(self.devices) == 0: # There were no services to notify self.logger.warning("There were no Pushy targets to notify.") return False # error tracking (used for function return) has_error = False # Default Header headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accepts": "application/json", } # Our URL notify_url = self.notify_url.format(apikey=self.apikey) # Default content response object content = {} # Create a copy of targets (topics and devices) targets = list(self.topics) + list(self.devices) while len(targets): target = targets.pop(0) # prepare JSON Object payload = { # Mandatory fields "to": target, "data": { "message": body, }, "notification": { "body": body, }, } # Optional payload items if title: payload["notification"]["title"] = title if self.sound: payload["notification"]["sound"] = self.sound if self.badge is not None: payload["notification"]["badge"] = self.badge self.logger.debug( "Pushy POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Pushy Payload: {payload!s}") # 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, ) # Sample response # See: https://pushy.me/docs/api/send-notifications # { # "success": true, # "id": "5ea9b214b47cad768a35f13a", # "info": { # "devices": 1 # "failed": ['abc'] # } # } try: content = loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = { "success": False, "id": "", "info": {}, } if r.status_code != requests.codes.ok or not content.get( "success" ): # We had a problem status_str = NotifyPushy.http_response_code_lookup( r.status_code, PUSHY_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Pushy 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] ) has_error = True continue else: self.logger.info(f"Sent Pushy notification to {target}.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Pushy:%s " "notification", target, ) self.logger.debug(f"Socket Exception: {e!s}") 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.apikey) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self.sound: params["sound"] = self.sound if self.badge is not None: params["badge"] = str(self.badge) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{apikey}/{targets}/?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [ NotifyPushy.quote(x, safe="@#") for x in chain( # Topics are prefixed with a pound/hashtag symbol [f"#{x}" for x in self.topics], # Devices [f"@{x}" for x in self.devices], ) ] ), params=NotifyPushy.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.topics) + len(self.devices) @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 # Token results["apikey"] = NotifyPushy.unquote(results["host"]) # Retrieve all of our targets results["targets"] = NotifyPushy.split_path(results["fullpath"]) # Get the sound if "sound" in results["qsd"] and len(results["qsd"]["sound"]): results["sound"] = NotifyPushy.unquote(results["qsd"]["sound"]) # Badge if "badge" in results["qsd"] and results["qsd"]["badge"]: results["badge"] = NotifyPushy.unquote( results["qsd"]["badge"].strip() ) # Support key variable to store Secret API Key if "key" in results["qsd"] and len(results["qsd"]["key"]): results["apikey"] = results["qsd"]["key"] # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyPushy.parse_list(results["qsd"]["to"]) return results apprise-1.10.0/apprise/plugins/qq.py000066400000000000000000000130271517341665700173750ustar00rootroot00000000000000# # 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. # Assumes QQ Push API provided by third-party bridge like message-pusher import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import validate_regex from .base import NotifyBase class NotifyQQ(NotifyBase): """A wrapper for QQ Push Notifications.""" # The default descriptive name associated with the Notification service_name = _("QQ Push") # The services URL service_url = "https://github.com/songquanpeng/message-pusher" # The default secure protocol secure_protocol = "qq" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/qq/" # URL used to send notifications with notify_url = "https://qmsg.zendee.cn/send/" templates = ("{schema}://{token}",) template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("User Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]{24,64}$", "i"), }, }, ) def __init__(self, token, **kwargs): """Initialize QQ Push Object. Args: token (str): User push token from QQ Push provider (e.g., Qmsg) """ super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The QQ Push token ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.webhook_url = f"{self.notify_url}{self.token}" 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 ( f"{self.secure_protocol}://" f"{self.pprint(self.token, privacy, mode=PrivacyMode.Secret)}/" f"?{self.urlencode(params)}" ) @property def url_identifier(self): """Returns a unique identifier for this plugin instance.""" return (self.secure_protocol, self.token) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Send a QQ Push Notification.""" payload = {"msg": f"{title}\n{body}" if title else body} headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", } self.throttle() try: response = requests.post( self.webhook_url, headers=headers, data=payload, verify=self.verify_certificate, timeout=self.request_timeout, ) if response.status_code != requests.codes.ok: self.logger.warning( "QQ Push notification failed: %d - %s", response.status_code, response.text, ) return False except requests.RequestException as e: self.logger.warning(f"QQ Push Exception: {e}") return False self.logger.info("QQ Push notification sent successfully.") return True @staticmethod def parse_url(url): """Parses the URL and returns arguments to re-instantiate the object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: return results if "token" in results["qsd"] and results["qsd"]["token"]: results["token"] = NotifyQQ.unquote(results["qsd"]["token"]) else: results["token"] = NotifyQQ.unquote(results["host"]) return results @staticmethod def parse_native_url(url): """Parse native QQ push-style URL into Apprise format.""" match = re.match( r"^https://qmsg\.zendee\.cn/send/([a-z0-9]+)$", url, re.I ) if not match: return None return NotifyQQ.parse_url( f"{NotifyQQ.secure_protocol}://{match.group(1)}" ) apprise-1.10.0/apprise/plugins/reddit.py000066400000000000000000000632631517341665700202360ustar00rootroot00000000000000# 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. Visit https://www.reddit.com/prefs/apps and scroll to the bottom # 2. Click on the button that reads 'are you a developer? create an app...' # 3. Set the mode to `script`, # 4. Provide a `name`, `description`, `redirect uri` and save it. # 5. Once the bot is saved, you'll be given a ID (next to the the bot name) # and a Secret. # The App ID will look something like this: YWARPXajkk645m # The App Secret will look something like this: YZGKc5YNjq3BsC-bf7oBKalBMeb1xA # The App will also have a location where you can identify the users # who have access (identified as Developers) to the app itself. You will # additionally need these credentials authenticate with. # With this information you'll be able to form the URL: # reddit://{user}:{password}@{app_id}/{app_secret} # All of the documentation needed to work with the Reddit API can be found # here: # - https://www.reddit.com/dev/api/ # - https://www.reddit.com/dev/api/#POST_api_submit # - https://github.com/reddit-archive/reddit/wiki/API from datetime import datetime, timedelta, timezone from json import loads import requests from .. import __title__, __version__ from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool, parse_list, validate_regex from .base import NotifyBase # Extend HTTP Error Messages REDDIT_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token", } class RedditMessageKind: """Define the kinds of messages supported.""" # Attempt to auto-detect the type prior to passing along the message to # Reddit AUTO = "auto" # A common message SELF = "self" # A Hyperlink LINK = "link" REDDIT_MESSAGE_KINDS = ( RedditMessageKind.AUTO, RedditMessageKind.SELF, RedditMessageKind.LINK, ) class NotifyReddit(NotifyBase): """A wrapper for Notify Reddit Notifications.""" # The default descriptive name associated with the Notification service_name = "Reddit" # The services URL service_url = "https://reddit.com" # The default secure protocol secure_protocol = "reddit" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/reddit/" # The maximum size of the message body_maxlen = 6000 # Maximum title length as defined by the Reddit API title_maxlen = 300 # Default to markdown notify_format = NotifyFormat.MARKDOWN # The default Notification URL to use auth_url = "https://www.reddit.com/api/v1/access_token" submit_url = "https://oauth.reddit.com/api/submit" # Reddit 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) # 1 hour in seconds (the lifetime of our token) access_token_lifetime_sec = timedelta(seconds=3600) # Define object templates templates = ( "{schema}://{user}:{password}@{app_id}/{app_secret}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "app_id": { "name": _("Application ID"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9_-]+$", "i"), }, "app_secret": { "name": _("Application Secret"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9_-]+$", "i"), }, "target_subreddit": { "name": _("Target Subreddit"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "kind": { "name": _("Kind"), "type": "choice:string", "values": REDDIT_MESSAGE_KINDS, "default": RedditMessageKind.AUTO, }, "flair_id": { "name": _("Flair ID"), "type": "string", "map_to": "flair_id", }, "flair_text": { "name": _("Flair Text"), "type": "string", "map_to": "flair_text", }, "nsfw": { "name": _("NSFW"), "type": "bool", "default": False, "map_to": "nsfw", }, "ad": { "name": _("Is Ad?"), "type": "bool", "default": False, "map_to": "advertisement", }, "replies": { "name": _("Send Replies"), "type": "bool", "default": True, "map_to": "sendreplies", }, "spoiler": { "name": _("Is Spoiler"), "type": "bool", "default": False, "map_to": "spoiler", }, "resubmit": { "name": _("Resubmit Flag"), "type": "bool", "default": False, "map_to": "resubmit", }, "to": { "alias_of": "targets", }, }, ) def __init__( self, app_id=None, app_secret=None, targets=None, kind=None, nsfw=False, sendreplies=True, resubmit=False, spoiler=False, advertisement=False, flair_id=None, flair_text=None, **kwargs, ): """Initialize Notify Reddit Object.""" super().__init__(**kwargs) # Initialize subreddit list self.subreddits = set() # Not Safe For Work Flag self.nsfw = nsfw # Send Replies Flag self.sendreplies = sendreplies # Is Spoiler Flag self.spoiler = spoiler # Resubmit Flag self.resubmit = resubmit # Is Ad? self.advertisement = advertisement # Flair details self.flair_id = flair_id self.flair_text = flair_text # Our keys we build using the provided content self.__refresh_token = None self.__access_token = None self.__access_token_expiry = datetime.now(timezone.utc) self.kind = ( kind.strip().lower() if isinstance(kind, str) else self.template_args["kind"]["default"] ) if self.kind not in REDDIT_MESSAGE_KINDS: msg = f"An invalid Reddit message kind ({kind}) was specified" self.logger.warning(msg) raise TypeError(msg) self.user = validate_regex(self.user) if not self.user: msg = f"An invalid Reddit User ID ({self.user}) was specified" self.logger.warning(msg) raise TypeError(msg) self.password = validate_regex(self.password) if not self.password: msg = f"An invalid Reddit Password ({self.password}) was specified" self.logger.warning(msg) raise TypeError(msg) self.client_id = validate_regex( app_id, *self.template_tokens["app_id"]["regex"] ) if not self.client_id: msg = f"An invalid Reddit App ID ({app_id}) was specified" self.logger.warning(msg) raise TypeError(msg) self.client_secret = validate_regex( app_secret, *self.template_tokens["app_secret"]["regex"] ) if not self.client_secret: msg = f"An invalid Reddit App Secret ({app_secret}) was specified" self.logger.warning(msg) raise TypeError(msg) # Build list of subreddits self.subreddits = [ sr.lstrip("#") for sr in parse_list(targets) if sr.lstrip("#") ] if not self.subreddits: self.logger.warning("No subreddits were identified to be notified") # For Rate Limit Tracking Purposes self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1.0 self.ratelimit_remaining = 1.0 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.client_id, self.client_secret, 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 = { "kind": self.kind, "ad": "yes" if self.advertisement else "no", "nsfw": "yes" if self.nsfw else "no", "resubmit": "yes" if self.resubmit else "no", "replies": "yes" if self.sendreplies else "no", "spoiler": "yes" if self.spoiler else "no", } # Flair support if self.flair_id: params["flair_id"] = self.flair_id if self.flair_text: params["flair_text"] = self.flair_text # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return ( "{schema}://{user}:{password}@{app_id}/{app_secret}" "/{targets}/?{params}".format( schema=self.secure_protocol, user=NotifyReddit.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), app_id=self.pprint( self.client_id, privacy, mode=PrivacyMode.Secret, safe="" ), app_secret=self.pprint( self.client_secret, privacy, mode=PrivacyMode.Secret, safe="", ), targets="/".join( [NotifyReddit.quote(x, safe="") for x in self.subreddits] ), params=NotifyReddit.urlencode(params), ) ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.subreddits) def login(self): """A simple wrapper to authenticate with the Reddit Server.""" # Prepare our payload payload = { "grant_type": "password", "username": self.user, "password": self.password, } # Enforce a False flag setting before calling _fetch() self.__access_token = False # Send Login Information postokay, response = self._fetch( self.auth_url, payload=payload, ) if not postokay or not response: # Setting this variable to False as a way of letting us know # we failed to authenticate on our last attempt self.__access_token = False return False # Our response object looks like this (content has been altered for # presentation purposes): # { # "access_token": Your access token, # "token_type": "bearer", # "expires_in": Unix Epoch Seconds, # "scope": A scope string, # "refresh_token": Your refresh token # } # Acquire our token self.__access_token = response.get("access_token") # Handle other optional arguments we can use if "expires_in" in response: delta = timedelta(seconds=int(response["expires_in"])) self.__access_token_expiry = ( delta + datetime.now(timezone.utc) - self.clock_skew ) else: self.__access_token_expiry = ( self.access_token_lifetime_sec + datetime.now(timezone.utc) - self.clock_skew ) # The Refresh Token self.__refresh_token = response.get( "refresh_token", self.__refresh_token ) if self.__access_token: self.logger.info(f"Authenticated to Reddit as {self.user}") return True self.logger.warning(f"Failed to authenticate to Reddit as {self.user}") # Mark our failure return False def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Reddit Notification.""" # error tracking (used for function return) has_error = False if not self.__access_token and not self.login(): # We failed to authenticate - we're done return False if not len(self.subreddits): # We have nothing to notify; we're done self.logger.warning("There are no Reddit targets to notify") return False # Prepare our Message Type/Kind if self.kind == RedditMessageKind.AUTO: parsed = NotifyBase.parse_url(body) # Detect a link if ( parsed and parsed.get("schema", "").startswith("http") and parsed.get("host") ): kind = RedditMessageKind.LINK else: kind = RedditMessageKind.SELF else: kind = self.kind # Create a copy of the subreddits list subreddits = list(self.subreddits) while len(subreddits) > 0: # Retrieve our subreddit subreddit = subreddits.pop() # Prepare our payload payload = { "ad": bool(self.advertisement), "api_type": "json", "extension": "json", "sr": subreddit, "title": title if title else self.app_desc, "kind": kind, "nsfw": bool(self.nsfw), "resubmit": bool(self.resubmit), "sendreplies": bool(self.sendreplies), "spoiler": bool(self.spoiler), } if self.flair_id: payload["flair_id"] = self.flair_id if self.flair_text: payload["flair_text"] = self.flair_text if kind == RedditMessageKind.LINK: payload.update( { "url": body, } ) else: payload.update( { "text": body, } ) postokay, _response = self._fetch(self.submit_url, payload=payload) # only toggle has_error flag if we had an error if not postokay: # Mark our failure has_error = True continue # If we reach here, we were successful self.logger.info(f"Sent Reddit notification to {subreddit}") return not has_error def _fetch(self, url, payload=None): """Wrapper to Reddit API requests object.""" # use what was specified, otherwise build headers dynamically headers = {"User-Agent": f"{__title__} v{__version__}"} if self.__access_token: # Set our token headers["Authorization"] = f"Bearer {self.__access_token}" # Prepare our url url = self.submit_url if self.__access_token else self.auth_url # Some Debug Logging self.logger.debug( f"Reddit POST URL: {url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Reddit Payload: {payload!s}") # By default set wait to None wait = 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 # Reddit 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) # Initialize a default value for our content value content = {} # acquire our request mode try: r = requests.post( url, data=payload, auth=( None if self.__access_token else (self.client_id, self.client_secret) ), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # We attempt to login again and retry the original request # if we aren't in the process of handling a login already if ( r.status_code != requests.codes.ok and self.__access_token and url != self.auth_url ): # We had a problem status_str = NotifyReddit.http_response_code_lookup( r.status_code, REDDIT_HTTP_ERROR_MAP ) self.logger.debug( "Taking countermeasures after failed to send to Reddit " "{}: {}error={}".format( url, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) # We failed to authenticate with our token; login one more # time and retry this original request if not self.login(): return (False, {}) # Try again r = requests.post( url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Get our JSON content if it's possible try: content = loads(r.content) except (TypeError, ValueError, AttributeError): # TypeError = r.content is not a String # ValueError = r.content is Unparsable # AttributeError = r.content is None # We had a problem status_str = NotifyReddit.http_response_code_lookup( r.status_code, REDDIT_HTTP_ERROR_MAP ) # Reddit always returns a JSON response self.logger.warning( "Failed to send to Reddit after countermeasures {}: " "{}error={}".format( url, ", " if status_str else "", r.status_code ) ) self.logger.debug(f"Response Details:\r\n{r.content}") return (False, {}) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyReddit.http_response_code_lookup( r.status_code, REDDIT_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send to Reddit {}: {}error={}".format( url, ", " if status_str else "", r.status_code ) ) self.logger.debug(f"Response Details:\r\n{r.content}") # Mark our failure return (False, content) errors = ( [] if not content else content.get("json", {}).get("errors", []) ) if errors: self.logger.warning( f"Failed to send to Reddit {url}: {errors!s}" ) self.logger.debug(f"Response Details:\r\n{r.content}") # Mark our failure return (False, content) 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 except requests.RequestException as e: self.logger.warning( f"Exception received when sending Reddit to {url}" ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure return (False, content) return (True, content) @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 # Acquire our targets results["targets"] = NotifyReddit.split_path(results["fullpath"]) # Kind override if "kind" in results["qsd"] and results["qsd"]["kind"]: results["kind"] = NotifyReddit.unquote( results["qsd"]["kind"].strip().lower() ) else: results["kind"] = RedditMessageKind.AUTO # Is an Ad? results["ad"] = parse_bool(results["qsd"].get("ad", False)) # Get Not Safe For Work (NSFW) Flag results["nsfw"] = parse_bool(results["qsd"].get("nsfw", False)) # Send Replies Flag results["replies"] = parse_bool(results["qsd"].get("replies", True)) # Resubmit Flag results["resubmit"] = parse_bool(results["qsd"].get("resubmit", False)) # Is Spoiler Flag results["spoiler"] = parse_bool(results["qsd"].get("spoiler", False)) if "flair_text" in results["qsd"]: results["flair_text"] = NotifyReddit.unquote( results["qsd"]["flair_text"] ) if "flair_id" in results["qsd"]: results["flair_id"] = NotifyReddit.unquote( results["qsd"]["flair_id"] ) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyReddit.parse_list(results["qsd"]["to"]) if "app_id" in results["qsd"]: results["app_id"] = NotifyReddit.unquote(results["qsd"]["app_id"]) else: # The App/Bot ID is the hostname results["app_id"] = NotifyReddit.unquote(results["host"]) if "app_secret" in results["qsd"]: results["app_secret"] = NotifyReddit.unquote( results["qsd"]["app_secret"] ) else: # The first target identified is the App secret results["app_secret"] = ( None if not results["targets"] else results["targets"].pop(0) ) return results apprise-1.10.0/apprise/plugins/resend.py000066400000000000000000000507751517341665700202470ustar00rootroot00000000000000# 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. # You will need an API Key for this plugin to work. # From the Settings -> API Keys you can click "Create API Key" if you don't # have one already. The key must have at least the "Mail Send" permission # to work. # # The schema to use the plugin looks like this: # {schema}://{apikey}:{from_addr} # # Your {from_addr} must be comprissed of your Resend Authenticated # Domain. # Simple API Reference: # - https://resend.com/onboarding from email.utils import formataddr from json import dumps import logging import requests from .. import exception from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_emails, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase RESEND_HTTP_ERROR_MAP = { 200: "Successful request.", 400: "Check that the parameters were correct.", 401: "The API key used was missing.", 403: "The API key used was invalid.", 404: "The resource was not found.", 429: "The rate limit was exceeded.", } class NotifyResend(NotifyBase): """A wrapper for Notify Resend Notifications.""" # The default descriptive name associated with the Notification service_name = "Resend" # The services URL service_url = "https://resend.com" # The default secure protocol secure_protocol = "resend" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/resend/" # Default to markdown notify_format = NotifyFormat.HTML # The default Email API URL to use notify_url = "https://api.resend.com/emails" # 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_addr}", "{schema}://{apikey}:{from_addr}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9._-]+$", "i"), }, "from_addr": { "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, **{ "from": { "name": _("From Address"), "type": "string", "map_to": "from_addr", }, "name": { "name": _("From Name"), "type": "string", "map_to": "from_addr", }, "apikey": { "name": _("API Key"), "type": "string", "map_to": "apikey", }, "reply": { "name": _("Reply To"), "type": "list: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_addr, targets=None, cc=None, bcc=None, reply_to=None, **kwargs, ): """Initialize Notify Resend 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 Resend API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Acquire Targets (To Emails) 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 = {} result = is_email(from_addr) if not result: # Invalid from msg = "Invalid ~From~ email specified: {}".format(from_addr) self.logger.warning(msg) raise TypeError(msg) # initialize our from address self.from_addr = ( result["name"] if result["name"] is not None else False, result["full_email"], ) # Update our Name if specified self.names[self.from_addr[1]] = ( result["name"] if result["name"] else False ) # Acquire our targets targets = parse_emails(targets) if targets: # Validate recipients (to:) and drop bad ones: for recipient in targets: result = is_email(recipient) if result: self.targets.append(result["full_email"]) continue self.logger.warning( f"Dropped invalid email ({recipient}) specified.", ) else: # If our target email list is empty we want to add ourselves to it self.targets.append(self.from_addr[1]) # Validate recipients (cc:) and drop bad ones: for recipient in parse_emails(cc): result = is_email(recipient) if result: self.cc.add(result["full_email"]) # Index our name (if one exists) self.names[result["full_email"]] = ( result["name"] if result["name"] else False ) continue self.logger.warning( f"Dropped invalid Carbon Copy email ({recipient}) specified.", ) # Validate recipients (bcc:) and drop bad ones: for recipient in parse_emails(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.", ) # Validate recipients (reply-to:) and drop bad ones: for recipient in parse_emails(reply_to): result = is_email(recipient) if result: self.reply_to.add(result["full_email"]) # Index our name (if one exists) self.names[result["full_email"]] = ( result["name"] if result["name"] else False ) continue self.logger.warning( "Dropped invalid Reply To email ({}) specified.".format( recipient ), ) 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_addr) 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 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 len(self.bcc) > 0: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join(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 ] ) # 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_addr[1] ) if self.from_addr[0] and self.from_addr[0] != self.app_id: # A custom name was provided params["name"] = self.from_addr[0] return "{schema}://{apikey}:{from_addr}/{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_addr=self.from_addr[1], targets=( "" if not has_targets else "/".join( [NotifyResend.quote(x, safe="@") for x in self.targets] ) ), params=NotifyResend.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.targets) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Resend Notification.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Authorization": f"Bearer {self.apikey}", } # error tracking (used for function return) has_error = False # Prepare our from_name self.from_addr[0] if self.from_addr[0] is not False else self.app_id payload_ = { "from": formataddr(self.from_addr, charset="utf-8"), # A subject is a requirement, so if none is specified we must # set a default with at least 1 character or Resend will deny # our request "subject": title if title else self.default_empty_subject, ( "text" if self.notify_format == NotifyFormat.TEXT else "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 Resend attachment" f" {attachment.url(privacy=True)}." ) return False try: attachments.append( { "content": attachment.base64(), "filename": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "type": "application/octet-stream", "disposition": "attachment", } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Resend attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Resend attachment" f" {attachment.url(privacy=True)}" ) # Append our attachments to the payload payload_.update( { "attachments": attachments, } ) targets = list(self.targets) while len(targets) > 0: target = targets.pop(0) # Create a copy of our template payload = payload_.copy() # unique cc/bcc list management cc = self.cc - self.bcc - {target} bcc = self.bcc - {target} # handle our reply to reply_to = self.reply_to - {target} # Format our cc addresses to support the Name field cc = [ formataddr( (self.names.get(addr, False), addr), charset="utf-8" ) for addr in cc ] # Format our reply-to addresses to support the Name field reply_to = [ formataddr( (self.names.get(addr, False), addr), charset="utf-8" ) for addr in reply_to ] # Set our target payload["to"] = target if cc: payload["cc"] = cc if len(bcc): payload["bcc"] = list(bcc) if reply_to: payload["reply_to"] = reply_to # 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( "Resend POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "Resend 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, ) if r.status_code not in ( requests.codes.ok, requests.codes.accepted, ): # We had a problem status_str = NotifyResend.http_response_code_lookup( r.status_code, RESEND_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Resend 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 Resend notification to {target}.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Resend " 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, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Our URL looks like this: # {schema}://{apikey}:{from_addr}/{targets} # # which actually equates to: # {schema}://{user}:{password}@{host}/{email1}/{email2}/etc.. # ^ ^ ^ # | | | # apikey -from addr- # Prepare our API Key if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): results["apikey"] = NotifyResend.unquote(results["qsd"]["apikey"]) else: results["apikey"] = NotifyResend.unquote(results["user"]) # Our Targets results["targets"] = [] # Attempt to detect 'from' email address if "from" in results["qsd"] and len(results["qsd"]["from"]): results["from_addr"] = NotifyResend.unquote(results["qsd"]["from"]) if results.get("host"): results["targets"].append( NotifyResend.unquote(results["host"]) ) else: # Prepare our From Email Address results["from_addr"] = "{}@{}".format( NotifyResend.unquote( results["password"] if results["password"] else results["user"] ), NotifyResend.unquote(results["host"]), ) if "name" in results["qsd"] and len(results["qsd"]["name"]): results["from_addr"] = formataddr( ( NotifyResend.unquote(results["qsd"]["name"]), results["from_addr"], ), charset="utf-8", ) # Acquire our targets results["targets"].extend(NotifyResend.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"] += NotifyResend.parse_list(results["qsd"]["to"]) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = NotifyResend.parse_list(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = NotifyResend.parse_list(results["qsd"]["bcc"]) # Handle Reply To Addresses if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = results["qsd"]["reply"] return results apprise-1.10.0/apprise/plugins/revolt.py000066400000000000000000000352361517341665700202750ustar00rootroot00000000000000# 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. # Youll need your own Revolt Bot and a Channel Id for the notifications to # be sent in since Revolt does not support webhooks yet. # # This plugin will simply work using the url of: # revolt://BOT_TOKEN/CHANNEL_ID # # API Documentation: # - https://api.revolt.chat/swagger/index.html # from datetime import datetime, timedelta, timezone from json import dumps, loads import requests from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_list, validate_regex from .base import NotifyBase class NotifyRevolt(NotifyBase): """A wrapper for Revolt Notifications.""" # The default descriptive name associated with the Notification service_name = "Revolt" # The services URL service_url = "https://revolt.chat/" # The default secure protocol secure_protocol = "revolt" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/revolt/" # Revolt Channel Message notify_url = "https://api.revolt.chat/" # Revolt supports attachments but doesn't support it here (for now) attachment_support = False # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 # Revolt 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 = 3 # Safety net clock_skew = timedelta(seconds=2) # The maximum allowable characters allowed in the body per message body_maxlen = 2000 # Title Maximum Length title_maxlen = 100 # Define object templates templates = ("{schema}://{bot_token}/{targets}",) # Defile out template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "bot_token": { "name": _("Bot Token"), "type": "string", "private": True, "required": True, }, "target_channel": { "name": _("Channel ID"), "type": "string", "map_to": "targets", "regex": (r"^[a-z0-9_-]+$", "i"), "private": True, "required": True, }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "channel": { "alias_of": "targets", }, "bot_token": { "alias_of": "bot_token", }, "icon_url": {"name": _("Icon URL"), "type": "string"}, "url": { "name": _("Embed URL"), "type": "string", "map_to": "link", }, "to": { "alias_of": "targets", }, }, ) def __init__(self, bot_token, targets, icon_url=None, link=None, **kwargs): super().__init__(**kwargs) # Bot Token self.bot_token = validate_regex(bot_token) if not self.bot_token: msg = f"An invalid Revolt Bot Token ({bot_token}) was specified." self.logger.warning(msg) raise TypeError(msg) # Parse our Channel IDs self.targets = [] for target in parse_list(targets): results = validate_regex( target, *self.template_tokens["target_channel"]["regex"] ) if not results: self.logger.warning( f"Dropped invalid Revolt channel ({target}) specified.", ) continue # Add our target self.targets.append(target) # Image for Embed self.icon_url = icon_url # Url for embed title self.link = link # For Tracking Purposes self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1.0 self.ratelimit_remaining = 1.0 return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Revolt Notification.""" if len(self.targets) == 0: self.logger.warning("There were not Revolt channels to notify.") return False payload = {} # Acquire image_url image_url = ( self.icon_url if self.icon_url else self.image_url(notify_type) ) if self.notify_format == NotifyFormat.MARKDOWN: payload["embeds"] = [ { "title": None if not title else title[0 : self.title_maxlen], "description": body, # Our color associated with our notification "colour": self.color(notify_type), "replies": None, } ] if image_url: payload["embeds"][0]["icon_url"] = image_url if self.link: payload["embeds"][0]["url"] = self.link else: payload["content"] = body if not title else f"{title}\n{body}" has_error = False channel_ids = list(self.targets) for channel_id in channel_ids: postokay, _response = self._send(payload, channel_id) if not postokay: # Failed to send message has_error = True return not has_error def _send(self, payload, channel_id, retries=1, **kwargs): """Wrapper to the requests (post) object.""" headers = { "User-Agent": self.app_id, "X-Bot-Token": self.bot_token, "Content-Type": "application/json; charset=utf-8", "Accept": "application/json; charset=utf-8", } notify_url = f"{self.notify_url}channels/{channel_id}/messages" self.logger.debug( "Revolt POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Revolt Payload: {payload!s}") # By default set wait to None wait = None now = datetime.now(timezone.utc).replace(tzinfo=None) if self.ratelimit_remaining <= 0.0 and now < self.ratelimit_reset: # 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 and set our throttle # accordingly wait = abs( (self.ratelimit_reset - now + self.clock_skew).total_seconds() ) # Default content response object content = {} # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) try: r = requests.post( notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) try: content = loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} # Handle rate limiting (if specified) try: # Store our rate limiting (if provided) self.ratelimit_remaining = int( r.headers.get("X-RateLimit-Remaining") ) self.ratelimit_reset = now + timedelta( seconds=( int(r.headers.get("X-RateLimit-Reset-After")) / 1000 ) ) 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, ): # Some details to debug by self.logger.debug( "Response Details:\r\n%r", content if content else (r.content or b"")[:2000], ) # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) self.logger.warning( "Revolt request limit reached; " "instructed to throttle for %.3fs", abs( ( self.ratelimit_reset - now + self.clock_skew ).total_seconds() ), ) if ( r.status_code == requests.codes.too_many_requests and retries > 0 ): # Try again return self._send( payload=payload, channel_id=channel_id, retries=retries - 1, **kwargs, ) self.logger.warning( "Failed to send to Revolt notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) # Return; we're done return (False, content) else: self.logger.info("Sent Revolt notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred posting to Revolt." ) self.logger.debug(f"Socket 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, self.bot_token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self.icon_url: params["icon_url"] = self.icon_url if self.link: params["url"] = self.link params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{bot_token}/{targets}/?{params}".format( schema=self.secure_protocol, bot_token=self.pprint(self.bot_token, privacy, safe=""), targets="/".join( [self.pprint(x, privacy, safe="") for x in self.targets] ), params=NotifyRevolt.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return 1 if not self.targets else 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, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Store our bot token bot_token = NotifyRevolt.unquote(results["host"]) # Now fetch the Channel IDs targets = NotifyRevolt.split_path(results["fullpath"]) results["bot_token"] = bot_token results["targets"] = targets # 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"] += NotifyRevolt.parse_list(results["qsd"]["to"]) # Support channel id on the URL string (if specified) if "channel" in results["qsd"]: results["targets"] += NotifyRevolt.parse_list( results["qsd"]["channel"] ) # Support bot token on the URL string (if specified) if "bot_token" in results["qsd"]: results["bot_token"] = NotifyRevolt.unquote( results["qsd"]["bot_token"] ) if "icon_url" in results["qsd"]: results["icon_url"] = NotifyRevolt.unquote( results["qsd"]["icon_url"] ) if "url" in results["qsd"]: results["link"] = NotifyRevolt.unquote(results["qsd"]["url"]) if "format" not in results["qsd"] and ( "url" in results or "icon_url" in results ): # Markdown is implied results["format"] = NotifyFormat.MARKDOWN return results apprise-1.10.0/apprise/plugins/rocketchat.py000066400000000000000000000643671517341665700211200ustar00rootroot00000000000000# 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 from json import dumps, loads import re import requests 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 IS_CHANNEL = re.compile(r"^#(?P[A-Za-z0-9_-]+)$") IS_USER = re.compile(r"^@(?P[A-Za-z0-9._-]+)$") IS_ROOM_ID = re.compile(r"^(?P[A-Za-z0-9]+)$") # Extend HTTP Error Messages RC_HTTP_ERROR_MAP = { 400: "Channel/RoomId is wrong format, or missing from server.", 401: "Authentication tokens provided is invalid or missing.", } class RocketChatAuthMode: """The Chat Authentication mode is detected.""" # providing a webhook WEBHOOK = "webhook" # Support token submission TOKEN = "token" # Providing a username and password (default) BASIC = "basic" # Define our authentication modes ROCKETCHAT_AUTH_MODES = ( RocketChatAuthMode.WEBHOOK, RocketChatAuthMode.TOKEN, RocketChatAuthMode.BASIC, ) class NotifyRocketChat(NotifyBase): """A wrapper for Notify Rocket.Chat Notifications.""" # The default descriptive name associated with the Notification service_name = "Rocket.Chat" # The services URL service_url = "https://rocket.chat/" # The default protocol protocol = "rocket" # The default secure protocol secure_protocol = "rockets" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/rocketchat/" # Allows the user to specify the NotifyImageSize object; this is supported # through the webhook image_size = NotifyImageSize.XY_128 # The title is not used title_maxlen = 0 # The maximum size of the message body_maxlen = 1000 # Default to markdown notify_format = NotifyFormat.MARKDOWN # Define object templates templates = ( "{schema}://{user}:{password}@{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{token}@{host}:{port}/{targets}", "{schema}://{user}:{token}@{host}/{targets}", "{schema}://{webhook}@{host}", "{schema}://{webhook}@{host}:{port}", "{schema}://{webhook}@{host}/{targets}", "{schema}://{webhook}@{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, }, "token": { "name": _("API Token"), "type": "string", "map_to": "password", "private": True, }, "webhook": { "name": _("Webhook"), "type": "string", }, "target_channel": { "name": _("Target Channel"), "type": "string", "prefix": "#", "map_to": "targets", }, "target_user": { "name": _("Target User"), "type": "string", "prefix": "@", "map_to": "targets", }, "target_room": { "name": _("Target Room ID"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "mode": { "name": _("Webhook Mode"), "type": "choice:string", "values": ROCKETCHAT_AUTH_MODES, }, "avatar": { "name": _("Use Avatar"), "type": "bool", "default": False, }, "webhook": { "alias_of": "webhook", }, "to": { "alias_of": "targets", }, }, ) def __init__( self, webhook=None, targets=None, mode=None, avatar=None, **kwargs ): """Initialize Notify Rocket.Chat Object.""" super().__init__(**kwargs) # Set our schema self.schema = "https" if self.secure else "http" # Prepare our URL self.api_url = f"{self.schema}://{self.host}" if isinstance(self.port, int): self.api_url += f":{self.port}" # Initialize channels list self.channels = [] # Initialize room list self.rooms = [] # Initialize user list (webhook only) self.users = [] # Assign our webhook (if defined) self.webhook = webhook # Used to track token headers upon authentication (if successful) # This is only used if not on webhook mode self.headers = {} # Authentication mode self.mode = None if not isinstance(mode, str) else mode.lower() if self.mode and self.mode not in ROCKETCHAT_AUTH_MODES: msg = f"The authentication mode specified ({mode}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Detect our mode if it wasn't specified if not self.mode: if self.webhook is not None: # Just a username was specified, we treat this as a webhook self.mode = RocketChatAuthMode.WEBHOOK elif self.password and len(self.password) > 32: self.mode = RocketChatAuthMode.TOKEN else: self.mode = RocketChatAuthMode.BASIC self.logger.debug( "Auto-Detected Rocketchat Auth Mode: %s", self.mode ) if self.mode in ( RocketChatAuthMode.BASIC, RocketChatAuthMode.TOKEN, ) and not (self.user and self.password): # Username & Password is required for Rocket Chat to work msg = "No Rocket.Chat {} was specified.".format( "user/pass combo" if self.mode == RocketChatAuthMode.BASIC else "user/apikey" ) self.logger.warning(msg) raise TypeError(msg) elif self.mode == RocketChatAuthMode.WEBHOOK and not self.webhook: msg = "No Rocket.Chat Incoming Webhook was specified." self.logger.warning(msg) raise TypeError(msg) if self.mode == RocketChatAuthMode.TOKEN: # Set our headers for further communication self.headers.update( { "X-User-Id": self.user, "X-Auth-Token": self.password, } ) # Validate recipients and drop bad ones: for recipient in parse_list(targets): result = IS_CHANNEL.match(recipient) if result: # store valid device self.channels.append(result.group("name")) continue result = IS_ROOM_ID.match(recipient) if result: # store valid room self.rooms.append(result.group("name")) continue result = IS_USER.match(recipient) if result: # store valid room self.users.append(result.group("name")) continue self.logger.warning( f"Dropped invalid channel/room/user ({recipient}) specified.", ) if ( self.mode == RocketChatAuthMode.BASIC and len(self.rooms) == 0 and len(self.channels) == 0 ): msg = "No Rocket.Chat room and/or channels specified to notify." self.logger.warning(msg) raise TypeError(msg) # Prepare our avatar setting # - if specified; that trumps all # - if not specified and we're dealing with a basic setup, the Avatar # is disabled by default. This is because if the account doesn't # have the bot flag set on it it won't work as documented here: # https://developer.rocket.chat/api/rest-api/endpoints\ # /team-collaboration-endpoints/chat/postmessage # - Otherwise if we're a webhook, we enable the avatar by default # (if not otherwise specified) since it will work nicely. # Place an avatar image to associate with our content if self.mode == RocketChatAuthMode.BASIC: self.avatar = False if avatar is None else avatar else: # self.mode == RocketChatAuthMode.WEBHOOK: self.avatar = True if avatar is None else avatar 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 if self.secure else self.protocol, self.host, self.port if self.port else (443 if self.secure else 80), self.user, ( self.password if self.mode in (RocketChatAuthMode.BASIC, RocketChatAuthMode.TOKEN) else self.webhook ), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "avatar": "yes" if self.avatar else "no", "mode": self.mode, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine Authentication if self.mode in (RocketChatAuthMode.BASIC, RocketChatAuthMode.TOKEN): auth = "{user}:{password}@".format( user=NotifyRocketChat.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) else: auth = "{user}{webhook}@".format( user=( "{}:".format(NotifyRocketChat.quote(self.user, safe="")) if self.user else "" ), webhook=self.pprint( self.webhook, privacy, mode=PrivacyMode.Secret, 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( [ NotifyRocketChat.quote(x, safe="@#") for x in chain( # Channels are prefixed with a pound/hashtag symbol [f"#{x}" for x in self.channels], # Rooms are as is self.rooms, # Users [f"@{x}" for x in self.users], ) ] ), params=NotifyRocketChat.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.channels) + len(self.rooms) + len(self.users) return targets if targets > 0 else 1 def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Wrapper to _send since we can alert more then one channel.""" # Call the _send_ function applicable to whatever mode we're in # - calls _send_webhook_notification if the mode variable is set # - calls _send_basic_notification if the mode variable is not set return getattr( self, "_send_{}_notification".format( RocketChatAuthMode.WEBHOOK if self.mode == RocketChatAuthMode.WEBHOOK else RocketChatAuthMode.BASIC ), )(body=body, title=title, notify_type=notify_type, **kwargs) def _send_webhook_notification( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Sends a webhook notification.""" # Our payload object payload = self._payload(body, title, notify_type) # Assemble our webhook URL path = f"hooks/{self.webhook}" # Build our list of channels/rooms/users (if any identified) targets = [f"@{u}" for u in self.users] targets.extend([f"#{c}" for c in self.channels]) targets.extend([f"{r}" for r in self.rooms]) if len(targets) == 0: # We can take an early exit return self._send( payload, notify_type=notify_type, path=path, **kwargs ) # Otherwise we want to iterate over each of the targets # Initiaize our error tracking has_error = False while len(targets): # Retrieve our target target = targets.pop(0) # Assign our channel/room/user payload["channel"] = target if not self._send( payload, notify_type=notify_type, path=path, **kwargs ): # toggle flag has_error = True return not has_error def _send_basic_notification( self, body, title="", notify_type=NotifyType.INFO, **kwargs ): """Authenticates with the server using a user/pass combo for notifications.""" # Track whether we authenticated okay if self.mode == RocketChatAuthMode.BASIC and not self.login(): return False # prepare JSON Object payload_ = self._payload(body, title, notify_type) # Initiaize our error tracking has_error = False # Build our list of channels/rooms/users (if any identified) channels = [f"@{u}" for u in self.users] channels.extend([f"#{c}" for c in self.channels]) # Create a copy of our channels to notify against payload = payload_.copy() while len(channels) > 0: # Get Channel channel = channels.pop(0) payload["channel"] = channel if not self._send(payload, notify_type=notify_type, **kwargs): # toggle flag has_error = True # Create a copy of our room id's to notify against rooms = list(self.rooms) payload = payload_.copy() while len(rooms): # Get Room room = rooms.pop(0) payload["roomId"] = room if not self._send(payload, notify_type=notify_type, **kwargs): # toggle flag has_error = True if self.mode == RocketChatAuthMode.BASIC: # logout self.logout() return not has_error def _payload(self, body, title="", notify_type=NotifyType.INFO): """Prepares a payload object.""" # prepare JSON Object payload = { "text": body, } # apply our images if they're set to be displayed image_url = self.image_url(notify_type) if self.avatar and image_url: payload["avatar"] = image_url return payload def _send( self, payload, notify_type, path="api/v1/chat.postMessage", **kwargs ): """Perform Notify Rocket.Chat Notification.""" api_url = f"{self.api_url}/{path}" self.logger.debug( "Rocket.Chat POST URL:" f" {api_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Rocket.Chat Payload: {payload!s}") # Copy our existing headers headers = self.headers.copy() # Apply minimum headers headers.update( { "User-Agent": self.app_id, "Content-Type": "application/json", } ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( api_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyRocketChat.http_response_code_lookup( r.status_code, RC_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Rocket.Chat {}:notification: " "{}{}error={}.".format( self.mode, 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(f"Sent Rocket.Chat {self.mode}:notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Rocket.Chat " f"{self.mode}:notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True def login(self): """Login to our server.""" payload = { "username": self.user, "password": self.password, } api_url = "{}/{}".format(self.api_url, "api/v1/login") try: r = requests.post( api_url, data=payload, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyRocketChat.http_response_code_lookup( r.status_code, RC_HTTP_ERROR_MAP ) self.logger.warning( "Failed to authenticate {} with Rocket.Chat: " "{}{}error={}.".format( self.user, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug(f"Response Details:\r\n{r.content}") # Return; we're done return False else: self.logger.debug("Rocket.Chat authentication successful") response = loads(r.content) if response.get("status") != "success": self.logger.warning( f"Could not authenticate {self.user} with Rocket.Chat." ) return False # Set our headers for further communication self.headers["X-Auth-Token"] = response.get( "data", {"authToken": None} ).get("authToken") self.headers["X-User-Id"] = response.get( "data", {"userId": None} ).get("userId") except (AttributeError, TypeError, ValueError): # Our response was not the JSON type we had expected it to be # - ValueError = r.content is Unparsable # - TypeError = r.content is None # - AttributeError = r is None self.logger.warning( f"A commuication error occurred authenticating {self.user} on " "Rocket.Chat." ) return False except requests.RequestException as e: self.logger.warning( f"A connection error occurred authenticating {self.user} on " "Rocket.Chat." ) self.logger.debug(f"Socket Exception: {e!s}") return False return True def logout(self): """Logout of our server.""" api_url = "{}/{}".format(self.api_url, "api/v1/logout") try: r = requests.post( api_url, headers=self.headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyRocketChat.http_response_code_lookup( r.status_code, RC_HTTP_ERROR_MAP ) self.logger.warning( "Failed to logoff {} from Rocket.Chat: " "{}{}error={}.".format( self.user, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug(f"Response Details:\r\n{r.content}") # Return; we're done return False else: self.logger.debug( f"Rocket.Chat log off successful; response {r.content}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred logging off the " "Rocket.Chat server" ) self.logger.debug(f"Socket Exception: {e!s}") return False return True @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" try: # Attempt to detect the webhook (if specified in the URL) # If no webhook is specified, then we just pass along as if nothing # happened. However if we do find a webhook, we want to rebuild our # URL without it since it conflicts with standard URLs. Support # %2F since that is a forward slash escaped # rocket://webhook@host # rocket://user:webhook@host match = re.match( r"^\s*(?P[^:]+://)((?P[^:]+):)?" r"(?P[a-z0-9]+(/|%2F)" r"[a-z0-9]+)\@(?P.+)$", url, re.I, ) except TypeError: # Not a string return None if match: # Re-assemble our URL without the webhook url = "{schema}{user}{url}".format( schema=match.group("schema"), user=( "{}@".format(match.group("user")) if match.group("user") else "" ), url=match.group("url"), ) results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results if match: # store our webhook results["webhook"] = NotifyRocketChat.unquote( match.group("webhook") ) # Take on the password too in the event we're in basic mode # We do not unquote() as this is done at a later state results["password"] = match.group("webhook") # Apply our targets results["targets"] = NotifyRocketChat.split_path(results["fullpath"]) # The user may have forced the mode if "mode" in results["qsd"] and len(results["qsd"]["mode"]): results["mode"] = NotifyRocketChat.unquote(results["qsd"]["mode"]) # avatar icon if "avatar" in results["qsd"] and len(results["qsd"]["avatar"]): results["avatar"] = parse_bool(results["qsd"].get("avatar", True)) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyRocketChat.parse_list( results["qsd"]["to"] ) # The 'webhook' over-ride (if specified) if "webhook" in results["qsd"] and len(results["qsd"]["webhook"]): results["webhook"] = NotifyRocketChat.unquote( results["qsd"]["webhook"] ) return results apprise-1.10.0/apprise/plugins/rsyslog.py000066400000000000000000000304061517341665700204560ustar00rootroot00000000000000# 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 socket from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool from .base import NotifyBase class syslog: """Extrapoloated information from the syslog library so that this plugin would not be dependent on it.""" # Notification Categories LOG_KERN = 0 LOG_USER = 8 LOG_MAIL = 16 LOG_DAEMON = 24 LOG_AUTH = 32 LOG_SYSLOG = 40 LOG_LPR = 48 LOG_NEWS = 56 LOG_UUCP = 64 LOG_CRON = 72 LOG_LOCAL0 = 128 LOG_LOCAL1 = 136 LOG_LOCAL2 = 144 LOG_LOCAL3 = 152 LOG_LOCAL4 = 160 LOG_LOCAL5 = 168 LOG_LOCAL6 = 176 LOG_LOCAL7 = 184 # Notification Types LOG_INFO = 6 LOG_NOTICE = 5 LOG_WARNING = 4 LOG_CRIT = 2 class SyslogFacility: """All of the supported facilities.""" KERN = "kern" USER = "user" MAIL = "mail" DAEMON = "daemon" AUTH = "auth" SYSLOG = "syslog" LPR = "lpr" NEWS = "news" UUCP = "uucp" CRON = "cron" LOCAL0 = "local0" LOCAL1 = "local1" LOCAL2 = "local2" LOCAL3 = "local3" LOCAL4 = "local4" LOCAL5 = "local5" LOCAL6 = "local6" LOCAL7 = "local7" SYSLOG_FACILITY_MAP = { SyslogFacility.KERN: syslog.LOG_KERN, SyslogFacility.USER: syslog.LOG_USER, SyslogFacility.MAIL: syslog.LOG_MAIL, SyslogFacility.DAEMON: syslog.LOG_DAEMON, SyslogFacility.AUTH: syslog.LOG_AUTH, SyslogFacility.SYSLOG: syslog.LOG_SYSLOG, SyslogFacility.LPR: syslog.LOG_LPR, SyslogFacility.NEWS: syslog.LOG_NEWS, SyslogFacility.UUCP: syslog.LOG_UUCP, SyslogFacility.CRON: syslog.LOG_CRON, SyslogFacility.LOCAL0: syslog.LOG_LOCAL0, SyslogFacility.LOCAL1: syslog.LOG_LOCAL1, SyslogFacility.LOCAL2: syslog.LOG_LOCAL2, SyslogFacility.LOCAL3: syslog.LOG_LOCAL3, SyslogFacility.LOCAL4: syslog.LOG_LOCAL4, SyslogFacility.LOCAL5: syslog.LOG_LOCAL5, SyslogFacility.LOCAL6: syslog.LOG_LOCAL6, SyslogFacility.LOCAL7: syslog.LOG_LOCAL7, } SYSLOG_FACILITY_RMAP = { syslog.LOG_KERN: SyslogFacility.KERN, syslog.LOG_USER: SyslogFacility.USER, syslog.LOG_MAIL: SyslogFacility.MAIL, syslog.LOG_DAEMON: SyslogFacility.DAEMON, syslog.LOG_AUTH: SyslogFacility.AUTH, syslog.LOG_SYSLOG: SyslogFacility.SYSLOG, syslog.LOG_LPR: SyslogFacility.LPR, syslog.LOG_NEWS: SyslogFacility.NEWS, syslog.LOG_UUCP: SyslogFacility.UUCP, syslog.LOG_CRON: SyslogFacility.CRON, syslog.LOG_LOCAL0: SyslogFacility.LOCAL0, syslog.LOG_LOCAL1: SyslogFacility.LOCAL1, syslog.LOG_LOCAL2: SyslogFacility.LOCAL2, syslog.LOG_LOCAL3: SyslogFacility.LOCAL3, syslog.LOG_LOCAL4: SyslogFacility.LOCAL4, syslog.LOG_LOCAL5: SyslogFacility.LOCAL5, syslog.LOG_LOCAL6: SyslogFacility.LOCAL6, syslog.LOG_LOCAL7: SyslogFacility.LOCAL7, } # Used as a lookup when handling the Apprise -> Syslog Mapping SYSLOG_PUBLISH_MAP = { NotifyType.INFO: syslog.LOG_INFO, NotifyType.SUCCESS: syslog.LOG_NOTICE, NotifyType.FAILURE: syslog.LOG_CRIT, NotifyType.WARNING: syslog.LOG_WARNING, } class NotifyRSyslog(NotifyBase): """A wrapper for Remote Syslog Notifications.""" # The default descriptive name associated with the Notification service_name = "Remote Syslog" # The services URL service_url = "https://tools.ietf.org/html/rfc5424" # The default protocol protocol = "rsyslog" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/rsyslog/" # Disable throttle rate for RSyslog requests request_rate_per_sec = 0 # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{host}/{facility}", "{schema}://{host}:{port}/{facility}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "facility": { "name": _("Facility"), "type": "choice:string", "values": list(SYSLOG_FACILITY_MAP), "default": SyslogFacility.USER, "required": True, }, "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, "default": 514, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "facility": { # We map back to the same element defined in template_tokens "alias_of": "facility", }, "logpid": { "name": _("Log PID"), "type": "bool", "default": True, "map_to": "log_pid", }, }, ) def __init__(self, facility=None, log_pid=True, **kwargs): """Initialize RSyslog Object.""" super().__init__(**kwargs) if facility: try: self.facility = SYSLOG_FACILITY_MAP[facility] except KeyError: msg = f"An invalid syslog facility ({facility}) was specified." self.logger.warning(msg) raise TypeError(msg) from None else: self.facility = SYSLOG_FACILITY_MAP[ self.template_tokens["facility"]["default"] ] # Include PID with each message. self.log_pid = log_pid return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform RSyslog Notification.""" if title: # Format title body = f"{title}: {body}" # Always call throttle before any remote server i/o is made self.throttle() host = self.host port = ( self.port if self.port else self.template_tokens["port"]["default"] ) priority = SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8 payload = ( f"<{priority}>- {os.getpid()} {body}" if self.log_pid else f"<{priority}>- {body}" ) # send UDP packet to upstream server self.logger.debug( "RSyslog Host: %s:%d/%s", host, port, SYSLOG_FACILITY_RMAP[self.facility], ) self.logger.debug(f"RSyslog Payload: {payload!s}") # our sent bytes sent = 0 try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(self.socket_connect_timeout) sent = sock.sendto(payload.encode("utf-8"), (host, port)) sock.close() except socket.gaierror as e: self.logger.warning( "A connection error occurred sending RSyslog " "notification to %s:%d/%s", host, port, SYSLOG_FACILITY_RMAP[self.facility], ) self.logger.debug(f"Socket Exception: {e!s}") return False except socket.timeout as e: self.logger.warning( "A connection timeout occurred sending RSyslog " "notification to %s:%d/%s", host, port, SYSLOG_FACILITY_RMAP[self.facility], ) self.logger.debug(f"Socket Exception: {e!s}") return False if sent < len(payload): self.logger.warning( "RSyslog sent %d byte(s) but intended to send %d byte(s)", sent, len(payload), ) return False self.logger.info("Sent RSyslog notification.") 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.protocol, self.host, ( self.port if self.port else self.template_tokens["port"]["default"] ), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "logpid": "yes" if self.log_pid else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{hostname}{port}/{facility}/?{params}".format( schema=self.protocol, hostname=NotifyRSyslog.quote(self.host, safe=""), port=( "" if self.port is None or self.port == self.template_tokens["port"]["default"] else f":{self.port}" ), facility=( self.template_tokens["facility"]["default"] if self.facility not in SYSLOG_FACILITY_RMAP else SYSLOG_FACILITY_RMAP[self.facility] ), params=NotifyRSyslog.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 tokens = [] # Get our path values tokens.extend(NotifyRSyslog.split_path(results["fullpath"])) # Initialization facility = None if tokens: # Store the last entry as the facility facility = tokens[-1].lower() # However if specified on the URL, that will over-ride what was # identified if "facility" in results["qsd"] and len(results["qsd"]["facility"]): facility = results["qsd"]["facility"].lower() if facility and facility not in SYSLOG_FACILITY_MAP: # Find first match; if no match is found we set the result # to the matching key. This allows us to throw a TypeError # during the __init__() call. The benifit of doing this # check here is if we do have a valid match, we can support # short form matches like 'u' which will match against user facility = next( (f for f in SYSLOG_FACILITY_MAP if f.startswith(facility)), facility, ) # Save facility if set if facility: results["facility"] = facility # Include PID as part of the message logged results["log_pid"] = parse_bool( results["qsd"].get( "logpid", NotifyRSyslog.template_args["logpid"]["default"] ) ) return results apprise-1.10.0/apprise/plugins/ryver.py000066400000000000000000000277321517341665700201330ustar00rootroot00000000000000# 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 first generate a webhook. # When you're complete, you will recieve a URL that looks something like this: # https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG # ^ ^ # | | # These are important <---^----------------------------------------^ # from json import dumps import re import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, validate_regex from .base import NotifyBase class RyverWebhookMode: """Ryver supports to webhook modes.""" SLACK = "slack" RYVER = "ryver" # Define the types in a list for validation purposes RYVER_WEBHOOK_MODES = ( RyverWebhookMode.SLACK, RyverWebhookMode.RYVER, ) class NotifyRyver(NotifyBase): """A wrapper for Ryver Notifications.""" # The default descriptive name associated with the Notification service_name = "Ryver" # The services URL service_url = "https://ryver.com/" # The default secure protocol secure_protocol = "ryver" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/ryver/" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # The maximum allowable characters allowed in the body per message body_maxlen = 1000 # Define object templates templates = ( "{schema}://{organization}/{token}", "{schema}://{botname}@{organization}/{token}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "organization": { "name": _("Organization"), "type": "string", "required": True, "regex": (r"^[A-Z0-9_-]{3,32}$", "i"), }, "token": { "name": _("Token"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9]{15}$", "i"), }, "botname": { "name": _("Bot Name"), "type": "string", "map_to": "user", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "mode": { "name": _("Webhook Mode"), "type": "choice:string", "values": RYVER_WEBHOOK_MODES, "default": RyverWebhookMode.RYVER, }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, }, ) def __init__( self, organization, token, mode=RyverWebhookMode.RYVER, include_image=True, **kwargs, ): """Initialize Ryver Object.""" super().__init__(**kwargs) # API Token (associated with project) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"An invalid Ryver API Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # Organization (associated with project) self.organization = validate_regex( organization, *self.template_tokens["organization"]["regex"] ) if not self.organization: msg = ( "An invalid Ryver Organization " f"({organization}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Store our webhook mode self.mode = None if not isinstance(mode, str) else mode.lower() if self.mode not in RYVER_WEBHOOK_MODES: msg = f"The Ryver webhook mode specified ({mode}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Place an image inline with the message body self.include_image = include_image # Slack formatting requirements are defined here which Ryver supports: # https://api.slack.com/docs/message-formatting self._re_formatting_map = { # New lines must become the string version r"\r\*\n": "\\n", # Escape other special characters r"&": "&", r"<": "<", r">": ">", } # Iterate over above list and store content accordingly self._re_formatting_rules = re.compile( r"(" + "|".join(self._re_formatting_map.keys()) + r")", re.IGNORECASE, ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Ryver Notification.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } if self.mode == RyverWebhookMode.SLACK: # Perform Slack formatting title = self._re_formatting_rules.sub( # pragma: no branch lambda x: self._re_formatting_map[x.group()], title, ) body = self._re_formatting_rules.sub( # pragma: no branch lambda x: self._re_formatting_map[x.group()], body, ) url = f"https://{self.organization}.ryver.com/application/webhook/{self.token}" # prepare JSON Object payload = { "body": body if not title else f"**{title}**\r\n{body}", "createSource": { "displayName": self.user, "avatar": None, }, } # Acquire our image url if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) if image_url: payload["createSource"]["avatar"] = image_url self.logger.debug( f"Ryver POST URL: {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Ryver Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Ryver 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 Ryver notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending" f" Ryver:{self.organization} " + "notification." ) self.logger.debug(f"Socket Exception: {e!s}") 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.organization, self.token) 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", "mode": self.mode, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine if there is a botname present botname = "" if self.user: botname = "{botname}@".format( botname=NotifyRyver.quote(self.user, safe=""), ) return "{schema}://{botname}{organization}/{token}/?{params}".format( schema=self.secure_protocol, botname=botname, organization=NotifyRyver.quote(self.organization, safe=""), token=self.pprint(self.token, privacy, safe=""), params=NotifyRyver.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 # The first token is stored in the hostname results["organization"] = NotifyRyver.unquote(results["host"]) # Now fetch the remaining tokens try: results["token"] = NotifyRyver.split_path(results["fullpath"])[0] except IndexError: # no token results["token"] = None # Retrieve the mode results["mode"] = results["qsd"].get("mode", RyverWebhookMode.RYVER) # use image= for consistency with the other plugins results["include_image"] = parse_bool( results["qsd"].get("image", True) ) return results @staticmethod def parse_native_url(url): """ Support https://RYVER_ORG.ryver.com/application/webhook/TOKEN """ result = re.match( r"^https?://(?P[A-Z0-9_-]+)\.ryver\.com/application/webhook/" r"(?P[A-Z0-9]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: return NotifyRyver.parse_url( "{schema}://{org}/{webhook_token}/{params}".format( schema=NotifyRyver.secure_protocol, org=result.group("org"), webhook_token=result.group("webhook_token"), params=( "" if not result.group("params") else result.group("params") ), ) ) return None apprise-1.10.0/apprise/plugins/sendgrid.py000066400000000000000000000460501517341665700205550ustar00rootroot00000000000000# 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. # You will need an API Key for this plugin to work. # From the Settings -> API Keys you can click "Create API Key" if you don't # have one already. The key must have at least the "Mail Send" permission # to work. # # The schema to use the plugin looks like this: # {schema}://{apikey}:{from_email} # # Your {from_email} must be comprissed of your Sendgrid Authenticated # Domain. The same domain must have 'Link Branding' turned on as well or it # will not work. This can be seen from Settings -> Sender Authentication. # If you're (SendGrid) verified domain is example.com, then your schema may # look something like this: # Simple API Reference: # - https://sendgrid.com/docs/API_Reference/Web_API_v3/index.html # - https://sendgrid.com/docs/ui/sending-email/\ # how-to-send-an-email-with-dynamic-transactional-templates/ from json import dumps import logging import requests from .. import exception from ..common import NotifyFormat, NotifyType 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 SENDGRID_HTTP_ERROR_MAP = { 401: "Unauthorized - You do not have authorization to make the request.", 413: ( "Payload To Large - The JSON payload you have included in your " "request is too large." ), 429: ( "Too Many Requests - The number of requests you have made exceeds " "SendGrid's rate limitations." ), } class NotifySendGrid(NotifyBase): """A wrapper for Notify SendGrid Notifications.""" # The default descriptive name associated with the Notification service_name = "SendGrid" # The services URL service_url = "https://sendgrid.com" # The default secure protocol secure_protocol = "sendgrid" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/sendgrid/" # Default to markdown notify_format = NotifyFormat.HTML # The default Email API URL to use notify_url = "https://api.sendgrid.com/v3/mail/send" # 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-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, **{ "template": { # Template ID # The template ID is 64 characters with one dash (d-uuid) "name": _("Template"), "type": "string", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) # Support Template Dynamic Variables (Substitutions) template_kwargs = { "template_data": { "name": _("Template Data"), "prefix": "+", }, } def __init__( self, apikey, from_email, targets=None, cc=None, bcc=None, template=None, template_data=None, **kwargs, ): """Initialize Notify SendGrid 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 SendGrid 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"] # Acquire Targets (To Emails) self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Now our dynamic template (if defined) self.template = template # Now our dynamic template data (if defined) self.template_data = ( template_data if isinstance(template_data, dict) else {} ) # 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.template: # Handle our Template ID if if was specified params["template"] = self.template # Append our template_data into our parameter list params.update({f"+{k}": v for k, v in self.template_data.items()}) # 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( [NotifySendGrid.quote(x, safe="") for x in self.targets] ) ), params=NotifySendGrid.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return max(1, len(self.targets)) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform SendGrid Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no SendGrid email recipients to notify" ) return False headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Authorization": f"Bearer {self.apikey}", } # error tracking (used for function return) has_error = False # A Simple Email Payload Template payload_ = { "personalizations": [ { # Placeholder "to": [{"email": None}], } ], "from": { "email": self.from_email, }, # A subject is a requirement, so if none is specified we must # set a default with at least 1 character or SendGrid will deny # our request "subject": title if title else self.default_empty_subject, "content": [ { "type": ( "text/plain" if self.notify_format == NotifyFormat.TEXT else "text/html" ), "value": 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 SendGrid attachment" f" {attachment.url(privacy=True)}." ) return False try: attachments.append( { "content": attachment.base64(), "filename": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "type": "application/octet-stream", "disposition": "attachment", } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access SendGrid attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending SendGrid attachment" f" {attachment.url(privacy=True)}" ) # Append our attachments to the payload payload_.update( { "attachments": attachments, } ) if self.template: payload_["template_id"] = self.template if self.template_data: payload_["personalizations"][0]["dynamic_template_data"] = ( dict(self.template_data.items()) ) 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 target payload["personalizations"][0]["to"][0]["email"] = target if len(cc): payload["personalizations"][0]["cc"] = [ {"email": email} for email in cc ] if len(bcc): payload["personalizations"][0]["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( "SendGrid POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "SendGrid 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, ) if r.status_code not in ( requests.codes.ok, requests.codes.accepted, ): # We had a problem status_str = NotifySendGrid.http_response_code_lookup( r.status_code, SENDGRID_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send SendGrid 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 SendGrid notification to {target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending SendGrid " 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"] = NotifySendGrid.unquote(results["user"]) # Prepare our From Email Address results["from_email"] = "{}@{}".format( NotifySendGrid.unquote(results["password"]), NotifySendGrid.unquote(results["host"]), ) # Acquire our targets results["targets"] = NotifySendGrid.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"] += NotifySendGrid.parse_list( results["qsd"]["to"] ) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = NotifySendGrid.parse_list(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = NotifySendGrid.parse_list(results["qsd"]["bcc"]) # Handle Blind Carbon Copy Addresses if "template" in results["qsd"] and len(results["qsd"]["template"]): results["template"] = NotifySendGrid.unquote( results["qsd"]["template"] ) # Add any template substitutions results["template_data"] = results["qsd+"] return results apprise-1.10.0/apprise/plugins/sendpulse.py000066400000000000000000000704601517341665700207620ustar00rootroot00000000000000# 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. # Simple API Reference: # - https://sendpulse.com/integrations/api/smtp import base64 from email.utils import formataddr from json import dumps, loads import logging import re import requests from .. import exception from ..common import NotifyFormat, NotifyType, PersistentStoreMode from ..conversion import convert_between from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_emails, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase class NotifySendPulse(NotifyBase): """ A wrapper for Notify SendPulse Notifications """ # The default descriptive name associated with the Notification service_name = "SendPulse" # The services URL service_url = "https://sendpulse.com" # The default secure protocol secure_protocol = "sendpulse" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/sendpulse/" # Default to markdown notify_format = NotifyFormat.HTML # The default Email API URL to use notify_email_url = "https://api.sendpulse.com/smtp/emails" # Our OAuth Query notify_oauth_url = "https://api.sendpulse.com/oauth/access_token" # Support attachments attachment_support = True # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.2 # Our default is to no not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.AUTO # Token expiry if not detected in seconds (below is 1 hr) token_expiry = 3600 # The number of seconds to grace for early token renewal # Below states that 10 seconds bfore our token expiry, we'll # attempt to renew it token_expiry_edge = 10 # Support attachments attachment_support = True # The default subject to use if one isn't specified. default_empty_subject = "" # Define object templates templates = ( "{schema}://{user}@{host}/{client_secret}/", "{schema}://{user}@{host}/{client_id}/{client_secret}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "host": { "name": _("Domain"), "type": "string", "required": True, }, "client_id": { "name": _("Client ID"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9._-]+$", "i"), }, "client_secret": { "name": _("Client Secret"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9._-]+$", "i"), }, "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, **{ "from": { "name": _("From Email"), "type": "string", "map_to": "from_addr", }, "template": { # The template ID is an integer "name": _("Template ID"), "type": "int", }, "id": { "alias_of": "client_id", }, "secret": { "alias_of": "client_secret", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) # Support Template Dynamic Variables (Substitutions) template_kwargs = { "template_data": { "name": _("Template Data"), "prefix": "+", }, } def __init__( self, client_id, client_secret, from_addr=None, targets=None, cc=None, bcc=None, template=None, template_data=None, **kwargs, ): """ Initialize Notify SendPulse Object """ super().__init__(**kwargs) # For tracking our email -> name lookups self.names = {} # Temporary from_addr to work with for parsing from_addr_ = [self.app_id, ""] if self.user: if self.host: # Prepare the bases of our email from_addr_ = [ from_addr_[0], "{}@{}".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"] from_addr_ = [ result["name"] if result["name"] else from_addr_[0], self.user, ] if isinstance(from_addr, str): result = is_email(from_addr) if result: from_addr_ = ( result["name"] if result["name"] else from_addr_[0], result["full_email"], ) else: # Only update the string but use the already detected info from_addr_[0] = from_addr result = is_email(from_addr_[1]) if not result: # Parse Source domain based on from_addr msg = "Invalid ~From~ email specified: {}".format( "{} <{}>".format(from_addr_[0], from_addr_[1]) if from_addr_[0] else "{}".format(from_addr_[1]) ) self.logger.warning(msg) raise TypeError(msg) # Store our lookup self.from_addr = from_addr_[1] self.names[from_addr_[1]] = from_addr_[0] # Client ID self.client_id = validate_regex( client_id, *self.template_tokens["client_id"]["regex"] ) if not self.client_id: msg = "An invalid SendPulse Client ID ({}) was specified.".format( client_id ) self.logger.warning(msg) raise TypeError(msg) # Client Secret self.client_secret = validate_regex( client_secret, *self.template_tokens["client_secret"]["regex"] ) if not self.client_secret: msg = ( "An invalid SendPulse Client Secret " "({}) was specified.".format(client_secret) ) self.logger.warning(msg) raise TypeError(msg) # Acquire Targets (To Emails) self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # No template self.template = None if template: try: # Store our template self.template = int(template) except (TypeError, ValueError): # Not a valid integer; ignore entry err = ( "The SendPulse Template ID specified" f" ({template}) is invalid." ) self.logger.warning(err) raise TypeError(err) from None # Now our dynamic template data (if defined) self.template_data = ( template_data if isinstance(template_data, dict) else {} ) 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["full_email"]) if result["name"]: self.names[result["full_email"]] = result["name"] 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(self.from_addr) # Validate recipients (cc:) and drop bad ones: for recipient in parse_emails(cc): result = is_email(recipient) if result: self.cc.add(result["full_email"]) if result["name"]: self.names[result["full_email"]] = result["name"] 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): result = is_email(recipient) if result: self.bcc.add(result["full_email"]) if result["name"]: self.names[result["full_email"]] = result["name"] continue self.logger.warning( "Dropped invalid Blind Carbon Copy email " "({}) specified.".format(recipient), ) if len(self.targets) == 0: # Notify ourselves self.targets.append(self.from_addr) 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.client_id, self.client_secret) 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( [ 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 len(self.bcc) > 0: # 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.template: # Handle our Template ID if if was specified params["template"] = self.template # handle from= if self.names[self.from_addr] != self.app_id: params["from"] = self.names[self.from_addr] # Append our template_data into our parameter list params.update( {"+{}".format(k): v for k, v in self.template_data.items()} ) # 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_addr ) return "{schema}://{source}/{cid}/{secret}/{targets}?{params}".format( schema=self.secure_protocol, source=self.from_addr, cid=self.pprint(self.client_id, privacy, safe=""), secret=self.pprint(self.client_secret, privacy, safe=""), targets="" if not has_targets else "/".join( [NotifySendPulse.quote(x, safe="") for x in self.targets] ), params=NotifySendPulse.urlencode(params), ) def __len__(self): """ Returns the number of targets associated with this notification """ return len(self.targets) def login(self): """ Authenticates with the server to get a access_token """ self.store.clear("access_token") payload = { "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret, } success, response = self._fetch(self.notify_oauth_url, payload) if not success: return False access_token = response.get("access_token") # If we get here, we're authenticated try: expires = int(response.get("expires_in")) - self.token_expiry_edge if expires <= self.token_expiry_edge: self.logger.error( "SendPulse token expiry limit returned was invalid" ) return False elif expires > self.token_expiry: self.logger.warning( "SendPulse token expiry limit fixed to: {}s".format( self.token_expiry ) ) expires = self.token_expiry - self.token_expiry_edge except (AttributeError, TypeError, ValueError): # expires_in was not an integer self.logger.warning( "SendPulse token expiry limit presumed to be: {}s".format( self.token_expiry ) ) expires = self.token_expiry - self.token_expiry_edge self.store.set("access_token", access_token, expires=expires) return access_token def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """ Perform SendPulse Notification """ access_token = self.store.get("access_token") or self.login() if not access_token: return False # error tracking (used for function return) has_error = False # A Simple Email Payload Template payload_ = { "email": { "from": { "name": self.names[self.from_addr], "email": self.from_addr, }, # To is populated further on "to": [], # A subject is a requirement, so if none is specified we must # set a default with at least 1 character or SendPulse will # deny our request "subject": title if title else self.default_empty_subject, } } # Prepare Email Message if self.notify_format == NotifyFormat.HTML: # HTML payload_["email"].update( { "text": convert_between( NotifyFormat.HTML, NotifyFormat.TEXT, body ), "html": base64.b64encode(body.encode("utf-8")).decode( "ascii" ), } ) else: # Text payload_["email"]["text"] = 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 SendPulse attachment {}.".format( attachment.url(privacy=True) ) ) return False try: attachments[ attachment.name if attachment.name else f"file{no:03}.dat" ] = attachment.base64() except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access SendPulse attachment {}.".format( attachment.url(privacy=True) ) ) return False self.logger.debug( "Appending SendPulse attachment {}".format( attachment.url(privacy=True) ) ) # Append our attachments to the payload payload_["email"].update( { "attachments_binary": attachments, } ) if self.template: payload_["email"].update( { "template": { "id": self.template, "variables": self.template_data, } } ) 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} # # prepare our 'to' # to = {"email": target} if target in self.names: to["name"] = self.names[target] # Set our target payload["email"]["to"] = [to] if len(cc): payload["email"]["cc"] = [] for email in cc: item = { "email": email, } if email in self.names: item["name"] = self.names[email] payload["email"]["cc"].append(item) if len(bcc): payload["email"]["bcc"] = [] for email in bcc: item = { "email": email, } if email in self.names: item["name"] = self.names[email] payload["email"]["bcc"].append(item) # Perform our post success, _response = self._fetch( self.notify_email_url, payload, target, retry=1 ) if not success: has_error = True continue return not has_error def _fetch(self, url, payload, target=None, retry=0): """ Wrapper to request.post() to manage it's response better and make the send() function cleaner and easier to maintain. This function returns True if the _post was successful and False if it wasn't. """ headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } access_token = self.store.get("access_token") if access_token: headers.update({"Authorization": f"Bearer {access_token}"}) # 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"SendPulse POST URL: {url}" f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "SendPulse Payload: %s", sanitize_payload(payload) ) # Prepare our default response response = {} # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) try: response = loads(r.content) except (AttributeError, TypeError, ValueError): # This gets thrown if we can't parse our JSON Response # - ValueError = r.content is Unparsable # - TypeError = r.content is None # - AttributeError = r is None self.logger.warning("Invalid response from SendPulse server.") self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return (False, {}) # Reference status code status_code = r.status_code # Key likely expired, we'll reset it and try one more time if ( status_code == requests.codes.unauthorized and retry and self.login() ): return self._fetch(url, payload, target, retry=retry - 1) if status_code not in (requests.codes.ok, requests.codes.accepted): # We had a problem status_str = NotifySendPulse.http_response_code_lookup( status_code ) if target: self.logger.warning( "Failed to send SendPulse notification to {}: " "{}{}error={}.".format( target, status_str, ", " if status_str else "", status_code, ) ) else: self.logger.warning( "SendPulse Authentication Request failed: " "{}{}error={}.".format( status_str, ", " if status_str else "", status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) else: if target: self.logger.info( "Sent SendPulse notification to {}.".format(target) ) else: self.logger.debug("SendPulse authentication successful") return (True, response) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending SendPulse " "notification to {}.".format(target) ) self.logger.debug("Socket Exception: {}".format(str(e))) return (False, response) @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 # Define our minimum requirements; defining them now saves us from # having to if/else all kinds of branches below... results["from_addr"] = None results["client_id"] = None results["client_secret"] = None # Prepare our targets results["targets"] = [] # Our URL looks like this: # {schema}://{from_addr}:{client_id}/{client_secret}/{targets} # # which actually equates to: # {schema}://{user}@{host}/{client_id}/{client_secret} # /{email1}/{email2}/etc.. # ^ ^ # | | # -from addr- if "from" in results["qsd"]: results["from_addr"] = NotifySendPulse.unquote( results["qsd"]["from"].rstrip() ) if is_email(results["from_addr"]): # Our hostname is free'd up to be interpreted as part of the # targets results["targets"].append( NotifySendPulse.unquote(results["host"]) ) results["host"] = "" if "user" in results["qsd"] and is_email( NotifySendPulse.unquote(results["user"]) ): # Our hostname is free'd up to be interpreted as part of the # targets results["targets"].append(NotifySendPulse.unquote(results["host"])) results["host"] = "" # Get our potential email targets # First 2 elements are the client_id and client_secret results["targets"] += NotifySendPulse.split_path(results["fullpath"]) # check for our client id if "id" in results["qsd"] and len(results["qsd"]["id"]): # Store our Client ID results["client_id"] = NotifySendPulse.unquote( results["qsd"]["id"] ) elif results["targets"]: # Store our Client ID results["client_id"] = results["targets"].pop(0) if "secret" in results["qsd"] and len(results["qsd"]["secret"]): # Store our Client Secret results["client_secret"] = NotifySendPulse.unquote( results["qsd"]["secret"] ) elif results["targets"]: # Store our Client Secret results["client_secret"] = results["targets"].pop(0) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append( NotifySendPulse.unquote(results["qsd"]["to"]) ) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = NotifySendPulse.unquote(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = NotifySendPulse.unquote(results["qsd"]["bcc"]) # Handle Blind Carbon Copy Addresses if "template" in results["qsd"] and len(results["qsd"]["template"]): results["template"] = NotifySendPulse.unquote( results["qsd"]["template"] ) # Add any template substitutions results["template_data"] = results["qsd+"] return results apprise-1.10.0/apprise/plugins/serverchan.py000066400000000000000000000136331517341665700211170ustar00rootroot00000000000000# 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 NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase # Register at https://sct.ftqq.com/ # - do as the page describe and you will get the token # Syntax: # schan://{access_token}/ class NotifyServerChan(NotifyBase): """A wrapper for ServerChan Notifications.""" # The default descriptive name associated with the Notification service_name = "ServerChan" # The services URL service_url = "https://sct.ftqq.com/" # All notification requests are secure secure_protocol = "schan" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/serverchan/" # ServerChan API notify_url = "https://sctapi.ftqq.com/{token}.send" # Define object templates templates = ("{schema}://{token}",) # 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"), }, }, ) def __init__(self, token, **kwargs): """Initialize ServerChan Object.""" super().__init__(**kwargs) # Token (associated with project) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"An invalid ServerChan API Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform ServerChan Notification.""" payload = { "title": title, "desp": body, } # Our Notification URL notify_url = self.notify_url.format(token=self.token) # Some Debug Logging self.logger.debug( "ServerChan URL:" f" {notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"ServerChan Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=payload, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyServerChan.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send ServerChan 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 ServerChan notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occured sending ServerChan notification." ) self.logger.debug(f"Socket Exception: {e!s}") 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.token) def url(self, privacy=False): """Returns the URL built dynamically based on specified arguments.""" return "{schema}://{token}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), ) @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 parse the URL return results pattern = "schan://([a-zA-Z0-9]+)/" + ( "?" if not url.endswith("/") else "" ) result = re.match(pattern, url) results["token"] = result.group(1) if result else "" return results apprise-1.10.0/apprise/plugins/ses.py000066400000000000000000001047321517341665700175520ustar00rootroot00000000000000# 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 Information: # - https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html # # AWS Credentials (access_key and secret_access_key) # - https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/\ # setup-credentials.html # - https://docs.aws.amazon.com/toolkit-for-eclipse/v1/user-guide/\ # setup-credentials.html # # Other systems write these credentials to: # - ~/.aws/credentials on Linux, macOS, or Unix # - C:\Users\USERNAME\.aws\credentials on Windows # # # To get A users access key ID and secret access key # # 1. Open the IAM console: https://console.aws.amazon.com/iam/home # 2. On the navigation menu, choose Users. # 3. Choose your IAM user name (not the check box). # 4. Open the Security credentials tab, and then choose: # Create Access key - Programmatic access # 5. To see the new access key, choose Show. Your credentials resemble # the following: # Access key ID: AKIAIOSFODNN7EXAMPLE # Secret access key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY # # To download the key pair, choose Download .csv file. Store the keys # The account requries this permssion to 'SES v2 : SendEmail' in order to # work # # To get the root users account (if you're logged in as that) you can # visit: https://console.aws.amazon.com/iam/home#/\ # security_credentials$access_key # # This information is vital to work with SES # To use/test the service, i logged into the portal via: # - https://portal.aws.amazon.com # # Go to the dashboard of the Amazon SES (Simple Email Service) # 1. You must have a verified identity; click on that option and create one # if you don't already have one. Until it's verified, you won't be able to # do the next step. # 2. From here you'll be able to retrieve your ARN associated with your # identity you want Apprise to send emails on behalf. It might look # something like: # arn:aws:ses:us-east-2:133216123003:identity/user@example.com # # This is your ARN (Amazon Record Name) # # import base64 from collections import OrderedDict from datetime import datetime, timezone from email.header import Header from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formataddr from hashlib import sha256 import hmac import re from urllib.parse import quote from xml.etree import ElementTree import requests from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_email, parse_emails, validate_regex from .base import NotifyBase # Our Regin Identifier # support us-gov-west-1 syntax as well IS_REGION = re.compile( r"^\s*(?P[a-z]{2})-(?P[a-z-]+?)-(?P[0-9]+)\s*$", re.I ) # Extend HTTP Error Messages AWS_HTTP_ERROR_MAP = { 403: "Unauthorized - Invalid Access/Secret Key Combination.", } class NotifySES(NotifyBase): """A wrapper for AWS SES (Amazon Simple Email Service)""" # The default descriptive name associated with the Notification service_name = "AWS Simple Email Service (SES)" # The services URL service_url = "https://aws.amazon.com/ses/" # The default secure protocol secure_protocol = "ses" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/ses/" # Support attachments attachment_support = True # AWS is pretty good for handling data load so request limits # can occur in much shorter bursts request_rate_per_sec = 2.5 # Default Notify Format notify_format = NotifyFormat.HTML # Define object templates templates = ( ( "{schema}://{from_email}/{access_key_id}/{secret_access_key}/" "{region}/{targets}" ), "{schema}://{from_email}/{access_key_id}/{secret_access_key}/{region}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "from_email": { "name": _("From Email"), "type": "string", "map_to": "from_addr", "required": True, }, "access_key_id": { "name": _("Access Key ID"), "type": "string", "private": True, "required": True, }, "secret_access_key": { "name": _("Secret Access Key"), "type": "string", "private": True, "required": True, }, "region": { "name": _("Region"), "type": "string", "regex": (r"^[a-z]{2}-[a-z-]+?-[0-9]+$", "i"), "required": True, "map_to": "region_name", }, "targets": { "name": _("Target Emails"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "from_email", }, "reply": { "name": _("Reply To Email"), "type": "string", "map_to": "reply_to", }, "name": { "name": _("From Name"), "type": "string", "map_to": "from_name", }, "access": { "alias_of": "access_key_id", }, "secret": { "alias_of": "secret_access_key", }, "region": { "alias_of": "region", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) def __init__( self, access_key_id, secret_access_key, region_name, reply_to=None, from_addr=None, from_name=None, targets=None, cc=None, bcc=None, **kwargs, ): """Initialize Notify AWS SES Object.""" super().__init__(**kwargs) # Store our AWS API Access Key self.aws_access_key_id = validate_regex(access_key_id) if not self.aws_access_key_id: msg = "An invalid AWS Access Key ID was specified." self.logger.warning(msg) raise TypeError(msg) # Store our AWS API Secret Access key self.aws_secret_access_key = validate_regex(secret_access_key) if not self.aws_secret_access_key: msg = ( "An invalid AWS Secret Access Key " f"({secret_access_key}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Acquire our AWS Region Name: # eg. us-east-1, cn-north-1, us-west-2, ... self.aws_region_name = validate_regex( region_name, *self.template_tokens["region"]["regex"] ) if not self.aws_region_name: msg = f"An invalid AWS Region ({region_name}) was specified." self.logger.warning(msg) raise TypeError(msg) # Acquire Email 'To' self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # For tracking our email -> name lookups self.names = {} # Set our notify_url based on our region self.notify_url = f"https://email.{self.aws_region_name}.amazonaws.com" # AWS Service Details self.aws_service_name = "ses" self.aws_canonical_uri = "/" # AWS Authentication Details self.aws_auth_version = "AWS4" self.aws_auth_algorithm = "AWS4-HMAC-SHA256" self.aws_auth_request = "aws4_request" # Get our From username (if specified) self.from_name = from_name if from_addr: self.from_addr = from_addr else: # Get our from email address self.from_addr = f"{self.user}@{self.host}" if self.user else None if not (self.from_addr and is_email(self.from_addr)): msg = "An invalid AWS From ({}) was specified.".format( f"{self.user}@{self.host}" ) self.logger.warning(msg) raise TypeError(msg) self.reply_to = None if reply_to: result = is_email(reply_to) if not result: msg = "An invalid AWS 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"], ) 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( f"Dropped invalid To email ({recipient}) specified.", ) else: # If our target email list is empty we want to add ourselves to it self.targets.append( (self.from_name if self.from_name else False, self.from_addr) ) # 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( f"Dropped invalid Carbon Copy email ({recipient}) specified.", ) # 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 " f"({recipient}) specified.", ) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Wrapper to send_notification since we can alert more then one channel.""" if not self.targets: # There is no one to email; we're done self.logger.warning("There are no SES email recipients to notify") return False # error tracking (used for function return) has_error = False # Initialize our default from name from_name = ( self.from_name if self.from_name else ( self.reply_to[0] if self.reply_to and self.reply_to[0] else self.app_desc ) ) reply_to = ( from_name, self.from_addr if not self.reply_to else self.reply_to[1], ) # Create a copy of the targets list emails = list(self.targets) 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 = self.cc - self.bcc - {to_addr} # Strip target out of bcc list if in To bcc = self.bcc - {to_addr} # Format our cc addresses to support the Name field cc = [ formataddr( (self.names.get(addr, False), addr), charset="utf-8" ) for addr in cc ] # Format our bcc addresses to support the Name field bcc = [ formataddr( (self.names.get(addr, False), addr), charset="utf-8" ) for addr in bcc ] self.logger.debug( "Email From: {} <{}>".format( quote(reply_to[0], " "), quote(reply_to[1], "@ ") ) ) self.logger.debug(f"Email To: {to_addr}") if cc: self.logger.debug("Email Cc: {}".format(", ".join(cc))) if bcc: self.logger.debug("Email Bcc: {}".format(", ".join(bcc))) # Prepare Email Message if self.notify_format == NotifyFormat.HTML: content = MIMEText(body, "html", "utf-8") else: content = MIMEText(body, "plain", "utf-8") # Create a Multipart container if there is an attachment base = ( MIMEMultipart() if attach and self.attachment_support else content ) # TODO: Deduplicate with `NotifyEmail`? base["Subject"] = Header(title, "utf-8") base["From"] = formataddr( (from_name if from_name else False, self.from_addr), charset="utf-8", ) base["To"] = formataddr((to_name, to_addr), charset="utf-8") if reply_to[1] != self.from_addr: base["Reply-To"] = formataddr(reply_to, charset="utf-8") base["Cc"] = ",".join(cc) base["Date"] = datetime.now(timezone.utc).strftime( "%a, %d %b %Y %H:%M:%S +0000" ) base["X-Application"] = self.app_id if attach and self.attachment_support: # First attach our body to our content as the first element base.attach(content) # 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 self.logger.error( "Could not access attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Preparing Email attachment" f" {attachment.url(privacy=True)}" ) with open(attachment.path, "rb") as abody: app = MIMEApplication(abody.read()) app.set_type(attachment.mimetype) filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) app.add_header( "Content-Disposition", 'attachment; filename="{}"'.format( Header(filename, "utf-8") ), ) base.attach(app) # Prepare our payload object payload = { "Action": "SendRawEmail", "Version": "2010-12-01", "RawMessage.Data": ( base64.b64encode(base.as_string().encode("utf-8")).decode( "utf-8" ) ), } for no, email in enumerate(([to_addr, *bcc, *cc]), start=1): payload[f"Destinations.member.{no}"] = email # Specify from address payload["Source"] = "{} <{}>".format( quote(from_name, " "), quote(self.from_addr, "@ ") ) (result, _response) = self._post(payload=payload, to=to_addr) if not result: # Mark our failure has_error = True continue return not has_error def _post(self, payload, to): """Wrapper to request.post() to manage it's response better and make the send() function cleaner and easier to maintain. This function returns True if the _post was successful and False if it wasn't. """ # Always call throttle before any remote server i/o is made; for AWS # time plays a huge factor in the headers being sent with the payload. # So for AWS (SES) requests we must throttle before they're generated # and not directly before the i/o call like other notification # services do. self.throttle() # Convert our payload from a dict() into a urlencoded string payload = NotifySES.urlencode(payload) # Prepare our Notification URL # Prepare our AWS Headers based on our payload headers = self.aws_prepare_request(payload) self.logger.debug( "AWS SES POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug("AWS SES Payload (%d bytes)", len(payload)) try: r = requests.post( self.notify_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifySES.http_response_code_lookup( r.status_code, AWS_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send AWS SES notification to {}: " "{}{}error={}.".format( to, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return (False, NotifySES.aws_response_to_dict(r.text)) else: self.logger.info(f'Sent AWS SES notification to "{to}".') except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending AWS SES " f'notification to "{to}".', ) self.logger.debug(f"Socket Exception: {e!s}") return (False, NotifySES.aws_response_to_dict(None)) return (True, NotifySES.aws_response_to_dict(r.text)) def aws_prepare_request(self, payload, reference=None): """Takes the intended payload and returns the headers for it. The payload is presumed to have been already urlencoded() """ # Define our AWS SES header headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", # Populated below "Content-Length": 0, "Authorization": None, "X-Amz-Date": None, } # Get a reference time (used for header construction) reference = datetime.now(timezone.utc) # Provide Content-Length headers["Content-Length"] = str(len(payload)) # Amazon Date Format amzdate = reference.strftime("%Y%m%dT%H%M%SZ") headers["X-Amz-Date"] = amzdate # Credential Scope scope = "{date}/{region}/{service}/{request}".format( date=reference.strftime("%Y%m%d"), region=self.aws_region_name, service=self.aws_service_name, request=self.aws_auth_request, ) # Similar to headers; but a subset. keys must be lowercase signed_headers = OrderedDict( [ ("content-type", headers["Content-Type"]), ("host", f"email.{self.aws_region_name}.amazonaws.com"), ("x-amz-date", headers["X-Amz-Date"]), ] ) # # Build Canonical Request Object # canonical_request = "\n".join( [ # Method "POST", # URL self.aws_canonical_uri, # Query String (none set for POST) "", # Header Content (must include \n at end!) # All entries except characters in amazon date must be # lowercase "\n".join([f"{k}:{v}" for k, v in signed_headers.items()]) + "\n", # Header Entries (in same order identified above) ";".join(signed_headers.keys()), # Payload sha256(payload.encode("utf-8")).hexdigest(), ] ) # Prepare Unsigned Signature to_sign = "\n".join( [ self.aws_auth_algorithm, amzdate, scope, sha256(canonical_request.encode("utf-8")).hexdigest(), ] ) # Our Authorization header headers["Authorization"] = ", ".join( [ ( f"{self.aws_auth_algorithm} " f"Credential={self.aws_access_key_id}/{scope}" ), "SignedHeaders={signed_headers}".format( signed_headers=";".join(signed_headers.keys()), ), f"Signature={self.aws_auth_signature(to_sign, reference)}", ] ) return headers def aws_auth_signature(self, to_sign, reference): """Generates a AWS v4 signature based on provided payload which should be in the form of a string.""" def _sign(key, msg, to_hex=False): """Perform AWS Signing.""" if to_hex: return hmac.new(key, msg.encode("utf-8"), sha256).hexdigest() return hmac.new(key, msg.encode("utf-8"), sha256).digest() date = _sign( (self.aws_auth_version + self.aws_secret_access_key).encode( "utf-8" ), reference.strftime("%Y%m%d"), ) region = _sign(date, self.aws_region_name) service = _sign(region, self.aws_service_name) signed = _sign(service, self.aws_auth_request) return _sign(signed, to_sign, to_hex=True) @staticmethod def aws_response_to_dict(aws_response): """Takes an AWS Response object as input and returns it as a dictionary but not befor extracting out what is useful to us first. eg: IN: 010f017d87656ee2-a2ea291f-79ea- 44f3-9d25-00d041de3007-000000 7abb454e-904b-4e46-a23c-2f4d2fc127a6 OUT: { 'type': 'SendRawEmailResponse', 'message_id': '010f017d87656ee2-a2ea291f-79ea- 44f3-9d25-00d041de3007-000000', 'request_id': '7abb454e-904b-4e46-a23c-2f4d2fc127a6', } """ # Define ourselves a set of directives we want to keep if found and # then identify the value we want to map them to in our response # object aws_keep_map = { "RequestId": "request_id", "MessageId": "message_id", # Error Message Handling "Type": "error_type", "Code": "error_code", "Message": "error_message", } # A default response object that we'll manipulate as we pull more data # from our AWS Response object response = { "type": None, "request_id": None, "message_id": None, } try: # we build our tree, but not before first eliminating any # reference to namespacing (if present) as it makes parsing # the tree so much easier. root = ElementTree.fromstring( re.sub(r' xmlns="[^"]+"', "", aws_response, count=1) ) # Store our response tag object name response["type"] = str(root.tag) def _xml_iter(root, response): if len(root) > 0: for child in root: # use recursion to parse everything _xml_iter(child, response) elif root.tag in aws_keep_map: response[aws_keep_map[root.tag]] = (root.text).strip() # Recursivly iterate over our AWS Response to extract the # fields we're interested in in efforts to populate our response # object. _xml_iter(root, response) except (ElementTree.ParseError, TypeError): # bad data just causes us to generate a bad response pass return response @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.from_addr, self.aws_access_key_id, self.aws_secret_access_key, self.aws_region_name, ) 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) if self.from_name is not None: # from_name specified; pass it back on the url params["name"] = self.from_name if self.cc: # Handle our Carbon Copy Addresses params["cc"] = ",".join( [ "{}{}".format( "" if not e not in self.names else f"{self.names[e]}:", e, ) for e in self.cc ] ) if self.bcc: # 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][1] == self.from_addr ) return ( "{schema}://{from_addr}/{key_id}/{key_secret}/{region}/" "{targets}/?{params}".format( schema=self.secure_protocol, from_addr=NotifySES.quote(self.from_addr, safe="@"), key_id=self.pprint(self.aws_access_key_id, privacy, safe=""), key_secret=self.pprint( self.aws_secret_access_key, privacy, mode=PrivacyMode.Secret, safe="", ), region=NotifySES.quote(self.aws_region_name, safe=""), targets=( "" if not has_targets else "/".join( [ NotifySES.quote( "{}{}".format( "" if not e[0] else f"{e[0]}:", e[1] ), safe="", ) for e in self.targets ] ) ), params=NotifySES.urlencode(params), ) ) 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 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 entries = NotifySES.split_path(results["fullpath"]) # The AWS Access Key ID is stored in the first entry access_key_id = entries.pop(0) if entries else None # Our AWS Access Key Secret contains slashes in it which unfortunately # means it is of variable length after the hostname. Since we require # that the user provides the region code, we intentionally use this # as our delimiter to detect where our Secret is. secret_access_key = None region_name = None # We need to iterate over each entry in the fullpath and find our # region. Once we get there we stop and build our secret from our # accumulated data. secret_access_key_parts = [] # Section 1: Get Region and Access Secret index = 0 for index, entry in enumerate(entries, start=1): # Are we at the region yet? result = IS_REGION.match(entry) if result: # Ensure region is nicely formatted region_name = "{country}-{area}-{no}".format( country=result.group("country").lower(), area=result.group("area").lower(), no=result.group("no"), ) # We're done with Section 1 of our url (the credentials) break elif is_email(entry): # We're done with Section 1 of our url (the credentials) index -= 1 break # Store our secret parts secret_access_key_parts.append(entry) # Prepare our Secret Access Key secret_access_key = ( "/".join(secret_access_key_parts) if secret_access_key_parts else None ) # Section 2: Get our Recipients (basically all remaining entries) results["targets"] = entries[index:] if "name" in results["qsd"] and len(results["qsd"]["name"]): # Extract from name to associate with from address results["from_name"] = NotifySES.unquote(results["qsd"]["name"]) # Handle 'to' email address if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append(results["qsd"]["to"]) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = NotifySES.parse_list(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = NotifySES.parse_list(results["qsd"]["bcc"]) # Handle From Address handling if "from" in results["qsd"] and len(results["qsd"]["from"]): results["from_addr"] = NotifySES.unquote(results["qsd"]["from"]) # Handle Reply To Address if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = NotifySES.unquote(results["qsd"]["reply"]) # Handle secret_access_key over-ride if "secret" in results["qsd"] and len(results["qsd"]["secret"]): results["secret_access_key"] = NotifySES.unquote( results["qsd"]["secret"] ) else: results["secret_access_key"] = secret_access_key # Handle access key id over-ride if "access" in results["qsd"] and len(results["qsd"]["access"]): results["access_key_id"] = NotifySES.unquote( results["qsd"]["access"] ) else: results["access_key_id"] = access_key_id # Handle region name id over-ride if "region" in results["qsd"] and len(results["qsd"]["region"]): results["region_name"] = NotifySES.unquote( results["qsd"]["region"] ) else: results["region_name"] = region_name # Return our result set return results apprise-1.10.0/apprise/plugins/seven.py000066400000000000000000000303321517341665700200720ustar00rootroot00000000000000# 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. # Create an account https://www.seven.io if you don't already have one # # Get your (apikey) from here: # - https://help.seven.io/en/api-key-access # import json import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase class NotifySeven(NotifyBase): """A wrapper for seven Notifications.""" # The default descriptive name associated with the Notification service_name = "seven" # The services URL service_url = "https://www.seven.io" # The default protocol secure_protocol = "seven" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/seven/" # Seven uses the http protocol with JSON requests notify_url = "https://gateway.seven.io/api/sms" # 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}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "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", }, "flash": { "name": _("Flash"), "type": "bool", "default": False, }, "label": {"name": _("Label"), "type": "string"}, "to": { "alias_of": "targets", }, }, ) def __init__( self, apikey, targets=None, source=None, flash=None, label=None, **kwargs, ): """Initialize Seven Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = apikey if not self.apikey: msg = f"An invalid seven API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) self.source = None if not isinstance(source, str) else source.strip() self.flash = ( self.template_args["flash"]["default"] if flash is None else bool(flash) ) self.label = None if not isinstance(label, str) else label.strip() # 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 @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, self.apikey) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform seven Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no seven targets to notify.") return False # error tracking (used for function return) has_error = False # Prepare our headers headers = { "Accept": "application/json", "Content-Type": "application/json", "SentWith": "Apprise", "X-Api-Key": self.apikey, } # Prepare our payload payload = { "to": None, "text": body, } if self.source: payload["from"] = self.source if self.flash: payload["flash"] = self.flash if self.label: payload["label"] = self.label # Create a copy of the targets list targets = list(self.targets) while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["to"] = f"+{target}" # Some Debug Logging self.logger.debug( "seven POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"seven 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, verify=self.verify_certificate, timeout=self.request_timeout, ) # Sample output of a successful transmission # { # "success": "100", # "total_price": 0.075, # "balance": 46.748, # "debug": "false", # "sms_type": "direct", # "messages": [ # { # "id": "77229135982", # "sender": "492022839080", # "recipient": "4917661254799", # "text": "x", # "encoding": "gsm", # "label": null, # "parts": 1, # "udh": null, # "is_binary": false, # "price": 0.075, # "success": true, # "error": null, # "error_text": null # } # ] # } if r.status_code not in ( requests.codes.ok, requests.codes.created, ): # We had a problem status_str = NotifySeven.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send seven notification to {}: " "{}{}error={}.".format( ",".join(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 seven notification to {target}.") except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending seven:{target} " + "notification." ) 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.""" params = { "flash": "yes" if self.flash else "no", } if self.source: params["from"] = self.source if self.label: params["label"] = self.label # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{apikey}/{targets}/?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [NotifySeven.quote(x, safe="") for x in self.targets] ), params=NotifySeven.urlencode(params), ) 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 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"] = NotifySeven.split_path(results["fullpath"]) # The hostname is our authentication key results["apikey"] = NotifySeven.unquote(results["host"]) # 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"] += NotifySeven.parse_phone_no( results["qsd"]["to"] ) # Support the 'from' and source variable if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifySeven.unquote(results["qsd"]["from"]) elif "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifySeven.unquote(results["qsd"]["source"]) results["flash"] = parse_bool(results["qsd"].get("flash", False)) results["label"] = results["qsd"].get("label", None) return results apprise-1.10.0/apprise/plugins/sfr.py000066400000000000000000000356201517341665700175510ustar00rootroot00000000000000# 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 have a valid SFR DMC service account # to whicthe API password can be generated. A "space" is also necessary # (space = a logical separation between clients), which will give you a # specific spaceId # # Expected credentials looks a little like this: # serviceId: 84920958892 - Random numbers # servicePassword: XxXXxXXx - Random characters # spaceId: 984348 - Random numbers # # 1. Visit https://www.sfr.fr/ # # 2. Url will look like this # https://www.dmc.sfr-sh.fr/DmcWS/1.5.8/JsonService// 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_phone_no from .base import NotifyBase class NotifySFR(NotifyBase): """A wrapper for SFR French Telecom DMC API.""" # The default descriptive name associated with the Notification service_name = _("SociΓ©tΓ© FranΓ§aise du RadiotΓ©lΓ©phone") # The services URL service_url = "https://www.sfr.fr/" # The default protocol protocol = "sfr" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/sfr/" # SFR api notify_url = ( "https://www.dmc.sfr-sh.fr/DmcWS/1.5.8/JsonService/" "MessagesUnitairesWS/addSingleCall" # this is the actual api call ) # 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}://{user}:{password}@{space_id}/{targets}",) # Define our tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("Service ID"), "type": "string", "required": True, }, "password": { "name": _("Service Password"), "type": "string", "private": True, "required": True, }, "space_id": { "name": _("Space ID"), "type": "string", "private": True, "required": True, }, "target": { "name": _("Recipient Phone Number"), "type": "string", "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, **{ "lang": { "name": _("Language"), "type": "string", "default": "fr_FR", "required": True, }, "sender": { "name": _("Sender Name"), "type": "string", "required": True, "default": "", }, "from": {"alias_of": "sender"}, "media": { "name": _("Media Type"), "type": "string", "required": True, "default": "SMSUnicode", "values": ["SMS", "SMSLong", "SMSUnicode", "SMSUnicodeLong"], }, "timeout": { "name": _("Timeout"), "type": "int", "default": 2880, "required": False, }, "voice": { "name": _("TTS Voice"), "type": "string", "default": "claire08s", "values": ["claire08s", "laura8k"], "required": False, }, "to": { "alias_of": "targets", }, }, ) def __init__( self, space_id=None, targets=None, lang=None, sender=None, media=None, timeout=None, voice=None, **kwargs, ): """Initialize SFR Object.""" super().__init__(**kwargs) if not (self.user and self.password): msg = ( "A SFR user (serviceId) and password (servicePassword) " "combination was not provided." ) self.logger.warning(msg) raise TypeError(msg) self.space_id = space_id if not self.space_id: msg = "A SFR Space ID is required." self.logger.warning(msg) raise TypeError(msg) self.voice = voice if voice else self.template_args["voice"]["default"] self.lang = lang if lang else self.template_args["lang"]["default"] self.media = media if media else self.template_args["media"]["default"] self.sender = ( sender if sender else self.template_args["sender"]["default"] ) # Set our Time to Live Flag self.timeout = self.template_args["timeout"]["default"] try: self.timeout = int(timeout) except (ValueError, TypeError): # set default timeout self.timeout = 2880 pass # 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"]) if not self.targets: msg = ( "No receiver phone number has been provided. Please " "provide as least one valid phone number." ) self.logger.warning(msg) raise TypeError(msg) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform the SFR notification.""" # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) # Construct the authentication JSON auth_payload = json.dumps( { "serviceId": self.user, "servicePassword": self.password, "spaceId": self.space_id, "lang": self.lang, } ) base_payload = { # Can be 'SMS', 'SMSLong', 'SMSUnicode', or 'SMSUnicodeLong' "media": self.media, # Content of the message "textMsg": body, # Receiver's phone number (set below) "to": None, # Optional, default to '' "from": self.sender, # Optional, default 2880 minutes "timeout": self.timeout, # Optional, default to French voice "ttsVoice": self.voice, } while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our target phone no base_payload["to"] = target # Always call throttle before any remote server i/o is made self.throttle() # Finalize our payload payload = { "authenticate": auth_payload, "messageUnitaire": json.dumps(base_payload, ensure_ascii=True), } # Some Debug Logging self.logger.debug( "SFR POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"SFR Payload: {payload}") try: r = requests.post( self.notify_url, params=payload, verify=self.verify_certificate, timeout=self.request_timeout, ) try: content = json.loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} # Check if the request was successfull if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifySFR.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send SFR 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 # SFR returns a code 200 even if the authentication fails # It then indicates in the content['success'] field the # Actual state of the transaction if not content.get("success", False): self.logger.warning( "SFR Notification to {} was not sent by the server: " "server_error={}, fatal={}.".format( target, content.get("errorCode", "UNKNOWN"), content.get("fatal", "True"), ) ) # Mark our failure has_error = True continue self.logger.info(f"Sent SFR notification to {target}.") except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending SFR:{target} " "notification." ) 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 if self.secure else self.protocol, self.user, self.password, self.space_id, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "from": self.sender, "timeout": str(self.timeout), "voice": self.voice, "lang": self.lang, "media": self.media, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{user}:{password}@{sid}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, user=self.user, password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="", ), sid=self.pprint(self.space_id, privacy, safe=""), targets="/".join( [NotifySFR.quote(x, safe="") for x in self.targets] ), params=self.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.targets) @staticmethod def parse_url(url): """Parse the URL and return arguments required to initialize this plugin.""" # NotifyBase.parse_url() will make the initial parsing of your string # very easy to use. It will tokenize the entire URL for you. The # tokens are then passed into your __init__() function you defined to # generate you're 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 # Extract user and password results["space_id"] = results.get("host") results["targets"] = NotifySFR.split_path(results["fullpath"]) # Extract additional parameters qsd = results.get("qsd", {}) results["sender"] = NotifySFR.unquote( qsd.get("sender", qsd.get("from")) ) results["timeout"] = NotifySFR.unquote(qsd.get("timeout")) results["voice"] = NotifySFR.unquote(qsd.get("voice")) results["lang"] = NotifySFR.unquote(qsd.get("lang")) results["media"] = NotifySFR.unquote(qsd.get("media")) # 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"] += NotifySFR.parse_phone_no( results["qsd"]["to"] ) return results apprise-1.10.0/apprise/plugins/signal_api.py000066400000000000000000000444241517341665700210670ustar00rootroot00000000000000# 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 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 is_phone_no, parse_bool, parse_phone_no from ..utils.sanitize import sanitize_payload from .base import NotifyBase, NotifyFormat GROUP_REGEX = re.compile( r"^\s*((\@|\%40)?(group\.)|\@|\%40)(?P[a-z0-9_=-]+)", re.I ) class NotifySignalAPI(NotifyBase): """A wrapper for SignalAPI Notifications.""" # The default descriptive name associated with the Notification service_name = "Signal API" # The services URL service_url = "https://bbernhard.github.io/signal-cli-rest-api/" # The default protocol protocol = "signal" # The default protocol secure_protocol = "signals" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/signal/" # Support attachments attachment_support = True # The maximum targets to include when doing batch transfers default_batch_size = 10 # We don't support titles for Signal notifications title_maxlen = 0 # Define object templates templates = ( "{schema}://{host}/{from_phone}", "{schema}://{host}:{port}/{from_phone}", "{schema}://{user}@{host}/{from_phone}", "{schema}://{user}@{host}:{port}/{from_phone}", "{schema}://{user}:{password}@{host}/{from_phone}", "{schema}://{user}:{password}@{host}:{port}/{from_phone}", "{schema}://{host}/{from_phone}/{targets}", "{schema}://{host}:{port}/{from_phone}/{targets}", "{schema}://{user}@{host}/{from_phone}/{targets}", "{schema}://{user}@{host}:{port}/{from_phone}/{targets}", "{schema}://{user}:{password}@{host}/{from_phone}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{from_phone}/{targets}", ) # Define our template tokens 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, }, "from_phone": { "name": _("From Phone No"), "type": "string", "required": True, "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "target_channel": { "name": _("Target Group ID"), "type": "string", "prefix": "@", "regex": (r"^[a-z0-9_=-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "from_phone", }, "status": { "name": _("Show Status"), "type": "bool", "default": False, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, source=None, targets=None, batch=False, status=False, **kwargs ): """Initialize SignalAPI Object.""" super().__init__(**kwargs) # Prepare Batch Mode Flag self.batch = batch # Set Status type self.status = status # Parse our targets self.targets = [] # Used for URL generation afterwards only self.invalid_targets = [] # Manage our Source Phone result = is_phone_no(source) if not result: msg = ( "An invalid Signal API Source Phone No " f"({source}) was provided." ) self.logger.warning(msg) raise TypeError(msg) self.source = "+{}".format(result["full"]) if targets: # Validate our targerts for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if result: # store valid phone number self.targets.append("+{}".format(result["full"])) continue result = GROUP_REGEX.match(target) if result: # Just store group information self.targets.append( "group.{}".format(result.group("group")) ) continue self.logger.warning( f"Dropped invalid phone/group ({target}) specified.", ) self.invalid_targets.append(target) continue else: # Send a message to ourselves self.targets.append(self.source) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Signal API Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no Signal API targets to notify.") return False # error tracking (used for function return) has_error = False attachments = [] if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Signal API attachment" f" {attachment.url(privacy=True)}." ) return False try: attachments.append(attachment.base64()) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Signal API attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Signal API attachment" f" {attachment.url(privacy=True)}" ) # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Support Styled (Markdown formatting) text_mode = ( "styled" if self.notify_format == NotifyFormat.MARKDOWN else "normal" ) # Format defined here: # https://bbernhard.github.io/signal-cli-rest-api\ # /#/Messages/post_v2_send # Example: # { # "base64_attachments": [ # "string" # ], # "message": "string", # "number": "string", # "recipients": [ # "string" # ] # } # Prepare our payload payload = { "message": ( "{}{}".format( ( "" if not self.status else f"{self.asset.ascii(notify_type)} " ), body, ).rstrip() ), "number": self.source, "text_mode": text_mode, "recipients": [], } if attachments: # Store our attachments payload["base64_attachments"] = attachments # Determine Authentication auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" # Construct our URL notify_url = f"{schema}://{self.host}" if isinstance(self.port, int): notify_url += f":{self.port}" notify_url += "/v2/send" # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size for index in range(0, len(self.targets), batch_size): # Prepare our recipients payload["recipients"] = self.targets[index : index + batch_size] # 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( "Signal API POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) log_payload = dict(payload) log_payload.pop("recipients", None) self.logger.debug( "Signal API Payload: %s", sanitize_payload(log_payload) ) self.logger.debug( "Signal API Recipients: %s", payload.get("recipients", []), ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, auth=auth, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.created, ): # We had a problem status_str = NotifySignalAPI.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send {} Signal API notification{}: " "{}{}error={}.".format( len(self.targets[index : index + batch_size]), ( f" to {self.targets[index]}" if 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 {} Signal API notification{}.".format( len(self.targets[index : index + batch_size]), ( f" to {self.targets[index]}" if batch_size == 1 else "(s)" ), ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occured sending" f" {len(self.targets[index : index + batch_size])} Signal" " API notification(s)." ) 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 if self.secure else self.protocol, self.user, self.password, self.host, self.port, self.source, ) 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", "status": "yes" if self.status else "no", } # 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=NotifySignalAPI.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifySignalAPI.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 # So we can strip out our own phone (if present); create a copy of our # targets if len(self.targets) == 1 and self.source in self.targets: targets = [] elif len(self.targets) == 0: # invalid phone-no were specified targets = self.invalid_targets else: # append @ to non-phone number entries as they are groups # Remove group. prefix as well targets = [f"@{x[6:]}" if x[0] != "+" else x for x in self.targets] return "{schema}://{auth}{hostname}{port}/{src}/{dst}?{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}" ), src=self.source, dst="/".join( [NotifySignalAPI.quote(x, safe="@+") for x in targets] ), params=NotifySignalAPI.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 @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"] = NotifySignalAPI.split_path(results["fullpath"]) # The hostname is our authentication key results["apikey"] = NotifySignalAPI.unquote(results["host"]) if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifySignalAPI.unquote(results["qsd"]["from"]) elif results["targets"]: # The from phone no is the first entry in the list otherwise results["source"] = results["targets"].pop(0) # 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"] += NotifySignalAPI.parse_phone_no( results["qsd"]["to"] ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) # Get status switch results["status"] = parse_bool(results["qsd"].get("status", False)) return results apprise-1.10.0/apprise/plugins/signl4.py000066400000000000000000000264051517341665700201600ustar00rootroot00000000000000# 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 Refererence: # - https://docs.signl4.com/integrations/webhook/webhook.html # from json import dumps from typing import Any, Optional import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool, validate_regex from .base import NotifyBase class NotifySIGNL4(NotifyBase): """ A wrapper for SIGNL4 Notifications """ # The default descriptive name associated with the Notification service_name = "SIGNL4" # The services URL service_url = "https://signl4.com/" # Secure Protocol secure_protocol = "signl4" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/signl4/" # Our event action type event_action = "trigger" # Our default notification URL notify_url = "https://connect.signl4.com/webhook/{secret}/" # Define object templates templates = ("{schema}://{secret}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ # SIGNL4 team or integration secret "secret": { "name": _("Secret"), "type": "string", "private": True, "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "service": { "name": _("Service"), "type": "string", }, "location": { "name": _("Location"), "type": "string", }, "alerting_scenario": { "name": _("Alerting Scenario"), "type": "string", }, "filtering": { "name": _("Filtering"), "type": "bool", "default": False, }, "external_id": { "name": _("External ID"), "type": "string", }, "status": { "name": _("Status"), "type": "string", }, }, ) def __init__( self, secret: str, service: Optional[str] = None, location: Optional[str] = None, alerting_scenario: Optional[str] = None, filtering: Optional[bool] = None, external_id: Optional[str] = None, status: Optional[str] = None, **kwargs: Any, ) -> None: """ Initialize SIGNL4 Object """ super().__init__(**kwargs) # SIGNL4 team or integration secret self.secret = validate_regex(secret) if not self.secret: msg = ( "An invalid SIGNL4 team or integration secret " "({}) was specified.".format(secret) ) self.logger.warning(msg) raise TypeError(msg) # A service option for notifications self.service = service # A location option for notifications self.location = location # A alerting_scenario option for notifications self.alerting_scenario = alerting_scenario # A filtering option for notifications self.filtering = ( self.template_args["filtering"]["default"] if filtering is None else bool(filtering) ) # A external_id option for notifications self.external_id = external_id # A location option for notifications self.status = status return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """ Send our SIGNL4 Notification """ # Prepare our headers headers = { "Content-Type": "application/json", } # Prepare our persistent_notification.create payload payload = { "title": title if title else self.app_desc, "body": body, "X-S4-SourceSystem": self.app_id, } if self.service: payload["X-S4-Service"] = self.service if self.alerting_scenario: payload["X-S4-AlertingScenario"] = self.alerting_scenario if self.location: payload["X-S4-Location"] = self.location if self.filtering: payload["X-S4-Filtering"] = self.filtering if self.external_id: payload["X-S4-ExternalID"] = self.external_id if self.status: payload["X-S4-Status"] = self.status # Prepare our URL notify_url = self.notify_url.format(secret=self.secret) self.logger.debug( "SIGNL4 POST URL: %s (cert_verify=%s)", notify_url, self.verify_certificate, ) self.logger.debug("SIGNL4 Payload: %r", 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, ) if r.status_code not in ( requests.codes.ok, requests.codes.created, requests.codes.accepted, ): # We had a problem status_str = NotifySIGNL4.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send SIGNL4 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 SIGNL4 notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending SIGNL4 " "notification to %s", self.host, ) self.logger.debug("Socket Exception: %s", e) # 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, self.secret, ) def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ # Define any URL parameters params = {} if self.service is not None: params["service"] = self.service if self.location is not None: params["location"] = self.location if self.alerting_scenario is not None: params["alerting_scenario"] = self.alerting_scenario if self.filtering != self.template_args["filtering"]["default"]: # Only add filtering if it is not the default value params["filtering"] = "yes" if self.filtering else "no" if self.external_id is not None: params["external_id"] = self.external_id if self.status is not None: params["status"] = self.status # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) url = "{schema}://{secret}" return url.format( schema=self.secure_protocol, # never encode hostname since we're expecting it to be a valid one secret=self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ), params=NotifySIGNL4.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 # The "secret" makes it easier to use yaml configuration if "secret" in results["qsd"] and len(results["qsd"]["secret"]): results["secret"] = NotifySIGNL4.unquote(results["qsd"]["secret"]) else: results["secret"] = NotifySIGNL4.unquote(results["host"]) if "service" in results["qsd"] and len(results["qsd"]["service"]): results["service"] = NotifySIGNL4.unquote( results["qsd"]["service"] ) if "location" in results["qsd"] and len(results["qsd"]["location"]): results["location"] = NotifySIGNL4.unquote( results["qsd"]["location"] ) if "alerting_scenario" in results["qsd"] and len( results["qsd"]["alerting_scenario"] ): results["alerting_scenario"] = NotifySIGNL4.unquote( results["qsd"]["alerting_scenario"] ) if "filtering" in results["qsd"] and len(results["qsd"]["filtering"]): results["filtering"] = parse_bool( NotifySIGNL4.unquote( results["qsd"]["filtering"], NotifySIGNL4.template_args["filtering"]["default"], ) ) if "external_id" in results["qsd"] and len( results["qsd"]["external_id"] ): results["external_id"] = NotifySIGNL4.unquote( results["qsd"]["external_id"] ) if "status" in results["qsd"] and len(results["qsd"]["status"]): results["status"] = NotifySIGNL4.unquote(results["qsd"]["status"]) return results apprise-1.10.0/apprise/plugins/simplepush.py000066400000000000000000000303121517341665700211410ustar00rootroot00000000000000# 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 base64 import urlsafe_b64encode import hashlib from json import loads from os import urandom import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import validate_regex from .base import NotifyBase try: from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import ( Cipher, algorithms, modes, ) # We're good to go! NOTIFY_SIMPLEPUSH_ENABLED = True except ImportError: # cryptography is required in order for this package to work NOTIFY_SIMPLEPUSH_ENABLED = False class NotifySimplePush(NotifyBase): """A wrapper for SimplePush Notifications.""" # Set our global enabled flag enabled = NOTIFY_SIMPLEPUSH_ENABLED requirements = { # Define our required packaging in order to work "packages_required": "cryptography" } # The default descriptive name associated with the Notification service_name = "SimplePush" # The services URL service_url = "https://simplepush.io/" # The default secure protocol secure_protocol = "spush" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/simplepush/" # SimplePush uses the http protocol with SimplePush requests notify_url = "https://api.simplepush.io/send" # The maximum allowable characters allowed in the body per message body_maxlen = 10000 # Defines the maximum allowable characters in the title title_maxlen = 1024 # Define object templates templates = ( "{schema}://{apikey}", "{schema}://{salt}:{password}@{apikey}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, # Used for encrypted logins "password": { "name": _("Password"), "type": "string", "private": True, }, "salt": { "name": _("Salt"), "type": "string", "private": True, "map_to": "user", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "event": { "name": _("Event"), "type": "string", }, }, ) def __init__(self, apikey, event=None, **kwargs): """Initialize SimplePush Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid SimplePush API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) if event: # Event Name (associated with project) self.event = validate_regex(event) if not self.event: msg = ( "An invalid SimplePush Event Name " f"({event}) was specified." ) self.logger.warning(msg) raise TypeError(msg) else: # Default Event Name self.event = None # Used/cached in _encrypt() function self._iv = None self._iv_hex = None self._key = None def _encrypt(self, content): """Encrypts message for use with SimplePush.""" if self._iv is None: # initialization vector and cache it self._iv = urandom(algorithms.AES.block_size // 8) # convert vector into hex string (used in payload) self._iv_hex = "".join( [ f"{ord(self._iv[idx : idx + 1]):02x}" for idx in range(len(self._iv)) ] ).upper() # encrypted key and cache it self._key = bytes( bytearray.fromhex( hashlib.sha1( f"{self.password}{self.user}".encode() ).hexdigest()[0:32] ) ) padder = padding.PKCS7(algorithms.AES.block_size).padder() content = padder.update(content.encode()) + padder.finalize() # # Encryption Notice # # CBC mode doesn't provide integrity guarantees. Unless the message # authentication for IV and the ciphertext are applied, it will be # vulnerable to a padding oracle attack # It is important to identify that both the Apprise package and team # recognizes this AES-CBC-128 weakness but requires that it exists due # to it being the SimplePush Requirement as documented on their # website here https://simplepush.io/features. # In the event the website link above does not exist/work, a screen # capture of the reference to the requirement for this encryption # can also be found on the Apprise SimplePush Wiki: # https://appriseit.com/services/simplepush/\ # #-aes-cbc-128-encryption-weakness # encryptor = Cipher( algorithms.AES(self._key), modes.CBC(self._iv), default_backend() ).encryptor() return urlsafe_b64encode( encryptor.update(content) + encryptor.finalize() ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform SimplePush Notification.""" headers = { "User-Agent": self.app_id, "Content-type": "application/x-www-form-urlencoded", } # Prepare our payload payload = { "key": self.apikey, } if self.password and self.user: body = self._encrypt(body) title = self._encrypt(title) payload.update( { "encrypted": "true", "iv": self._iv_hex, } ) # prepare SimplePush Object payload.update( { "msg": body, "title": title, } ) if self.event: # Store Event payload["event"] = self.event self.logger.debug( "SimplePush POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"SimplePush Payload: {payload!s}") # We need to rely on the status string returned in the SimplePush # response status_str = None status = None # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Get our SimplePush response (if it's possible) try: json_response = loads(r.content) status_str = json_response.get("message") status = json_response.get("status") except (TypeError, ValueError, AttributeError): # TypeError = r.content is not a String # ValueError = r.content is Unparsable # AttributeError = r.content is None pass if r.status_code != requests.codes.ok or status != "OK": # We had a problem status_str = ( status_str if status_str else NotifyBase.http_response_code_lookup(r.status_code) ) self.logger.warning( "Failed to send SimplePush 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 SimplePush notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending SimplePush notification." ) 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, self.user, self.password, self.apikey) 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 self.event: params["event"] = self.event # Determine Authentication auth = "" if self.user and self.password: auth = "{salt}:{password}@".format( salt=self.pprint( self.user, privacy, mode=PrivacyMode.Secret, safe="" ), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{apikey}/?{params}".format( schema=self.secure_protocol, auth=auth, apikey=self.pprint(self.apikey, privacy, safe=""), params=NotifySimplePush.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 # Set the API Key results["apikey"] = NotifySimplePush.unquote(results["host"]) # Event if "event" in results["qsd"] and len(results["qsd"]["event"]): # Extract the account sid from an argument results["event"] = NotifySimplePush.unquote( results["qsd"]["event"] ) return results @staticmethod def runtime_deps(): """Return a tuple of top-level Python package names that this plugin imported as optional runtime dependencies. """ return ("cryptography",) apprise-1.10.0/apprise/plugins/sinch.py000066400000000000000000000421051517341665700200570ustar00rootroot00000000000000# 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 Sinch account to which you can get your # API_TOKEN and SERVICE_PLAN_ID right from your console/dashboard at: # https://dashboard.sinch.com/sms/overview # # You will also need to send the SMS From a phone number or account id name. # This is identified as the source (or where the SMS message will originate # from). Activated phone numbers can be found on your dashboard here: # - https://dashboard.sinch.com/numbers/your-numbers/numbers # 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_phone_no, validate_regex from .base import NotifyBase class SinchRegion: """Defines the Sinch Server Regions.""" USA = "us" EUROPE = "eu" # Used for verification purposes SINCH_REGIONS = (SinchRegion.USA, SinchRegion.EUROPE) class NotifySinch(NotifyBase): """A wrapper for Sinch Notifications.""" # The default descriptive name associated with the Notification service_name = "Sinch" # The services URL service_url = "https://sinch.com/" # All notification requests are secure secure_protocol = "sinch" # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.20 # the number of seconds undelivered messages should linger for # in the Sinch queue validity_period = 14400 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/sinch/" # Sinch uses the http protocol with JSON requests # - the 'spi' gets substituted with the Service Provider ID # provided as part of the Apprise URL. notify_url = "https://{region}.sms.api.sinch.com/xms/v1/{spi}/batches" # 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}://{service_plan_id}:{api_token}@{from_phone}", "{schema}://{service_plan_id}:{api_token}@{from_phone}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "service_plan_id": { "name": _("Account SID"), "type": "string", "private": True, "required": True, "regex": (r"^[a-f0-9]+$", "i"), }, "api_token": { "name": _("Auth Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-f0-9]+$", "i"), }, "from_phone": { "name": _("From Phone No"), "type": "string", "required": True, "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "short_code": { "name": _("Target Short Code"), "type": "string", "regex": (r"^[0-9]{5,6}$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "from_phone", }, "spi": { "alias_of": "service_plan_id", }, "region": { "name": _("Region"), "type": "string", "regex": (r"^[a-z]{2}$", "i"), "default": SinchRegion.USA, }, "token": { "alias_of": "api_token", }, "to": { "alias_of": "targets", }, }, ) def __init__( self, service_plan_id, api_token, source, targets=None, region=None, **kwargs, ): """Initialize Sinch Object.""" super().__init__(**kwargs) # The Account SID associated with the account self.service_plan_id = validate_regex( service_plan_id, *self.template_tokens["service_plan_id"]["regex"] ) if not self.service_plan_id: msg = ( "An invalid Sinch Account SID " f"({service_plan_id}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # The Authentication Token associated with the account self.api_token = validate_regex( api_token, *self.template_tokens["api_token"]["regex"] ) if not self.api_token: msg = ( "An invalid Sinch Authentication Token " f"({api_token}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Setup our region self.region = ( self.template_args["region"]["default"] if not isinstance(region, str) else region.lower() ) if self.region and self.region not in SINCH_REGIONS: msg = f"The region specified ({region}) is invalid." self.logger.warning(msg) raise TypeError(msg) # The Source Phone # and/or short-code result = is_phone_no(source, min_len=5) if not result: msg = ( "The Account (From) Phone # or Short-code specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Tidy source self.source = result["full"] if len(self.source) < 11 or len(self.source) > 14: # A short code is a special 5 or 6 digit telephone number # that's shorter than a full phone number. if len(self.source) not in (5, 6): msg = ( "The Account (From) Phone # specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # else... it as a short code so we're okay else: # We're dealing with a phone number; so we need to just # place a plus symbol at the end of it self.source = f"+{self.source}" # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Parse each phone number we found 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("+{}".format(result["full"])) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Sinch Notification.""" if not self.targets and len(self.source) in (5, 6): # Generate a warning since we're a short-code. We need # a number to message at minimum self.logger.warning("There are no valid Sinch targets to notify.") return False # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json", } # Prepare our payload payload = { "body": body, "from": self.source, # The To gets populated in the loop below "to": None, } # Prepare our Sinch URL (spi = Service Provider ID) url = self.notify_url.format( region=self.region, spi=self.service_plan_id ) # Create a copy of the targets list targets = list(self.targets) if len(targets) == 0: # No sources specified, use our own phone no targets.append(self.source) while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["to"] = [target] # Some Debug Logging self.logger.debug( "Sinch POST URL:" f" {url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Sinch Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=json.dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # The responsne might look like: # { # "id": "CJloRJOe3MtDITqx", # "to": ["15551112222"], # "from": "15553334444", # "canceled": false, # "body": "This is a test message from your Sinch account", # "type": "mt_text", # "created_at": "2020-01-14T01:05:20.694Z", # "modified_at": "2020-01-14T01:05:20.694Z", # "delivery_report": "none", # "expire_at": "2020-01-17T01:05:20.694Z", # "flash_message": false # } 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 try: # Update our status response if we can json_response = json.loads(r.content) status_code = json_response.get("code", status_code) 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. pass self.logger.warning( "Failed to send Sinch notification to {}: " "{}{}error={}.".format( 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 Sinch notification to {target}.") except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending Sinch:{target} " + "notification." ) 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 if self.secure else self.protocol, self.service_plan_id, self.api_token, self.source, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "region": self.region, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{spi}:{token}@{source}/{targets}/?{params}".format( schema=self.secure_protocol, spi=self.pprint( self.service_plan_id, privacy, mode=PrivacyMode.Tail, safe="" ), token=self.pprint(self.api_token, privacy, safe=""), source=NotifySinch.quote(self.source, safe=""), targets="/".join( [NotifySinch.quote(x, safe="") for x in self.targets] ), params=NotifySinch.urlencode(params), ) 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 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"] = NotifySinch.split_path(results["fullpath"]) # The hostname is our source number results["source"] = NotifySinch.unquote(results["host"]) # Get our service_plan_ide and api_token from the user/pass config results["service_plan_id"] = NotifySinch.unquote(results["user"]) results["api_token"] = NotifySinch.unquote(results["password"]) # Auth Token if "token" in results["qsd"] and len(results["qsd"]["token"]): # Extract the account spi from an argument results["api_token"] = NotifySinch.unquote(results["qsd"]["token"]) # Account SID if "spi" in results["qsd"] and len(results["qsd"]["spi"]): # Extract the account spi from an argument results["service_plan_id"] = NotifySinch.unquote( results["qsd"]["spi"] ) # 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"] = NotifySinch.unquote(results["qsd"]["from"]) if "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifySinch.unquote(results["qsd"]["source"]) # Allow one to define a region if "region" in results["qsd"] and len(results["qsd"]["region"]): results["region"] = NotifySinch.unquote(results["qsd"]["region"]) # 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"] += NotifySinch.parse_phone_no( results["qsd"]["to"] ) return results apprise-1.10.0/apprise/plugins/slack.py000066400000000000000000001376231517341665700200620ustar00rootroot00000000000000# 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. # There are 2 ways to use this plugin... # Method 1: Via Webhook: # Visit https://my.slack.com/services/new/incoming-webhook/ # to create a new incoming webhook for your account. You'll need to # follow the wizard to pre-determine the channel(s) you want your # message to broadcast to, and when you're complete, you will # recieve a URL that looks something like this: # https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 # ^ ^ ^ # | | | # These are important <--------------^---------^---------------^ # # Method 2: Via a Bot: # 1. visit: https://api.slack.com/apps?new_app=1 # 2. Pick an App Name (such as Apprise) and select your workspace. Then # press 'Create App' # 3. You'll be able to click on 'Bots' from here where you can then choose # to add a 'Bot User'. Give it a name and choose 'Add Bot User'. # 4. Now you can choose 'Install App' to which you can choose 'Install App # to Workspace'. # 5. You will need to authorize the app which you get prompted to do. # 6. Finally you'll get some important information providing you your # 'OAuth Access Token' and 'Bot User OAuth Access Token' such as: # slack://{Oauth Access Token} # # ... which might look something like: # slack://xoxp-1234-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d # ... or: # slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d # # You must at least give your bot the following access for it to # be useful: # - chat:write - MUST be set otherwise you can not post into # a channel # - users:read.email - Required if you want to be able to lookup # users by their email address. # # The easiest way to bring a bot into a channel (so that it can send # a message to it is to invite it. At this time Apprise does not support # an auto-join functionality. To do this: # - In the 'Details' section of your channel # - Click on the 'More' [...] (elipse icon) # - Click 'Add apps' # - You will be able to select the Bot App you previously created # - Your bot will join your channel. import contextlib from json import dumps, loads import re from time import time import requests from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_bool, parse_list, validate_regex from .base import NotifyBase # Extend HTTP Error Messages SLACK_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", } # Used to break path apart into list of channels CHANNEL_LIST_DELIM = re.compile(r"[ \t\r\n,#\\/]+") # Channel Regular Expression Parsing CHANNEL_RE = re.compile( r"^(?P[+#@]?[A-Z0-9_-]{1,32})(:(?P[0-9.]+))?$", re.I ) class SlackMode: """Tracks the mode of which we're using Slack.""" # We're dealing with a webhook # Our token looks like: T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 WEBHOOK = "hook" # Government Webhook # Our token still looks like: T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 # however we have a different URL we post to WEBHOOK_GOV = "gov-hook" # We're dealing with a bot (using the OAuth Access Token) # Our token looks like: xoxp-1234-1234-1234-abc124 or # Our token looks like: xoxb-1234-1234-abc124 or BOT = "bot" # Define our Slack Modes SLACK_MODES = ( SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV, SlackMode.BOT, ) class NotifySlack(NotifyBase): """A wrapper for Slack Notifications.""" # The default descriptive name associated with the Notification service_name = "Slack" # The services URL service_url = "https://slack.com/" # The default secure protocol secure_protocol = "slack" # Allow 50 requests per minute (Tier 2). # 60/50 = 0.2 request_rate_per_sec = 1.2 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/slack/" # Support attachments attachment_support = True # The maximum targets to include when doing batch transfers # Slack Webhook URLs webhook_url = "https://hooks.slack.com/services" webhook_gov_url = "https://hooks.slack-gov.com/services" # Slack API URL (used with Bots) api_url = "https://slack.com/api/{}" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # The maximum allowable characters allowed in the body per message body_maxlen = 35000 # Default Notification Format notify_format = NotifyFormat.MARKDOWN # Bot's do not have default channels to notify; so #general # becomes the default channel in BOT mode default_notification_channel = "#general" # Define object templates templates = ( # Webhook "{schema}://{token_a}/{token_b}/{token_c}", "{schema}://{botname}@{token_a}/{token_b}/{token_c}", "{schema}://{token_a}/{token_b}/{token_c}/{targets}", "{schema}://{botname}@{token_a}/{token_b}/{token_c}/{targets}", # Bot "{schema}://{access_token}/", "{schema}://{access_token}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "botname": { "name": _("Bot Name"), "type": "string", "map_to": "user", }, # Bot User OAuth Access Token # which always starts with xoxp- e.g.: # xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d "access_token": { "name": _("OAuth Access Token"), "type": "string", "private": True, "required": True, "regex": (r"^(?:xoxe\.)?xox[abp]-[A-Z0-9-]+$", "i"), }, # Token required as part of the Webhook request # /AAAAAAAAA/........./........................ "token_a": { "name": _("Token A"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9]+$", "i"), }, # Token required as part of the Webhook request # /........./BBBBBBBBB/........................ "token_b": { "name": _("Token B"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9]+$", "i"), }, # Token required as part of the Webhook request # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC "token_c": { "name": _("Token C"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Za-z0-9]+$", "i"), }, "target_encoded_id": { "name": _("Target Encoded ID"), "type": "string", "prefix": "+", "map_to": "targets", }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "target_user": { "name": _("Target User"), "type": "string", "prefix": "@", "map_to": "targets", }, "target_channels": { "name": _("Target Channel"), "type": "string", "prefix": "#", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "footer": { "name": _("Include Footer"), "type": "bool", "default": True, "map_to": "include_footer", }, # Use Payload in Blocks (vs legacy way): # See: https://api.slack.com/reference/messaging/payload "blocks": { "name": _("Use Blocks"), "type": "bool", "default": False, "map_to": "use_blocks", }, "timestamp": { "name": _("Include Timestamp"), "type": "bool", "default": True, "map_to": "include_timestamp", }, "mode": { "name": _("Message Mode"), "type": "choice:string", "values": SLACK_MODES, # mode is detected if not specified }, "token": { "name": _("Token"), "alias_of": ("access_token", "token_a", "token_b", "token_c"), }, "to": { "alias_of": "targets", }, }, ) # Formatting requirements are defined here: # https://api.slack.com/docs/message-formatting _re_formatting_map = { # New lines must become the string version r"\r\*\n": "\\n", # Escape other special characters r"&": "&", r"<": "<", r">": ">", } # To notify a channel, one uses _re_channel_support = re.compile( r"(?P(?:<|\<)?[ \t]*" r"!(?P[^| \n]+)" r"(?:[ \t]*\|[ \t]*(?:(?P[^\n]+?)[ \t]*)?(?:>|\>)" r"|(?:>|\>)))", re.IGNORECASE, ) # To notify a user by their ID, one uses <@U6TTX1F9R> _re_user_id_support = re.compile( r"(?P(?:<|\<)?[ \t]*" r"@(?P[^| \n]+)" r"(?:[ \t]*\|[ \t]*(?:(?P[^\n]+?)[ \t]*)?(?:>|\>)" r"|(?:>|\>)))", re.IGNORECASE, ) # The markdown in slack isn't [desc](url), it's # # To accommodate this, we need to ensure we don't escape URLs that match _re_url_support = re.compile( r"(?P(?:<|\<)?[ \t]*" r"(?P(?:https?|mailto)://[^| \n]+)" r"(?:[ \t]*\|[ \t]*(?:(?P[^\n]+?)[ \t]*)?(?:>|\>)" r"|(?:>|\>)))", re.IGNORECASE, ) def __init__( self, access_token=None, token_a=None, token_b=None, token_c=None, targets=None, include_image=None, include_footer=None, include_timestamp=None, use_blocks=None, mode=None, **kwargs, ): """Initialize Slack Object.""" super().__init__(**kwargs) # Store our webhook mode if mode and isinstance(mode, str): self.mode = next( (a for a in SLACK_MODES if a.startswith(mode)), None ) if self.mode not in SLACK_MODES: msg = f"The Slack mode specified ({mode}) is invalid." self.logger.warning(msg) raise TypeError(msg) else: # Detect self.mode = SlackMode.BOT if access_token else SlackMode.WEBHOOK if self.mode in (SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV): self.access_token = None self.token_a = validate_regex( token_a, *self.template_tokens["token_a"]["regex"] ) if not self.token_a: msg = ( "An invalid Slack (first) Token " f"({token_a}) was specified." ) self.logger.warning(msg) raise TypeError(msg) self.token_b = validate_regex( token_b, *self.template_tokens["token_b"]["regex"] ) if not self.token_b: msg = ( "An invalid Slack (second) Token " f"({token_b}) was specified." ) self.logger.warning(msg) raise TypeError(msg) self.token_c = validate_regex( token_c, *self.template_tokens["token_c"]["regex"] ) if not self.token_c: msg = ( "An invalid Slack (third) Token " f"({token_c}) was specified." ) self.logger.warning(msg) raise TypeError(msg) else: self.token_a = None self.token_b = None self.token_c = None self.access_token = validate_regex( access_token, *self.template_tokens["access_token"]["regex"] ) if not self.access_token: msg = ( "An invalid Slack OAuth Access Token " f"({access_token}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Look the users up by their email address and map them back to their # id here for future queries (if needed). This allows people to # specify a full email as a recipient via slack self._lookup_users = {} self.use_blocks = ( parse_bool(use_blocks, self.template_args["blocks"]["default"]) if use_blocks is not None else self.template_args["blocks"]["default"] ) # Build list of channels self.channels = parse_list(targets) if len(self.channels) == 0: # No problem; the webhook is smart enough to just notify the # channel it was created for; adding 'None' is just used as # a flag lower to not set the channels self.channels.append( None if self.mode in (SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV) else self.default_notification_channel ) # Iterate over above list and store content accordingly self._re_formatting_rules = re.compile( r"(" + "|".join(self._re_formatting_map.keys()) + r")", re.IGNORECASE, ) # Place a thumbnail image inline with the message body self.include_image = ( self.template_args["image"]["default"] if include_image is None else include_image ) # Place a footer with each post self.include_footer = ( self.template_args["footer"]["default"] if include_footer is None else include_footer ) # timestamp inclusion (only applicable if footer also defined self.include_timestamp = ( self.template_args["timestamp"]["default"] if include_timestamp is None else include_timestamp ) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Slack Notification.""" # error tracking (used for function return) has_error = False # # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) # if self.use_blocks: # Our slack format slack_format = ( "mrkdwn" if self.notify_format == NotifyFormat.MARKDOWN else "plain_text" ) payload = { "username": self.user if self.user else self.app_id, "attachments": [ { "blocks": [ { "type": "section", "text": {"type": slack_format, "text": body}, } ], "color": self.color(notify_type), } ], } # Slack only accepts non-empty header sections if title: payload["attachments"][0]["blocks"].insert( 0, { "type": "header", "text": { "type": "plain_text", "text": title, "emoji": True, }, }, ) # Include the footer only if specified to do so if self.include_footer: # Acquire our to-be footer icon if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) # Prepare our footer based on the block structure footer = { "type": "context", "elements": [{"type": slack_format, "text": self.app_id}], } if image_url: payload["icon_url"] = image_url footer["elements"].insert( 0, { "type": "image", "image_url": image_url, "alt_text": notify_type, }, ) payload["attachments"][0]["blocks"].append(footer) else: # # Legacy API Formatting # if self.notify_format == NotifyFormat.MARKDOWN: body = self._re_formatting_rules.sub( # pragma: no branch lambda x: self._re_formatting_map[x.group()], body, ) # Support , entries for match in self._re_channel_support.findall(body): # Swap back any ampersands previously updaated channel = match[1].strip() desc = match[2].strip() # Update our string body = re.sub( re.escape(match[0]), f"" if desc else f"", body, flags=re.IGNORECASE, ) # Support <@userid|desc>, <@channel> entries for match in self._re_user_id_support.findall(body): # Swap back any ampersands previously updaated user = match[1].strip() desc = match[2].strip() # Update our string body = re.sub( re.escape(match[0]), f"<@{user}|{desc}>" if desc else f"<@{user}>", body, flags=re.IGNORECASE, ) # Support , entries for match in self._re_url_support.findall(body): # Swap back any ampersands previously updaated url = match[1].replace("&", "&") desc = match[2].strip() # Update our string body = re.sub( re.escape(match[0]), f"<{url}|{desc}>" if desc else f"<{url}>", body, flags=re.IGNORECASE, ) # Perform Formatting on title here; this is not needed for block # mode above title = self._re_formatting_rules.sub( # pragma: no branch lambda x: self._re_formatting_map[x.group()], title, ) # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) payload = { "username": self.user if self.user else self.app_id, # Use Markdown language "mrkdwn": self.notify_format == NotifyFormat.MARKDOWN, "attachments": [ { "title": title, "text": body, "color": self.color(notify_type), } ], } # Acquire our to-be footer icon if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) if image_url: payload["icon_url"] = image_url # Include the footer only if specified to do so if self.include_footer: if image_url: payload["attachments"][0]["footer_icon"] = image_url # Include the footer only if specified to do so payload["attachments"][0]["footer"] = self.app_id if self.include_timestamp: # Timestamp payload["attachments"][0]["ts"] = time() if ( attach and self.attachment_support and self.mode in (SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV) ): # Be friendly; let the user know why they can't send their # attachments if using the Webhook mode self.logger.warning("Slack Webhooks do not support attachments.") # Prepare our Slack URL (depends on mode) if self.mode is SlackMode.WEBHOOK: url = ( f"{self.webhook_url}/{self.token_a}" f"/{self.token_b}/{self.token_c}" ) elif self.mode is SlackMode.WEBHOOK_GOV: url = ( f"{self.webhook_gov_url}/{self.token_a}" f"/{self.token_b}/{self.token_c}" ) else: # SlackMode.BOT url = self.api_url.format("chat.postMessage") # Create a copy of the channel list channels = list(self.channels) attach_channel_list = [] while len(channels): channel = channels.pop(0) if channel is not None: # We'll perform a user lookup if we detect an email email = is_email(channel) if email: payload["channel"] = self.lookup_userid( email["full_email"] ) if not payload["channel"]: # Move along; any notifications/logging would have # come from lookup_userid() has_error = True continue else: # Channel result = CHANNEL_RE.match(channel) if not result: # Channel over-ride was specified self.logger.warning( f"The specified Slack target {channel} is invalid;" "skipping." ) # Mark our failure has_error = True continue # Store oure content channel, thread_ts = ( result.group("channel"), result.group("thread_ts"), ) if thread_ts: payload["thread_ts"] = thread_ts elif "thread_ts" in payload: # Handle situations where one channel has a thread_id # specified, and the next does not. We do not want to # cary forward the last value specified del payload["thread_ts"] if channel[0] == "+": # Treat as encoded id if prefixed with a + payload["channel"] = channel[1:] elif channel[0] == "@": # Treat @ value 'as is' payload["channel"] = channel else: # Prefix with channel hash tag (if not already) payload["channel"] = ( channel if channel[0] == "#" else f"#{channel}" ) response = self._send(url, payload) if not response: # Handle any error has_error = True continue # Store the valid channel or chat ID (for DMs) that will # be accepted by Slack's attachment method later. if response.get("channel"): attach_channel_list.append(response.get("channel")) self.logger.info( "Sent Slack notification{}.".format( f" to {channel}" if channel is not None else "" ) ) if ( attach and self.attachment_support and self.mode is SlackMode.BOT and attach_channel_list ): # Send our attachments (can only be done in bot mode) 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 self.logger.debug( f"Posting Slack attachment {attachment.url(privacy=True)}" ) # Get the URL to which to upload the file. # https://api.slack.com/methods/files.getUploadURLExternal params = { "filename": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "length": len(attachment), } url_ = self.api_url.format("files.getUploadURLExternal") response = self._send( url_, {}, http_method="get", params=params ) if not ( response and response.get("file_id") and response.get("upload_url") ): self.logger.error("Could retrieve file upload URL.") # We failed to get an upload URL, take an early exit return False file_id = response.get("file_id") upload_url = response.get("upload_url") # Upload file response = self._send(upload_url, {}, attach=attachment) # Send file to channels # https://api.slack.com/methods/files.completeUploadExternal for channel_id in attach_channel_list: payload_ = { "files": [ { "id": file_id, "title": attachment.name, } ], "channel_id": channel_id, } url_ = self.api_url.format("files.completeUploadExternal") response = self._send(url_, payload_) # Expected response # { # "ok": true, # "files": [ # { # "id": "F123ABC456", # "title": "slack-test" # } # ] # } if not (response and response.get("files")): self.logger.error("Failed to send file to channel.") # We failed to send the file to the channel, # take an early exit return False return not has_error def lookup_userid(self, email): """Takes an email address and attempts to resolve/acquire it's user id for notification purposes.""" if email in self._lookup_users: # We're done as entry has already been retrieved return self._lookup_users[email] if self.mode is not SlackMode.BOT: # You can not look up self.logger.warning( "Emails can not be resolved to Slack User IDs unless you " "have a bot configured." ) return None lookup_url = self.api_url.format("users.lookupByEmail") headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", "Authorization": f"Bearer {self.access_token}", } # we pass in our email address as the argument params = { "email": email, } self.logger.debug( "Slack User Lookup POST URL:" f" {lookup_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Slack User Lookup Parameters: {params!s}") # Initialize our HTTP JSON response response = {"ok": False} # Initialize our detected user id (also the response to this function) user_id = None # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.get( lookup_url, headers=headers, params=params, verify=self.verify_certificate, timeout=self.request_timeout, ) # Attachment posts return a JSON string with contextlib.suppress(AttributeError, TypeError, ValueError): # Load our JSON object if we can # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None response = loads(r.content) # We can get a 200 response, but still fail. A failure message # might look like this (missing bot permissions): # { # 'ok': False, # 'error': 'missing_scope', # 'needed': 'users:read.email', # 'provided': 'calls:write,chat:write' # } if r.status_code != requests.codes.ok or not ( response and response.get("ok", False) ): # We had a problem status_str = NotifySlack.http_response_code_lookup( r.status_code, SLACK_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Slack User Lookup:{}{}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 # If we reach here, then we were successful in looking up # the user. A response generally looks like this: # { # 'ok': True, # 'user': { # 'id': 'J1ZQB9T9Y', # 'team_id': 'K1WR6TML2', # 'name': 'l2g', # 'deleted': False, # 'color': '9f69e7', # 'real_name': 'Chris C', # 'tz': 'America/New_York', # 'tz_label': 'Eastern Standard Time', # 'tz_offset': -18000, # 'profile': { # 'title': '', # 'phone': '', # 'skype': '', # 'real_name': 'Chris C', # 'real_name_normalized': # 'Chris C', # 'display_name': 'l2g', # 'display_name_normalized': 'l2g', # 'fields': None, # 'status_text': '', # 'status_emoji': '', # 'status_expiration': 0, # 'avatar_hash': 'g785e9c0ddf6', # 'email': 'lead2gold@gmail.com', # 'first_name': 'Chris', # 'last_name': 'C', # 'image_24': 'https://secure.gravatar.com/...', # 'image_32': 'https://secure.gravatar.com/...', # 'image_48': 'https://secure.gravatar.com/...', # 'image_72': 'https://secure.gravatar.com/...', # 'image_192': 'https://secure.gravatar.com/...', # 'image_512': 'https://secure.gravatar.com/...', # 'status_text_canonical': '', # 'team': 'K1WR6TML2' # }, # 'is_admin': True, # 'is_owner': True, # 'is_primary_owner': True, # 'is_restricted': False, # 'is_ultra_restricted': False, # 'is_bot': False, # 'is_app_user': False, # 'updated': 1603904274 # } # } # We're only interested in the id user_id = response["user"]["id"] # Cache it for future self._lookup_users[email] = user_id self.logger.info( "Email %s resolves to the Slack User ID: %s.", email, user_id ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred looking up Slack User.", ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return None return user_id def _send( self, url, payload, attach=None, http_method="post", params=None, **kwargs, ): """Wrapper to the requests (post) object.""" self.logger.debug( f"Slack POST URL: {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Slack Payload: {payload!s}") headers = { "User-Agent": self.app_id, "Accept": "application/json", } if not attach: headers["Content-Type"] = "application/json; charset=utf-8" if self.mode is SlackMode.BOT: headers["Authorization"] = f"Bearer {self.access_token}" # Our response object response = {"ok": False} # Always call throttle before any remote server i/o is made self.throttle() # Our attachment path (if specified) files = None try: # Open our attachment path if required: if attach: files = { "file": ( attach.name, # file handle is safely closed in `finally`; inline # open is intentional; attach.open() dispatches to # BytesIO for memory attachments attach.open(), ), } r = requests.request( http_method, url, data=payload if attach else dumps(payload), headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, params=params if params else None, ) # Posts return a JSON string with contextlib.suppress(AttributeError, TypeError, ValueError): # Load our JSON object if we can # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None response = loads(r.content) # Another response type is: # { # 'ok': False, # 'error': 'not_in_channel', # } status_okay = False if self.mode is SlackMode.BOT: status_okay = ( (response and response.get("ok", False)) or # Responses for file uploads look like this # 'OK - ' ( r.content and isinstance(r.content, bytes) and b"OK" in r.content ) ) elif r.content == b"ok": # The text 'ok' is returned if this is a Webhook request # So the below captures that as well. status_okay = True if r.status_code != requests.codes.ok or not status_okay: # We had a problem status_str = NotifySlack.http_response_code_lookup( r.status_code, SLACK_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send{} to Slack: {}{}error={}.".format( (" " + attach.name) if attach else "", status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug(f"Response Details:\r\n{r.content}") return False # Message Post Response looks like this: # { # "attachments": [ # { # "color": "3AA3E3", # "fallback": "test", # "id": 1, # "text": "my body", # "title": "my title", # "ts": 1573694687 # } # ], # "bot_id": "BAK4K23G5", # "icons": { # "image_48": "https://s3-us-west-2.amazonaws.com/... # }, # "subtype": "bot_message", # "text": "", # "ts": "1573694689.003700", # "type": "message", # "username": "Apprise" # } # files.completeUploadExternal responses look like this: # { # "ok": true, # "files": [ # { # "id": "F123ABC456", # "title": "slack-test" # } # ] # } except requests.RequestException as e: self.logger.warning( "A Connection error occurred posting {}to Slack.".format( attach.name if attach else "" ) ) self.logger.debug(f"Socket Exception: {e!s}") return False except OSError as e: self.logger.warning( "An I/O error occurred while reading {}.".format( attach.name if attach else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: # Close our file (if it's open) stored in the second element # of our files tuple (index 1) if files: files["file"][1].close() # Return the response for processing return response @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_a, self.token_b, self.token_c, self.access_token, ) 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", "footer": "yes" if self.include_footer else "no", "timestamp": "yes" if self.include_timestamp else "no", "blocks": "yes" if self.use_blocks else "no", "mode": self.mode, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine if there is a botname present botname = "" if self.user: botname = "{botname}@".format( botname=NotifySlack.quote(self.user, safe=""), ) if self.mode in (SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV): return ( "{schema}://{botname}{token_a}/{token_b}/{token_c}/" "{targets}/?{params}".format( schema=self.secure_protocol, botname=botname, token_a=self.pprint(self.token_a, privacy, safe=""), token_b=self.pprint(self.token_b, privacy, safe=""), token_c=self.pprint(self.token_c, privacy, safe=""), targets="/".join( [NotifySlack.quote(x, safe="") for x in self.channels] ), params=NotifySlack.urlencode(params), ) ) # else -> self.mode == SlackMode.BOT: return "{schema}://{botname}{access_token}/{targets}/?{params}".format( schema=self.secure_protocol, botname=botname, access_token=self.pprint(self.access_token, privacy, safe=""), targets="/".join( [NotifySlack.quote(x, safe="") for x in self.channels] ), params=NotifySlack.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.channels) @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 first token is stored in the hostname token = NotifySlack.unquote(results["host"]) # Get unquoted entries entries = NotifySlack.split_path(results["fullpath"]) # Verify if our token_a us a bot token or part of a webhook: if token.startswith("xo"): # We're dealing with a bot results["access_token"] = token else: # We're dealing with a webhook results["token_a"] = token results["token_b"] = entries.pop(0) if entries else None results["token_c"] = entries.pop(0) if entries else None # assign remaining entries to the channels we wish to notify results["targets"] = entries # Support the token flag where you can set it to the bot token # or the webhook token (with slash delimiters) if "token" in results["qsd"] and len(results["qsd"]["token"]): # Break our entries up into a list; we can ue the Channel # list delimiter above since it doesn't contain any characters # we don't otherwise accept anyway in our token entries = list( filter( bool, CHANNEL_LIST_DELIM.split( NotifySlack.unquote(results["qsd"]["token"]) ), ) ) # check to see if we're dealing with a bot/user token if entries and entries[0].startswith("xo"): # We're dealing with a bot results["access_token"] = entries[0] results["token_a"] = None results["token_b"] = None results["token_c"] = None else: # Webhook results["access_token"] = None results["token_a"] = entries.pop(0) if entries else None results["token_b"] = entries.pop(0) if entries else None results["token_c"] = entries.pop(0) if entries else None # 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"] += list( filter( bool, CHANNEL_LIST_DELIM.split( NotifySlack.unquote(results["qsd"]["to"]) ), ) ) # Get Image Flag results["include_image"] = parse_bool( results["qsd"].get( "image", NotifySlack.template_args["image"]["default"] ) ) results["include_timestamp"] = parse_bool( results["qsd"].get( "timestamp", NotifySlack.template_args["timestamp"]["default"] ) ) # Get Payload structure (use blocks?) if "blocks" in results["qsd"] and len(results["qsd"]["blocks"]): results["use_blocks"] = parse_bool(results["qsd"]["blocks"]) # Get Footer Flag results["include_footer"] = parse_bool( results["qsd"].get( "footer", NotifySlack.template_args["footer"]["default"] ) ) # Get Mode if "mode" in results["qsd"] and len(results["qsd"]["mode"]): results["mode"] = NotifySlack.unquote(results["qsd"]["mode"]) return results @staticmethod def parse_native_url(url): """ Supports: - https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C - https://hooks.slack-gov.com/services/TOKEN_A/TOKEN_B/TOKEN_C """ result = re.match( r"^https?://(?Phooks\.slack(?P-gov)?\.com)/services/" r"(?P[A-Z0-9]+)/" r"(?P[A-Z0-9]+)/" r"(?P[A-Z0-9]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: params = ( "" if not result.group("params") else result.group("params") ) if result.group("gov"): # provide gov parameters params = ( "?" if not params else "&" ) + f"mode={SlackMode.WEBHOOK_GOV}" return NotifySlack.parse_url( "{schema}://{token_a}/{token_b}/{token_c}/{params}".format( schema=NotifySlack.secure_protocol, token_a=result.group("token_a"), token_b=result.group("token_b"), token_c=result.group("token_c"), params=params, ) ) return None apprise-1.10.0/apprise/plugins/smpp.py000066400000000000000000000273541517341665700177430ustar00rootroot00000000000000# 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 try: import smpplib import smpplib.consts import smpplib.gsm # We're good to go! NOTIFY_SMPP_ENABLED = True except ImportError: # cryptography is required in order for this package to work NOTIFY_SMPP_ENABLED = False from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_phone_no, parse_phone_no from .base import NotifyBase class NotifySMPP(NotifyBase): """A wrapper for SMPP Notifications.""" # Set our global enabled flag enabled = NOTIFY_SMPP_ENABLED requirements = { # Define our required packaging in order to work "packages_required": "smpplib" } # The default descriptive name associated with the Notification service_name = _("SMPP") # The services URL service_url = "https://smpp.org/" # The default protocol protocol = "smpp" # The default secure protocol secure_protocol = "smpps" # Default port setup default_port = 2775 default_secure_port = 3550 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/smpp/" # 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}://{user}:{password}@{host}/{from_phone}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{from_phone}/{targets}", ) template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("Username"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "host": { "name": _("Host"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "from_phone": { "name": _("From Phone No"), "type": "string", "regex": (r"^[0-9\s)(+-]+$", "i"), "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, }, }, ) def __init__(self, source=None, targets=None, **kwargs): """Initialize SMPP Object.""" super().__init__(**kwargs) self.source = None if not (self.user and self.password): msg = "No SMPP user/pass combination was 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"] # 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 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.port, 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}://{user}:{password}@{host}/{source}/{targets}/?{params}" ).format( schema=self.secure_protocol if self.secure else self.protocol, user=self.user, password=self.password, host=f"{self.host}:{self.port}" if self.port else self.host, source=self.source, targets="/".join( [ NotifySMPP.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 SMPP Notification.""" if not self.targets: # There were no targets to notify self.logger.warning("There were no SMPP targets to notify") return False # error tracking (used for function return) has_error = False port = ( self.default_port if not self.secure else self.default_secure_port ) client = smpplib.client.Client( self.host, port, allow_unknown_opt_params=True ) try: client.connect() client.bind_transmitter( system_id=self.user, password=self.password ) except smpplib.exceptions.ConnectionError as e: self.logger.warning( "Failed to establish connection to SMPP server" f" {self.host}: {e}" ) return False for target in self.targets: parts, encoding, msg_type = smpplib.gsm.make_parts(body) # Always call throttle before any remote server i/o is made self.throttle() try: for payload in parts: client.send_message( source_addr_ton=smpplib.consts.SMPP_TON_INTL, source_addr=self.source, dest_addr_ton=smpplib.consts.SMPP_TON_INTL, destination_addr=target, short_message=payload, data_coding=encoding, esm_class=msg_type, registered_delivery=True, ) except Exception as e: self.logger.warning(f"Failed to send SMPP notification: {e}") # Mark our failure has_error = True continue self.logger.info("Sent SMPP notification to %s", target) client.unbind() client.disconnect() 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 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"] = NotifySMPP.unquote(results["qsd"]["from"]) # hostname will also be a target in this case results["targets"] = [ *NotifySMPP.parse_phone_no(results["host"]), *NotifySMPP.split_path(results["fullpath"]), ] else: # store our source results["source"] = NotifySMPP.unquote(results["host"]) # store targets results["targets"] = NotifySMPP.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"] += NotifySMPP.parse_phone_no( results["qsd"]["to"] ) results["targets"] = NotifySMPP.split_path(results["fullpath"]) # Support the 'to' variable so that we can support targets this way too # 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifySMPP.parse_phone_no( results["qsd"]["to"] ) # store any additional payload extras defined results["payload"] = { NotifySMPP.unquote(x): NotifySMPP.unquote(y) for x, y in results["qsd:"].items() } # Add our GET parameters in the event the user wants to pass them results["params"] = { NotifySMPP.unquote(x): NotifySMPP.unquote(y) for x, y in results["qsd-"].items() } # Support the 'from' and 'source' variable so that we can support # targets this way too. # 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifySMPP.unquote(results["qsd"]["from"]) elif results["targets"]: # from phone number is the first entry in the list otherwise results["source"] = results["targets"].pop(0) return results @staticmethod def runtime_deps(): """Return a tuple of top-level Python package names that this plugin imported as optional runtime dependencies. """ return ("smpplib",) apprise-1.10.0/apprise/plugins/smseagle.py000066400000000000000000000634371517341665700205660ustar00rootroot00000000000000# 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 from json import dumps, loads 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 ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from ..utils.sanitize import sanitize_payload from .base import NotifyBase GROUP_REGEX = re.compile(r"^\s*(\#|\%35)(?P[a-z0-9_-]+)", re.I) CONTACT_REGEX = re.compile(r"^\s*(\@|\%40)?(?P[a-z0-9_-]+)", re.I) # Priorities class SMSEaglePriority: NORMAL = 0 HIGH = 1 SMSEAGLE_PRIORITIES = ( SMSEaglePriority.NORMAL, SMSEaglePriority.HIGH, ) SMSEAGLE_PRIORITY_MAP = { # short for 'normal' "normal": SMSEaglePriority.NORMAL, # short for 'high' "+": SMSEaglePriority.HIGH, "high": SMSEaglePriority.HIGH, } class SMSEagleCategory: """We define the different category types that we can notify via SMS Eagle.""" PHONE = "phone" GROUP = "group" CONTACT = "contact" SMSEAGLE_CATEGORIES = ( SMSEagleCategory.PHONE, SMSEagleCategory.GROUP, SMSEagleCategory.CONTACT, ) class NotifySMSEagle(NotifyBase): """A wrapper for SMSEagle Notifications.""" # The default descriptive name associated with the Notification service_name = "SMS Eagle" # The services URL service_url = "https://smseagle.eu" # The default protocol protocol = "smseagle" # The default protocol secure_protocol = "smseagles" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/smseagle/" # The path we send our notification to notify_path = "/jsonrpc/sms" # Support attachments attachment_support = True # The maxumum length of the text message # The actual limit is 160 but SMSEagle looks after the handling # of large messages in it's upstream service body_maxlen = 1200 # The maximum targets to include when doing batch transfers default_batch_size = 10 # We don't support titles for SMSEagle notifications title_maxlen = 0 # Define object templates templates = ( "{schema}://{token}@{host}/{targets}", "{schema}://{token}@{host}:{port}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "token": { "name": _("Access Token"), "type": "string", "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 ID"), "type": "string", "prefix": "#", "regex": (r"^[a-z0-9_-]+$", "i"), "map_to": "targets", }, "target_contact": { "name": _("Target Contact"), "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, **{ "token": { "alias_of": "token", }, "priority": { "name": _("Priority"), "type": "choice:int", "values": SMSEAGLE_PRIORITIES, "default": SMSEaglePriority.NORMAL, }, "status": { "name": _("Show Status"), "type": "bool", "default": False, }, "test": { "name": _("Test Only"), "type": "bool", "default": False, }, "flash": { "name": _("Flash"), "type": "bool", "default": False, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, token=None, targets=None, priority=None, batch=False, status=False, flash=False, test=False, **kwargs, ): """Initialize SMSEagle Object.""" super().__init__(**kwargs) # Prepare Flash Mode Flag self.flash = flash # Prepare Test Mode Flag self.test = test # Prepare Batch Mode Flag self.batch = batch # Set Status type self.status = status # Parse our targets self.target_phones = [] self.target_groups = [] self.target_contacts = [] # Used for URL generation afterwards only self.invalid_targets = [] # We always use a token if provided self.token = validate_regex(token if token else self.user) if not self.token: msg = ( "An invalid SMSEagle Access Token" f" ({token if token else self.user}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # # Priority # try: # Acquire our priority if we can: # - We accept both the integer form as well as a string # representation self.priority = int(priority) except TypeError: # NoneType means use Default; this is an okay exception self.priority = self.template_args["priority"]["default"] except ValueError: # Input is a string; attempt to get the lookup from our # priority mapping priority = priority.lower().strip() # This little bit of black magic allows us to match against # low, lo, l (for low); # normal, norma, norm, nor, no, n (for normal) # ... etc result = ( next( ( key for key in SMSEAGLE_PRIORITY_MAP if key.startswith(priority) ), None, ) if priority else None ) # Now test to see if we got a match if not result: msg = ( f"An invalid SMSEagle priority ({priority}) was specified." ) self.logger.warning(msg) raise TypeError(msg) from None # store our successfully looked up priority self.priority = SMSEAGLE_PRIORITY_MAP[result] if ( self.priority is not None and self.priority not in SMSEAGLE_PRIORITY_MAP.values() ): msg = f"An invalid SMSEagle priority ({priority}) was specified." self.logger.warning(msg) raise TypeError(msg) # Validate our targerts for target in parse_phone_no(targets): # Validate targets and drop bad ones: # Allow 9 digit numbers (without country code) result = is_phone_no(target, min_len=9) if result: # store valid phone number self.target_phones.append( "{}{}".format( "" if target[0] != "+" else "+", result["full"] ) ) continue result = GROUP_REGEX.match(target) if result: # Just store group information self.target_groups.append(result.group("group")) continue result = CONTACT_REGEX.match(target) if result: # Just store contact information self.target_contacts.append(result.group("contact")) continue self.logger.warning( f"Dropped invalid phone/group/contact ({target}) specified.", ) self.invalid_targets.append(target) continue return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform SMSEagle Notification.""" if ( not self.target_groups and not self.target_phones and not self.target_contacts ): # There were no services to notify self.logger.warning("There were no SMSEagle targets to notify.") return False # error tracking (used for function return) has_error = False attachments = [] if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access SMSEagle 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 SMSEagle attachment" f" {attachment.url(privacy=True)}." ) continue try: # Prepare our Attachment in Base64 attachments.append( { "content_type": attachment.mimetype, "content": attachment.base64(), } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access SMSEagle attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending SMSEagle attachment" f" {attachment.url(privacy=True)}" ) # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Prepare our payload params_template = { # Our Access Token "access_token": self.token, # The message to send (populated below) "message": None, # 0 = normal priority, 1 = high priority "highpriority": self.priority, # Support unicode characters "unicode": 1, # sms or mms (if attachment) "message_type": "sms", # Response Types: # simple: format response as simple object with one result field # extended: format response as extended JSON object "responsetype": "extended", # SMS will be sent as flash message (1 = yes, 0 = no) "flash": 1 if self.flash else 0, # Message Simulation "test": 1 if self.test else 0, } # Set our schema schema = "https" if self.secure else "http" # Construct our URL notify_url = f"{schema}://{self.host}" if isinstance(self.port, int): notify_url += f":{self.port}" notify_url += self.notify_path # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size notify_by = { SMSEagleCategory.PHONE: { "method": "sms.send_sms", "target": "to", }, SMSEagleCategory.GROUP: { "method": "sms.send_togroup", "target": "groupname", }, SMSEagleCategory.CONTACT: { "method": "sms.send_tocontact", "target": "contactname", }, } # categories separated into a tuple since notify_by.keys() # returns an unpredicable list in Python 2.7 which causes # tests to fail every so often for category in SMSEAGLE_CATEGORIES: # Create a copy of our template payload = { "method": notify_by[category]["method"], "params": { notify_by[category]["target"]: None, }, } # Apply Template payload["params"].update(params_template) # Set our Message payload["params"]["message"] = "{}{}".format( "" if not self.status else f"{self.asset.ascii(notify_type)} ", body, ) if attachments: # Store our attachments payload["params"]["message_type"] = "mms" payload["params"]["attachments"] = attachments targets = getattr(self, f"target_{category}s") for index in range(0, len(targets), batch_size): # Prepare our recipients payload["params"][notify_by[category]["target"]] = ",".join( targets[index : index + batch_size] ) # 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( "SMSEagle POST URL:" f" {notify_url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "SMSEagle Payload: %s", sanitize_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, verify=self.verify_certificate, timeout=self.request_timeout, ) try: content = loads(r.content) # Store our status status_str = str(content["result"]) except (AttributeError, TypeError, ValueError, KeyError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None # KeyError = 'result' is not found in result content = {} # The result set can be a list such as: # b'{"result":[{"message_id":4753,"status":"ok"}]}' # # It can also just be as a dictionary: # b'{"result":{"message_id":4753,"status":"ok"}}' # # The below code handles both cases only only fails if a # non-ok value was returned if ( r.status_code not in (requests.codes.ok, requests.codes.created) or not isinstance(content.get("result"), (dict, list)) or ( isinstance(content.get("result"), dict) and content["result"].get("status") != "ok" ) or ( isinstance(content.get("result"), list) and next( ( True for entry in content.get("result") if isinstance(entry, dict) and entry.get("status") != "ok" ), False, ) # pragma: no cover ) ): # We had a problem status_str = ( content.get("result") if content.get("result") else NotifySMSEagle.http_response_code_lookup( r.status_code ) ) self.logger.warning( "Failed to send {} {} SMSEagle {} notification: " "{}{}error={}.".format( len(targets[index : index + batch_size]), ( f"to {targets[index]}" if batch_size == 1 else "(s)" ), category, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response" f" {category.upper()} Details:\r\n{r.content}" ) # Mark our failure has_error = True continue else: self.logger.info( "Sent {} SMSEagle {} notification{}.".format( len(targets[index : index + batch_size]), category, ( f" to {targets[index]}" if batch_size == 1 else "(s)" ), ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occured sending" f" {len(targets[index : index + batch_size])} SMSEagle" f" {category} notification(s)." ) 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 if self.secure else self.protocol, self.token, self.host, self.port, ) 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", "status": "yes" if self.status else "no", "flash": "yes" if self.flash else "no", "test": "yes" if self.test else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) default_priority = self.template_args["priority"]["default"] if self.priority is not None: # Store our priority; but only if it was specified params["priority"] = next( ( key for key, value in SMSEAGLE_PRIORITY_MAP.items() if value == self.priority ), default_priority, ) # pragma: no cover # Default port handling default_port = 443 if self.secure else 80 return "{schema}://{token}@{hostname}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, token=self.pprint( self.token, privacy, mode=PrivacyMode.Secret, safe="" ), # 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( [ NotifySMSEagle.quote(x, safe="#@") for x in chain( # Pass phones directly as is self.target_phones, # Contacts [f"@{x}" for x in self.target_contacts], # Groups [f"#{x}" for x in self.target_groups], # Pass along the same invalid entries as were provided self.invalid_targets, ) ] ), params=NotifySMSEagle.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 if batch_size > 1: # Batches can only be sent by group (you can't combine groups into # a single batch) total_targets = 0 for c in SMSEAGLE_CATEGORIES: targets = len(getattr(self, f"target_{c}s")) total_targets += int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return total_targets # Normal batch count; just count the targets return ( len(self.target_phones) + len(self.target_contacts) + len(self.target_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"] = NotifySMSEagle.split_path(results["fullpath"]) if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifySMSEagle.unquote(results["qsd"]["token"]) elif not results["password"] and results["user"]: results["token"] = NotifySMSEagle.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"] += NotifySMSEagle.parse_phone_no( results["qsd"]["to"] ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) # Get Flash Mode Flag results["flash"] = parse_bool(results["qsd"].get("flash", False)) # Get Test Mode Flag results["test"] = parse_bool(results["qsd"].get("test", False)) # Get status switch results["status"] = parse_bool(results["qsd"].get("status", False)) # Get priority if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifySMSEagle.unquote( results["qsd"]["priority"] ) return results apprise-1.10.0/apprise/plugins/smsmanager.py000066400000000000000000000347011517341665700211130ustar00rootroot00000000000000# 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://smsmanager.cz/api/http#send # To use this service you will need a SMS Manager account # You will need credits (new accounts start with a few) # https://smsmanager.cz # 1. Sign up and get test credit # 2. Generate an API key in web administration. 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 SMSManagerGateway: """The different gateway values.""" HIGH = "high" ECONOMY = "economy" LOW = "low" DIRECT = "direct" # Used for verification purposes SMS_MANAGER_GATEWAYS = ( SMSManagerGateway.HIGH, SMSManagerGateway.ECONOMY, SMSManagerGateway.LOW, SMSManagerGateway.DIRECT, ) class NotifySMSManager(NotifyBase): """A wrapper for SMS Manager Notifications.""" # The default descriptive name associated with the Notification service_name = "SMS Manager" # The services URL service_url = "https://smsmanager.cz" # All notification requests are secure secure_protocol = ( "smsmgr", "smsmanager", ) # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/sms_manager/" # SMS Manager uses the http protocol with JSON requests notify_url = "https://http-api.smsmanager.cz/Send" # The maximum amount of texts that can go out in one batch default_batch_size = 4000 # 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}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "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, **{ "key": { "alias_of": "apikey", }, "from": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "sender", }, "sender": { "alias_of": "from", }, "gateway": { "name": _("Gateway"), "type": "choice:string", "values": SMS_MANAGER_GATEWAYS, "default": SMS_MANAGER_GATEWAYS[0], }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, apikey=None, sender=None, targets=None, batch=None, gateway=None, **kwargs, ): """Initialize SMS Manager Object.""" super().__init__(**kwargs) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Setup our gateway self.gateway = ( self.template_args["gateway"]["default"] if not isinstance(gateway, str) else gateway.lower() ) if self.gateway not in SMS_MANAGER_GATEWAYS: msg = f"The Gateway specified ({gateway}) is invalid." self.logger.warning(msg) raise TypeError(msg) # 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) ) # Maximum 11 characters and must be approved by administrators of site self.sender = sender[0:11] if isinstance(sender, str) else None # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Parse each phone number we found # It is documented that numbers with a length of 9 characters are # supplemented by "420". result = is_phone_no(target, min_len=9) if result: # Carry forward '+' if defined, otherwise do not... self.targets.append( ("+" + result["full"]) if target.lstrip()[0] == "+" else result["full"] ) continue self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform SMS Manager Notification.""" if not self.targets: # We have nothing to notify self.logger.warning("There are no SMS Manager targets to notify") return False # 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 # Prepare our headers headers = { "User-Agent": self.app_id, } # 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 payload # Note: Payload is assembled inside of our while-loop due to # mock testing issues (payload singleton isn't persistent # when performing follow up checks on the params object. payload = { "apikey": self.apikey, "gateway": self.gateway, # The number gets populated in the loop below "number": None, "message": body, } if self.sender: # Sender is ony set if specified payload["sender"] = self.sender # Printable target details if isinstance(target, list): p_target = f"{len(target)} targets" # Prepare our target numbers payload["number"] = ";".join(target) else: p_target = target # Prepare our target numbers payload["number"] = target # Some Debug Logging self.logger.debug( "SMS Manager POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"SMS Manager Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.get( self.notify_url, params=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) 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 SMS Manager 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 SMS Manager notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending SMS Manager: 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[0], 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", "gateway": self.gateway, } if self.sender: # Set our sender if it was set params["sender"] = self.sender # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{apikey}@{targets}?{params}".format( schema=self.secure_protocol[0], apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [ NotifySMSManager.quote(f"{x}", safe="+") for x in self.targets ] ), params=NotifySMSManager.urlencode(params), ) 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 @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 API Key results["apikey"] = NotifySMSManager.unquote(results["user"]) # Store our targets results["targets"] = [ *NotifySMSManager.parse_phone_no(results["host"]), *NotifySMSManager.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"] = NotifySMSManager.unquote( results["qsd"]["from"] ) elif "sender" in results["qsd"] and len(results["qsd"]["sender"]): # Support sender= value as well to align with SMS Manager API results["sender"] = NotifySMSManager.unquote( results["qsd"]["sender"] ) # 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"] += NotifySMSManager.parse_phone_no( results["qsd"]["to"] ) if "key" in results["qsd"] and len(results["qsd"]["key"]): results["apikey"] = NotifySMSManager.unquote(results["qsd"]["key"]) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifySMSManager.template_args["batch"]["default"] ) ) # Define our gateway if "gateway" in results["qsd"] and len(results["qsd"]["gateway"]): results["gateway"] = NotifySMSManager.unquote( results["qsd"]["gateway"] ) return results apprise-1.10.0/apprise/plugins/smtp2go.py000066400000000000000000000512641517341665700203540ustar00rootroot00000000000000# 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. # Signup @ https://smtp2go.com (free accounts available) # # From your dashboard, you can generate an API Key if you haven't already # at https://app.smtp2go.com/settings/apikeys/ # The API Key from here which will look something like: # api-60F0DD0AB5BA11ABA421F23C91C88EF4 # # Knowing this, you can buid your smtp2go url as follows: # smtp2go://{user}@{domain}/{apikey} # smtp2go://{user}@{domain}/{apikey}/{email} # # You can email as many addresses as you want as: # smtp2go://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN} # # The {user}@{domain} effectively assembles the 'from' email address # the email will be transmitted from. If no email address is specified # then it will also become the 'to' address as well. # from email.utils import formataddr from json import dumps import logging import requests from .. import exception from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_bool, parse_emails, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase SMTP2GO_HTTP_ERROR_MAP = { 429: "To many requests.", } class NotifySMTP2Go(NotifyBase): """A wrapper for SMTP2Go Notifications.""" # The default descriptive name associated with the Notification service_name = "SMTP2Go" # The services URL service_url = "https://www.smtp2go.com/" # All notification requests are secure secure_protocol = "smtp2go" # SMTP2Go advertises they 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/smtp2go/" # Notify URL notify_url = "https://api.smtp2go.com/v3/email/send" # Support attachments attachment_support = True # Default Notify Format notify_format = NotifyFormat.HTML # The maximum amount of emails that can reside within a single # batch transfer default_batch_size = 100 # Define object templates templates = ( "{schema}://{user}@{host}/{apikey}/", "{schema}://{user}@{host}/{apikey}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "host": { "name": _("Domain"), "type": "string", "required": True, }, "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, "targets": { "name": _("Target Emails"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "name": { "name": _("From Name"), "type": "string", "map_to": "from_name", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("Email Header"), "prefix": "+", }, } def __init__( self, apikey, targets, cc=None, bcc=None, from_name=None, headers=None, batch=False, **kwargs, ): """Initialize SMTP2Go Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid SMTP2Go API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Validate our username if not self.user: msg = "No SMTP2Go username was specified." self.logger.warning(msg) raise TypeError(msg) # Acquire Email 'To' self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # For tracking our email -> name lookups self.names = {} self.headers = {} if headers: # Store our extra headers self.headers.update(headers) # Prepare Batch Mode Flag self.batch = batch # Get our From username (if specified) self.from_name = from_name # Get our from email address self.from_addr = f"{self.user}@{self.host}" if not is_email(self.from_addr): # Parse Source domain based on from_addr msg = f"Invalid ~From~ email format: {self.from_addr}" self.logger.warning(msg) raise TypeError(msg) 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( f"Dropped invalid To email ({recipient}) specified.", ) else: # If our target email list is empty we want to add ourselves to it self.targets.append( (self.from_name if self.from_name else False, self.from_addr) ) # 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( f"Dropped invalid Carbon Copy email ({recipient}) specified.", ) # 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 " f"({recipient}) specified.", ) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform SMTP2Go Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning("There are no Email recipients to notify") return False # 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 # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", } # 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 SMTP2Go attachment" f" {attachment.url(privacy=True)}." ) return False try: # Format our attachment attachments.append( { "filename": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "fileblob": attachment.base64(), "mimetype": attachment.mimetype, } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access SMTP2Go attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending SMTP2Go attachment" f" {attachment.url(privacy=True)}" ) sender = formataddr( (self.from_name if self.from_name else False, self.from_addr), charset="utf-8", ) # Prepare our payload payload = { # API Key "api_key": self.apikey, # Base payload options "sender": sender, "subject": title, # our To array "to": [], } if attachments: payload["attachments"] = attachments if self.notify_format == NotifyFormat.HTML: payload["html_body"] = body else: payload["text_body"] = body # Create a copy of the targets list emails = list(self.targets) for index in range(0, len(emails), batch_size): # Initialize our cc list cc = self.cc - self.bcc # Initialize our bcc list bcc = set(self.bcc) # Initialize our to list to = [] for to_addr in self.targets[index : index + batch_size]: # Strip target out of cc list if in To cc = cc - {to_addr[1]} # Strip target out of bcc list if in To bcc = bcc - {to_addr[1]} # Prepare our `to` to.append(formataddr(to_addr, charset="utf-8")) # Prepare our To payload["to"] = to if cc: # Format our cc addresses to support the Name field payload["cc"] = [ formataddr( (self.names.get(addr, False), addr), charset="utf-8" ) for addr in cc ] # Format our bcc addresses to support the Name field if bcc: # set our bcc variable (convert to list first so it's # JSON serializable) payload["bcc"] = list(bcc) # Store our header entries if defined into the payload # in their payload if self.headers: payload["custom_headers"] = [ {"header": k, "value": v} for k, v in self.headers.items() ] # 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( "SMTP2Go POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate})" ) self.logger.debug( "SMTP2Go Payload: %s", sanitize_payload(payload) ) # For logging output of success and errors; we get a head count # of our outbound details: _batch = self.targets[index : index + batch_size] verbose_dest = ( ", ".join([x[1] for x in _batch]) if len(_batch) <= 3 else f"{len(_batch)} recipients" ) # 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, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code, SMTP2GO_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send SMTP2Go notification to {}: " "{}{}error={}.".format( verbose_dest, 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 SMTP2Go notification to {verbose_dest}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending" f" SMTP2Go:{verbose_dest} " + "notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue except OSError as e: self.logger.warning( "An I/O error occurred while reading attachments" ) self.logger.debug(f"I/O 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.user, self.host, 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", } # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if self.from_name is not None: # from_name specified; pass it back on the url params["name"] = self.from_name if self.cc: # Handle our Carbon Copy Addresses params["cc"] = ",".join( [ "{}{}".format( "" if not e not in self.names else f"{self.names[e]}:", e, ) for e in self.cc ] ) if self.bcc: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join(self.bcc) # 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 ) return "{schema}://{user}@{host}/{apikey}/{targets}?{params}".format( schema=self.secure_protocol, host=self.host, user=NotifySMTP2Go.quote(self.user, safe=""), apikey=self.pprint(self.apikey, privacy, safe=""), targets=( "" if not has_targets else "/".join( [ NotifySMTP2Go.quote( "{}{}".format( "" if not e[0] else f"{e[0]}:", e[1] ), safe="", ) for e in self.targets ] ) ), params=NotifySMTP2Go.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 # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = NotifySMTP2Go.split_path(results["fullpath"]) # Our very first entry is reserved for our api key try: results["apikey"] = results["targets"].pop(0) except IndexError: # We're done - no API Key found results["apikey"] = None if "name" in results["qsd"] and len(results["qsd"]["name"]): # Extract from name to associate with from address results["from_name"] = NotifySMTP2Go.unquote( results["qsd"]["name"] ) # Handle 'to' email address if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append(results["qsd"]["to"]) # 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"] # 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() } # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifySMTP2Go.template_args["batch"]["default"] ) ) return results apprise-1.10.0/apprise/plugins/sns.py000066400000000000000000000604271517341665700175650ustar00rootroot00000000000000# 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 import OrderedDict from datetime import datetime, timezone from hashlib import sha256 import hmac from itertools import chain import re from xml.etree import ElementTree import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_list, validate_regex from .base import NotifyBase # Topic Detection # Summary: 256 Characters max, only alpha/numeric plus underscore (_) and # dash (-) additionally allowed. # # Soure: https://docs.aws.amazon.com/AWSSimpleQueueService/latest\ # /SQSDeveloperGuide/sqs-limits.html#limits-queues # # Allow a starting hashtag (#) specification to help eliminate possible # ambiguity between a topic that is comprised of all digits and a phone number IS_TOPIC = re.compile(r"^#?(?P[A-Za-z0-9_-]+)\s*$") # Because our AWS Access Key Secret contains slashes, we actually use the # region as a delimiter. This is a bit hacky; but it's much easier than having # users of this product search though this Access Key Secret and escape all # of the forward slashes! IS_REGION = re.compile( r"^\s*(?P[a-z]{2})-(?P[a-z-]+?)-(?P[0-9]+)\s*$", re.I ) # Extend HTTP Error Messages AWS_HTTP_ERROR_MAP = { 403: "Unauthorized - Invalid Access/Secret Key Combination.", } class NotifySNS(NotifyBase): """A wrapper for AWS SNS (Amazon Simple Notification)""" # The default descriptive name associated with the Notification service_name = "AWS Simple Notification Service (SNS)" # The services URL service_url = "https://aws.amazon.com/sns/" # The default secure protocol secure_protocol = "sns" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/sns/" # AWS is pretty good for handling data load so request limits # can occur in much shorter bursts request_rate_per_sec = 2.5 # The maximum length of the body # Source: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html 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}://{access_key_id}/{secret_access_key}/{region}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "access_key_id": { "name": _("Access Key ID"), "type": "string", "private": True, "required": True, }, "secret_access_key": { "name": _("Secret Access Key"), "type": "string", "private": True, "required": True, }, "region": { "name": _("Region"), "type": "string", "required": True, "regex": (r"^[a-z]{2}-[a-z-]+?-[0-9]+$", "i"), "map_to": "region_name", }, "target_phone_no": { "name": _("Target Phone No"), "type": "string", "map_to": "targets", "regex": (r"^[0-9\s)(+-]+$", "i"), }, "target_topic": { "name": _("Target Topic"), "type": "string", "map_to": "targets", "prefix": "#", "regex": (r"^[A-Za-z0-9_-]+$", "i"), }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "access": { "alias_of": "access_key_id", }, "secret": { "alias_of": "secret_access_key", }, "region": { "alias_of": "region", }, "to": { "alias_of": "targets", }, }, ) def __init__( self, access_key_id, secret_access_key, region_name, targets=None, **kwargs, ): """Initialize Notify AWS SNS Object.""" super().__init__(**kwargs) # Store our AWS API Access Key self.aws_access_key_id = validate_regex(access_key_id) if not self.aws_access_key_id: msg = "An invalid AWS Access Key ID was specified." self.logger.warning(msg) raise TypeError(msg) # Store our AWS API Secret Access key self.aws_secret_access_key = validate_regex(secret_access_key) if not self.aws_secret_access_key: msg = ( "An invalid AWS Secret Access Key " f"({secret_access_key}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Acquire our AWS Region Name: # eg. us-east-1, cn-north-1, us-west-2, ... self.aws_region_name = validate_regex( region_name, *self.template_tokens["region"]["regex"] ) if not self.aws_region_name: msg = f"An invalid AWS Region ({region_name}) was specified." self.logger.warning(msg) raise TypeError(msg) # Initialize topic list self.topics = [] # Initialize numbers list self.phone = [] # Set our notify_url based on our region self.notify_url = f"https://sns.{self.aws_region_name}.amazonaws.com/" # AWS Service Details self.aws_service_name = "sns" self.aws_canonical_uri = "/" # AWS Authentication Details self.aws_auth_version = "AWS4" self.aws_auth_algorithm = "AWS4-HMAC-SHA256" self.aws_auth_request = "aws4_request" # Validate targets and drop bad ones: for target in parse_list(targets): result = is_phone_no(target) if result: # store valid phone number in E.164 format self.phone.append("+{}".format(result["full"])) continue result = IS_TOPIC.match(target) if result: # store valid topic self.topics.append(result.group("name")) continue self.logger.warning( f"Dropped invalid phone/topic ({target}) specified.", ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Wrapper to send_notification since we can alert more then one channel.""" if len(self.phone) == 0 and len(self.topics) == 0: # We have a bot token and no target(s) to message self.logger.warning("No AWS targets to notify.") return False # Initiaize our error tracking error_count = 0 # Create a copy of our phone #'s to notify against phone = list(self.phone) topics = list(self.topics) while len(phone) > 0: # Get Phone No no = phone.pop(0) # Prepare SNS Message Payload payload = { "Action": "Publish", "Message": body, "Version": "2010-03-31", "PhoneNumber": no, } (result, _) = self._post(payload=payload, to=no) if not result: error_count += 1 # Send all our defined topic id's while len(topics): # Get Topic topic = topics.pop(0) # First ensure our topic exists, if it doesn't, it gets created payload = { "Action": "CreateTopic", "Version": "2010-03-31", "Name": topic, } (result, response) = self._post(payload=payload, to=topic) if not result: error_count += 1 continue # Get the Amazon Resource Name topic_arn = response.get("topic_arn") if not topic_arn: # Could not acquire our topic; we're done error_count += 1 continue # Build our payload now that we know our topic_arn payload = { "Action": "Publish", "Version": "2010-03-31", "TopicArn": topic_arn, "Message": body, } # Send our payload to AWS (result, _) = self._post(payload=payload, to=topic) if not result: error_count += 1 return error_count == 0 def _post(self, payload, to): """Wrapper to request.post() to manage it's response better and make the send() function cleaner and easier to maintain. This function returns True if the _post was successful and False if it wasn't. """ # Always call throttle before any remote server i/o is made; for AWS # time plays a huge factor in the headers being sent with the payload. # So for AWS (SNS) requests we must throttle before they're generated # and not directly before the i/o call like other notification # services do. self.throttle() # Convert our payload from a dict() into a urlencoded string payload = NotifySNS.urlencode(payload) # Prepare our Notification URL # Prepare our AWS Headers based on our payload headers = self.aws_prepare_request(payload) self.logger.debug( "AWS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"AWS Payload: {payload!s}") try: r = requests.post( self.notify_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifySNS.http_response_code_lookup( r.status_code, AWS_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send AWS notification to {}: " "{}{}error={}.".format( to, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) return (False, NotifySNS.aws_response_to_dict(r.text)) else: self.logger.info(f'Sent AWS notification to "{to}".') except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending AWS " f'notification to "{to}".', ) self.logger.debug(f"Socket Exception: {e!s}") return (False, NotifySNS.aws_response_to_dict(None)) return (True, NotifySNS.aws_response_to_dict(r.text)) def aws_prepare_request(self, payload, reference=None): """Takes the intended payload and returns the headers for it. The payload is presumed to have been already urlencoded() """ # Define our AWS header headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", # Populated below "Content-Length": 0, "Authorization": None, "X-Amz-Date": None, } # Get a reference time (used for header construction) reference = datetime.now(timezone.utc) # Provide Content-Length headers["Content-Length"] = str(len(payload)) # Amazon Date Format amzdate = reference.strftime("%Y%m%dT%H%M%SZ") headers["X-Amz-Date"] = amzdate # Credential Scope scope = "{date}/{region}/{service}/{request}".format( date=reference.strftime("%Y%m%d"), region=self.aws_region_name, service=self.aws_service_name, request=self.aws_auth_request, ) # Similar to headers; but a subset. keys must be lowercase signed_headers = OrderedDict( [ ("content-type", headers["Content-Type"]), ( "host", f"{self.aws_service_name}" f".{self.aws_region_name}.amazonaws.com", ), ("x-amz-date", headers["X-Amz-Date"]), ] ) # # Build Canonical Request Object # canonical_request = "\n".join( [ # Method "POST", # URL self.aws_canonical_uri, # Query String (none set for POST) "", # Header Content (must include \n at end!) # All entries except characters in amazon date must be # lowercase "\n".join([f"{k}:{v}" for k, v in signed_headers.items()]) + "\n", # Header Entries (in same order identified above) ";".join(signed_headers.keys()), # Payload sha256(payload.encode("utf-8")).hexdigest(), ] ) # Prepare Unsigned Signature to_sign = "\n".join( [ self.aws_auth_algorithm, amzdate, scope, sha256(canonical_request.encode("utf-8")).hexdigest(), ] ) # Our Authorization header headers["Authorization"] = ", ".join( [ ( f"{self.aws_auth_algorithm} " f"Credential={self.aws_access_key_id}/{scope}" ), "SignedHeaders={signed_headers}".format( signed_headers=";".join(signed_headers.keys()), ), f"Signature={self.aws_auth_signature(to_sign, reference)}", ] ) return headers def aws_auth_signature(self, to_sign, reference): """Generates a AWS v4 signature based on provided payload which should be in the form of a string.""" def _sign(key, msg, to_hex=False): """Perform AWS Signing.""" if to_hex: return hmac.new(key, msg.encode("utf-8"), sha256).hexdigest() return hmac.new(key, msg.encode("utf-8"), sha256).digest() date = _sign( (self.aws_auth_version + self.aws_secret_access_key).encode( "utf-8" ), reference.strftime("%Y%m%d"), ) region = _sign(date, self.aws_region_name) service = _sign(region, self.aws_service_name) signed = _sign(service, self.aws_auth_request) return _sign(signed, to_sign, to_hex=True) @staticmethod def aws_response_to_dict(aws_response): """Takes an AWS Response object as input and returns it as a dictionary but not befor extracting out what is useful to us first. eg: IN: arn:aws:sns:us-east-1:000000000000:abcd 604bef0f-369c-50c5-a7a4-bbd474c83d6a OUT: { type: 'CreateTopicResponse', request_id: '604bef0f-369c-50c5-a7a4-bbd474c83d6a', topic_arn: 'arn:aws:sns:us-east-1:000000000000:abcd', } """ # Define ourselves a set of directives we want to keep if found and # then identify the value we want to map them to in our response # object aws_keep_map = { "RequestId": "request_id", "TopicArn": "topic_arn", "MessageId": "message_id", # Error Message Handling "Type": "error_type", "Code": "error_code", "Message": "error_message", } # A default response object that we'll manipulate as we pull more data # from our AWS Response object response = { "type": None, "request_id": None, } try: # we build our tree, but not before first eliminating any # reference to namespacing (if present) as it makes parsing # the tree so much easier. root = ElementTree.fromstring( re.sub(r' xmlns="[^"]+"', "", aws_response, count=1) ) # Store our response tag object name response["type"] = str(root.tag) def _xml_iter(root, response): if len(root) > 0: for child in root: # use recursion to parse everything _xml_iter(child, response) elif root.tag in aws_keep_map: response[aws_keep_map[root.tag]] = (root.text).strip() # Recursivly iterate over our AWS Response to extract the # fields we're interested in in efforts to populate our response # object. _xml_iter(root, response) except (ElementTree.ParseError, TypeError): # bad data just causes us to generate a bad response pass return response @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.aws_access_key_id, self.aws_secret_access_key, self.aws_region_name, ) 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) return ( "{schema}://{key_id}/{key_secret}/{region}/{targets}/" "?{params}".format( schema=self.secure_protocol, key_id=self.pprint(self.aws_access_key_id, privacy, safe=""), key_secret=self.pprint( self.aws_secret_access_key, privacy, mode=PrivacyMode.Secret, safe="", ), region=NotifySNS.quote(self.aws_region_name, safe=""), targets="/".join( [ NotifySNS.quote(x) for x in chain( # Phone # are already prefixed with a plus symbol self.phone, # Topics are prefixed with a pound/hashtag symbol [f"#{x}" for x in self.topics], ) ] ), params=NotifySNS.urlencode(params), ) ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.phone) + len(self.topics) @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 AWS Access Key ID is stored in the hostname access_key_id = NotifySNS.unquote(results["host"]) # Our AWS Access Key Secret contains slashes in it which unfortunately # means it is of variable length after the hostname. Since we require # that the user provides the region code, we intentionally use this # as our delimiter to detect where our Secret is. secret_access_key = None region_name = None # We need to iterate over each entry in the fullpath and find our # region. Once we get there we stop and build our secret from our # accumulated data. secret_access_key_parts = [] # Start with a list of entries to work with entries = NotifySNS.split_path(results["fullpath"]) # Section 1: Get Region and Access Secret index = 0 for i, entry in enumerate(entries): # Are we at the region yet? result = IS_REGION.match(entry) if result: # We found our Region; Rebuild our access key secret based on # all entries we found prior to this: secret_access_key = "/".join(secret_access_key_parts) # Ensure region is nicely formatted region_name = "{country}-{area}-{no}".format( country=result.group("country").lower(), area=result.group("area").lower(), no=result.group("no"), ) # Track our index as we'll use this to grab the remaining # content in the next Section index = i + 1 # We're done with Section 1 break # Store our secret parts secret_access_key_parts.append(entry) # Section 2: Get our Recipients (basically all remaining entries) results["targets"] = entries[index:] # 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"] += NotifySNS.parse_list(results["qsd"]["to"]) # Handle secret_access_key over-ride if "secret" in results["qsd"] and len(results["qsd"]["secret"]): results["secret_access_key"] = NotifySNS.unquote( results["qsd"]["secret"] ) else: results["secret_access_key"] = secret_access_key # Handle access key id over-ride if "access" in results["qsd"] and len(results["qsd"]["access"]): results["access_key_id"] = NotifySNS.unquote( results["qsd"]["access"] ) else: results["access_key_id"] = access_key_id # Handle region name id over-ride if "region" in results["qsd"] and len(results["qsd"]["region"]): results["region_name"] = NotifySNS.unquote( results["qsd"]["region"] ) else: results["region_name"] = region_name # Return our result set return results apprise-1.10.0/apprise/plugins/sparkpost.py000066400000000000000000000677231517341665700210160ustar00rootroot00000000000000# 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. # Signup @ https://www.sparkpost.com # # Ensure you've added a Senders Domain and have generated yourself an # API Key at: # https://app.sparkpost.com/dashboard # Note: For SMTP Access, your API key must have at least been granted the # 'Send via SMTP' privileges. # From here you can click on the domain you're interested in. You can acquire # the API Key from here which will look something like: # 1e1d479fcf1a87527e9411e083c700689fa1acdc # # Knowing this, you can buid your sparkpost url as follows: # sparkpost://{user}@{domain}/{apikey} # sparkpost://{user}@{domain}/{apikey}/{email} # # You can email as many addresses as you want as: # sparkpost://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN} # # The {user}@{domain} effectively assembles the 'from' email address # the email will be transmitted from. If no email address is specified # then it will also become the 'to' address as well. # # The {domain} must cross reference a domain you've set up with Spark Post # # API Documentation: https://developers.sparkpost.com/api/ # Specifically: https://developers.sparkpost.com/api/transmissions/ import contextlib from email.utils import formataddr from json import dumps, loads import logging import requests from .. import exception from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_bool, parse_emails, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase # Provide some known codes SparkPost uses and what they translate to: # Based on https://www.sparkpost.com/docs/tech-resources/extended-error-codes/ SPARKPOST_HTTP_ERROR_MAP = { 400: "A bad request was made to the server", 401: "Invalid User ID and/or Unauthorized User", 403: "Permission Denied; the provided API Key was not valid", 404: "There is a problem with the server query URI.", 405: "Invalid HTTP method", 420: "Sending limit reached.", 422: "Invalid data/format/type/length", 429: "To many requests per sec; rate limit", } class SparkPostRegion: """Regions.""" US = "us" EU = "eu" # SparkPost APIs SPARKPOST_API_LOOKUP = { SparkPostRegion.US: "https://api.sparkpost.com/api/v1", SparkPostRegion.EU: "https://api.eu.sparkpost.com/api/v1", } # A List of our regions we can use for verification SPARKPOST_REGIONS = ( SparkPostRegion.US, SparkPostRegion.EU, ) class NotifySparkPost(NotifyBase): """A wrapper for SparkPost Notifications.""" # The default descriptive name associated with the Notification service_name = "SparkPost" # The services URL service_url = "https://sparkpost.com/" # Support attachments attachment_support = True # All notification requests are secure secure_protocol = "sparkpost" # SparkPost advertises they allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.20 # Words straight from their website: # https://developers.sparkpost.com/api/#header-rate-limiting # These limits are dynamic, but as a general rule, wait 1 to 5 seconds # after receiving a 429 response before requesting again. # As a simple work around, this is what we will do... Wait X seconds # (defined below) before trying again when we get a 429 error sparkpost_retry_wait_sec = 5 # The maximum number of times we'll retry to send our message when we've # reached a throttling situatin before giving up sparkpost_retry_attempts = 3 # The maximum amount of emails that can reside within a single # batch transfer based on: # https://www.sparkpost.com/docs/tech-resources/\ # smtp-rest-api-performance/#sending-via-the-transmission-rest-api default_batch_size = 2000 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/sparkpost/" # Default Notify Format notify_format = NotifyFormat.HTML # Define object templates templates = ( "{schema}://{user}@{host}/{apikey}/", "{schema}://{user}@{host}/{apikey}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "host": { "name": _("Domain"), "type": "string", "required": True, }, "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, "targets": { "name": _("Target Emails"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "name": { "name": _("From Name"), "type": "string", "map_to": "from_name", }, "region": { "name": _("Region Name"), "type": "choice:string", "values": SPARKPOST_REGIONS, "default": SparkPostRegion.US, "map_to": "region_name", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("Email Header"), "prefix": "+", }, "tokens": { "name": _("Template Tokens"), "prefix": ":", }, } def __init__( self, apikey, targets, cc=None, bcc=None, from_name=None, region_name=None, headers=None, tokens=None, batch=None, **kwargs, ): """Initialize SparkPost Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid SparkPost API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # Validate our username if not self.user: msg = "No SparkPost username was specified." self.logger.warning(msg) raise TypeError(msg) # Acquire Email 'To' self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # For tracking our email -> name lookups self.names = {} # Store our region try: self.region_name = ( self.template_args["region"]["default"] if region_name is None else region_name.lower() ) if self.region_name not in SPARKPOST_REGIONS: # allow the outer except to handle this common response raise IndexError() except (AttributeError, IndexError, TypeError): # Invalid region specified msg = f"The SparkPost region specified ({region_name}) is invalid." self.logger.warning(msg) raise TypeError(msg) from None # Get our From username (if specified) self.from_name = from_name # Get our from email address self.from_addr = f"{self.user}@{self.host}" if not is_email(self.from_addr): # Parse Source domain based on from_addr msg = f"Invalid ~From~ email format: {self.from_addr}" self.logger.warning(msg) raise TypeError(msg) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.tokens = {} if tokens: # Store our template tokens self.tokens.update(tokens) # Prepare Batch Mode Flag self.batch = ( self.template_args["batch"]["default"] if batch is None else batch ) 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( f"Dropped invalid To email ({recipient}) specified.", ) else: # If our target email list is empty we want to add ourselves to it self.targets.append( (self.from_name if self.from_name else False, self.from_addr) ) # 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( f"Dropped invalid Carbon Copy email ({recipient}) specified.", ) # 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 " f"({recipient}) specified.", ) def __post(self, payload, retry): """Performs the actual post and returns the response.""" # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", "Authorization": self.apikey, } # Prepare our URL as it's based on our hostname url = f"{SPARKPOST_API_LOOKUP[self.region_name]}/transmissions/" # 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( "SparkPost POST URL:" f" {url} (cert_verify={self.verify_certificate})" ) self.logger.debug( "SparkPost Payload: %s", sanitize_payload(payload) ) wait = None # For logging output of success and errors; we get a head count # of our outbound details: verbose_dest = ( ", ".join([x["address"]["email"] for x in payload["recipients"]]) if len(payload["recipients"]) <= 3 else "{} recipients".format(len(payload["recipients"])) ) # Initialize our response object json_response = {} # Set ourselves a status code status_code = -1 while 1: # pragma: no branch # Always call throttle before any remote server i/o is made self.throttle(wait=wait) try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # A Good response (200) looks like this: # "results": { # "total_rejected_recipients": 0, # "total_accepted_recipients": 1, # "id": "11668787484950529" # } # } # # A Bad response looks like this: # { # "errors": [ # { # "description": # "Unconfigured or unverified sending domain.", # "code": "7001", # "message": "Invalid domain" # } # ] # } # with contextlib.suppress( AttributeError, TypeError, ValueError ): # Load our JSON Object if we can # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None json_response = loads(r.content) status_code = r.status_code payload["recipients"] = [] if status_code == requests.codes.ok: self.logger.info( f"Sent SparkPost notification to {verbose_dest}." ) return status_code, json_response # We had a problem if we get here status_str = NotifyBase.http_response_code_lookup( status_code, SPARKPOST_API_LOOKUP ) self.logger.warning( "Failed to send SparkPost notification to {}: " "{}{}error={}.".format( verbose_dest, status_str, ", " if status_str else "", status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) if status_code == requests.codes.too_many_requests and retry: retry = retry - 1 if retry > 0: wait = self.sparkpost_retry_wait_sec continue except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending SparkPost " "notification" ) self.logger.debug(f"Socket Exception: {e!s}") # Anything else and we're done return status_code, json_response # Our code will never reach here (outside of infinite while loop above) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform SparkPost Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no SparkPost Email recipients to notify" ) return False # Initialize our has_error flag has_error = False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size reply_to = formataddr( (self.from_name if self.from_name else False, self.from_addr), charset="utf-8", ) payload = { "options": { # When set to True, an image is included with the email which # is used to detect if the user looked at the image or not. "open_tracking": False, # Track if links were clicked that were found within email "click_tracking": False, }, "content": { "from": { "name": ( self.from_name if self.from_name else self.app_desc ), "email": self.from_addr, }, # SparkPost does not allow empty subject lines or lines that # only contain whitespace; Since Apprise allows an empty title # parameter we swap empty title entries with the period "subject": title if title.strip() else ".", "reply_to": reply_to, }, } if self.notify_format == NotifyFormat.HTML: payload["content"]["html"] = body else: payload["content"]["text"] = body if attach and self.attachment_support: # Prepare ourselves an attachment object payload["content"]["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 SparkPost attachment" f" {attachment.url(privacy=True)}." ) return False try: # Prepare API Upload Payload payload["content"]["attachments"].append( { "name": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "type": attachment.mimetype, "data": attachment.base64(), } ) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access SparkPost attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending SparkPost attachment" f" {attachment.url(privacy=True)}" ) # Take a copy of our token dictionary tokens = self.tokens.copy() # Apply some defaults template values 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) tokens["app_url"] = self.app_url # Store our tokens if they're identified payload["substitution_data"] = self.tokens # Create a copy of the targets list emails = list(self.targets) for index in range(0, len(emails), batch_size): # Generate our email listing payload["recipients"] = [] # Initialize our cc list cc = self.cc - self.bcc # Initialize our bcc list bcc = set(self.bcc) # Initialize our headers headers = self.headers.copy() for addr in self.targets[index : index + batch_size]: entry = { "address": { "email": addr[1], } } # Strip target out of cc list if in To cc = cc - {addr[1]} # Strip target out of bcc list if in To bcc = bcc - {addr[1]} if addr[0]: entry["address"]["name"] = addr[0] # Add our recipient to our list payload["recipients"].append(entry) if cc: # Handle our cc List for addr in cc: entry = { "address": { "email": addr, "header_to": # Take the first email in the To self.targets[index : index + batch_size][0][1], }, } if self.names.get(addr): entry["address"]["name"] = self.names[addr] # Add our recipient to our list payload["recipients"].append(entry) headers["CC"] = ",".join(cc) # Handle our bcc for addr in bcc: # Add our recipient to our list payload["recipients"].append( { "address": { "email": addr, "header_to": # Take the first email in the To self.targets[index : index + batch_size][0][1], }, } ) if headers: payload["content"]["headers"] = headers # Send our message status_code, _response = self.__post( payload, self.sparkpost_retry_attempts ) # Failed if status_code != requests.codes.ok: has_error = True 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.user, self.apikey, self.host) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "region": self.region_name, "batch": "yes" if self.batch else "no", } # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our template tokens into our parameters params.update({f":{k}": v for k, v in self.tokens.items()}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if self.from_name is not None: # from_name specified; pass it back on the url params["name"] = self.from_name if self.cc: # Handle our Carbon Copy Addresses params["cc"] = ",".join( [ "{}{}".format( "" if not e not in self.names else f"{self.names[e]}:", e, ) for e in self.cc ] ) if self.bcc: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join(self.bcc) # 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 ) return "{schema}://{user}@{host}/{apikey}/{targets}/?{params}".format( schema=self.secure_protocol, host=self.host, user=NotifySparkPost.quote(self.user, safe=""), apikey=self.pprint(self.apikey, privacy, safe=""), targets=( "" if not has_targets else "/".join( [ NotifySparkPost.quote( "{}{}".format( "" if not e[0] else f"{e[0]}:", e[1] ), safe="", ) for e in self.targets ] ) ), params=NotifySparkPost.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 # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = NotifySparkPost.split_path(results["fullpath"]) # Our very first entry is reserved for our api key try: results["apikey"] = results["targets"].pop(0) except IndexError: # We're done - no API Key found results["apikey"] = None if "name" in results["qsd"] and len(results["qsd"]["name"]): # Extract from name to associate with from address results["from_name"] = NotifySparkPost.unquote( results["qsd"]["name"] ) if "region" in results["qsd"] and len(results["qsd"]["region"]): # Extract region results["region_name"] = NotifySparkPost.unquote( results["qsd"]["region"] ) # Handle 'to' email address if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append(results["qsd"]["to"]) # 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"] # 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() } # Add our template tokens (if defined) results["tokens"] = { NotifyBase.unquote(x): NotifyBase.unquote(y) for x, y in results["qsd:"].items() } # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifySparkPost.template_args["batch"]["default"] ) ) return results apprise-1.10.0/apprise/plugins/spike.py000066400000000000000000000135131517341665700200670ustar00rootroot00000000000000# # 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. # Details at: # https://www.spike.sh/docs/alerts/send-alerts-to-spike/ import json import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import validate_regex from .base import NotifyBase class NotifySpike(NotifyBase): """A wrapper for Spike.sh Notifications.""" # The default descriptive name associated with the Notification service_name = _("Spike.sh") # The services URL service_url = "https://www.spike.sh/" # The default secure protocol secure_protocol = "spike" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/spike/" # URL used to send notifications with notify_url = "https://api.spike.sh/v1/alerts/" templates = ("{schema}://{token}",) template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Integration Key"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]{32}$", "i"), }, }, ) def __init__(self, token, **kwargs): """Initialize Spike.sh Object.""" super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The Spike.sh integration key ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.webhook_url = f"{self.notify_url}{self.token}" @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 url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = self.url_parameters(privacy=privacy, *args, **kwargs) return ( f"{self.secure_protocol}://" f"{self.pprint(self.token, privacy, mode=PrivacyMode.Secret)}/" f"?{self.urlencode(params)}" ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Send Spike.sh Notification.""" payload = { "message": title if title else body, "description": body, } headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Always call throttle before any remote server i/o is made self.throttle() try: response = requests.post( self.webhook_url, headers=headers, data=json.dumps(payload), verify=self.verify_certificate, timeout=self.request_timeout, ) if response.status_code != requests.codes.ok: self.logger.warning( "Spike.sh notification failed: %d - %s", response.status_code, response.text, ) return False except requests.RequestException as e: self.logger.warning(f"Spike.sh Exception: {e}") return False self.logger.info("Spike.sh notification sent successfully.") return True @staticmethod def parse_url(url): """Parses the URL and returns arguments to re-instantiate the object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: return results # Access token if "token" in results["qsd"] and len(results["qsd"]["token"]): # Extract the account sid from an argument results["token"] = NotifySpike.unquote(results["qsd"]["token"]) else: # Retrieve the token from the host results["token"] = NotifySpike.unquote(results["host"]) return results @staticmethod def parse_native_url(url): """Supports reverse-parsing a Spike.sh native URL into an Apprise one.""" match = re.match( r"^https://api\.spike\.sh/v1/alerts/([a-z0-9]{32})$", url, re.I ) if not match: return None return NotifySpike.parse_url( f"{NotifySpike.secure_protocol}://{match.group(1)}" ) apprise-1.10.0/apprise/plugins/splunk.py000066400000000000000000000413211517341665700202660ustar00rootroot00000000000000# 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. # Splunk On-Call # API: https://portal.victorops.com/public/api-docs.html # Main: https://www.splunk.com/en_us/products/on-call.html # Routing Keys https://help.victorops.com/knowledge-base/routing-keys/ # Setup: https://help.victorops.com/knowledge-base/rest-endpoint-integration\ # -guide/ from json import dumps import re import requests from ..common import NOTIFY_TYPES, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase class SplunkAction: """Tracks the actions supported by Apprise Splunk Plugin.""" # Use mapping (specify :key=arg to over-ride) MAP = "map" # Creates a timeline event but does not trigger an incident INFO = "info" # Triggers a warning (possibly causing incident) in all cases WARNING = "warning" # Triggers an incident in all cases CRITICAL = "critical" # Acknowldege entity_id provided in all cases ACKNOWLEDGE = "acknowledgement" # Recovery entity_id provided in all cases RECOVERY = "recovery" # Resolve (aliase of Recover) RESOLVE = "resolve" # Define our Splunk Actions SPLUNK_ACTIONS = ( SplunkAction.MAP, SplunkAction.INFO, SplunkAction.ACKNOWLEDGE, SplunkAction.WARNING, SplunkAction.RECOVERY, SplunkAction.RESOLVE, SplunkAction.CRITICAL, ) class SplunkMessageType: """Defines the supported splunk message types.""" # Triggers an incident CRITICAL = "CRITICAL" # May trigger an incident, depending on your settings WARNING = "WARNING" # Acks an incident ACKNOWLEDGEMENT = "ACKNOWLEDGEMENT" # Creates a timeline event but does not trigger an incident INFO = "INFO" # Resolves an incident RECOVERY = "RECOVERY" # Defines our supported message types SPLUNK_MESSAGE_TYPES = ( SplunkMessageType.CRITICAL, SplunkMessageType.WARNING, SplunkMessageType.ACKNOWLEDGEMENT, SplunkMessageType.INFO, SplunkMessageType.RECOVERY, ) class NotifySplunk(NotifyBase): """A wrapper for Splunk Notifications.""" # The default descriptive name associated with the Notification service_name = _("Splunk On-Call") # The services URL service_url = "https://www.splunk.com/en_us/products/on-call.html" # The default secure protocol secure_protocol = ("splunk", "victorops") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/splunk/" # Notification URL notify_url = ( "https://alert.victorops.com/integrations/generic/20131114/" "alert/{apikey}/{routing_key}" ) # Define object templates templates = ( "{schema}://{routing_key}@{apikey}", "{schema}://{routing_key}@{apikey}/{entity_id}", ) # The title is not used title_maxlen = 60 # body limit body_maxlen = 400 # Defines our default message mapping splunk_message_map = { # Creates a timeline event but doesnot trigger an incident NotifyType.INFO: SplunkMessageType.INFO, # Resolves an incident NotifyType.SUCCESS: SplunkMessageType.RECOVERY, # May trigger an incident, depending on your settings NotifyType.WARNING: SplunkMessageType.WARNING, # Triggers an incident NotifyType.FAILURE: SplunkMessageType.CRITICAL, } # 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, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9_-]+$", "i"), }, "routing_key": { "name": _("Target Routing Key"), "type": "string", "required": True, "regex": (r"^[A-Z0-9_-]+$", "i"), }, "entity_id": { # Provide a value such as: "disk space/db01.mycompany.com" "name": _("Entity ID"), "type": "string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "apikey": { "alias_of": "apikey", }, "routing_key": { "alias_of": "routing_key", }, "route": { "alias_of": "routing_key", }, "entity_id": { "alias_of": "entity_id", }, "action": { "name": _("Action"), "type": "choice:string", "values": SPLUNK_ACTIONS, "default": SPLUNK_ACTIONS[0], }, }, ) # Define any kwargs we're using template_kwargs = { "mapping": { "name": _("Action Mapping"), "prefix": ":", }, } def __init__( self, apikey, routing_key, entity_id=None, action=None, mapping=None, **kwargs, ): """Initialize Splunk Object.""" super().__init__(**kwargs) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"The Splunk API Key specified ({apikey}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.routing_key = validate_regex( routing_key, *self.template_tokens["routing_key"]["regex"] ) if not self.routing_key: msg = ( f"The Splunk Routing Key specified ({routing_key}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) if not ( isinstance(entity_id, str) and len(entity_id.strip(" \r\n\t\v/")) ): # Use routing key self.entity_id = f"{self.app_id}/{self.routing_key}" else: # Assign what was defined: self.entity_id = entity_id.strip(" \r\n\t\v/") if action and isinstance(action, str): self.action = next( (a for a in SPLUNK_ACTIONS if a.startswith(action)), None ) if self.action not in SPLUNK_ACTIONS: msg = f"The Splunk action specified ({action}) is invalid." self.logger.warning(msg) raise TypeError(msg) else: self.action = self.template_args["action"]["default"] # Store our mappings self.mapping = self.splunk_message_map.copy() if mapping and isinstance(mapping, dict): for k_, v_ in mapping.items(): # Get our mapping k = next((t for t in NOTIFY_TYPES if t.startswith(k_)), None) if not k: msg = ( f"The Splunk mapping key specified ({k_}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) v_upper = v_.upper() v = next( (v for v in SPLUNK_MESSAGE_TYPES if v.startswith(v_upper)), None, ) if not v: msg = ( f"The Splunk mapping value (assigned to {k}) " f"specified ({v_}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Update our mapping self.mapping[k] = v 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/json", } # Set up our message type if self.action == SplunkAction.MAP: # Use Mapping message_type = self.mapping[notify_type] elif self.action == SplunkAction.ACKNOWLEDGE: # Always Acknowledge message_type = SplunkMessageType.ACKNOWLEDGEMENT elif self.action == SplunkAction.INFO: # Creates a timeline event but does not trigger an incident message_type = SplunkMessageType.INFO elif self.action == SplunkAction.CRITICAL: # Always create Incident message_type = SplunkMessageType.CRITICAL elif self.action == SplunkAction.WARNING: # Always trigger warning (potentially creating incident) message_type = SplunkMessageType.WARNING else: # self.action == SplunkAction.RECOVERY or SplunkAction.RESOLVE # Always Recover message_type = SplunkMessageType.RECOVERY # Prepare our payload payload = { "entity_id": self.entity_id, "message_type": message_type, "entity_display_name": title if title else self.app_desc, "state_message": body, "monitoring_tool": self.app_id, } notify_url = self.notify_url.format( apikey=self.apikey, routing_key=self.routing_key ) self.logger.debug( "Splunk GET URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Splunk Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=dumps(payload).encode("utf-8"), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Sample Response # { # "result" : "success", # "entity_id" : "disk space/db01.mycompany.com" # } if r.status_code != requests.codes.ok: # We had a problem status_str = NotifySplunk.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Splunk 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 Splunk notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Splunk notification." ) 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[0], self.routing_key, self.entity_id, self.apikey, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "action": self.action, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our assignment extra's into our parameters params.update({f":{k.value}": v for k, v in self.mapping.items()}) return "{schema}://{routing_key}@{apikey}/{entity_id}?{params}".format( schema=self.secure_protocol[0], routing_key=self.routing_key, entity_id=( "" if self.entity_id == self.routing_key else self.entity_id ), apikey=self.pprint(self.apikey, privacy, safe=""), params=NotifySplunk.urlencode(params), ) @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 # Entity ID if "entity_id" in results["qsd"] and len(results["qsd"]["entity_id"]): results["entity_id"] = NotifySplunk.unquote( results["qsd"]["entity_id"] ) else: results["entity_id"] = NotifySplunk.unquote(results["fullpath"]) # API Key if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): results["apikey"] = NotifySplunk.unquote(results["qsd"]["apikey"]) else: results["apikey"] = NotifySplunk.unquote(results["host"]) # Routing Key if "routing_key" in results["qsd"] and len( results["qsd"]["routing_key"] ): results["routing_key"] = NotifySplunk.unquote( results["qsd"]["routing_key"] ) elif "route" in results["qsd"] and len(results["qsd"]["route"]): results["routing_key"] = NotifySplunk.unquote( results["qsd"]["route"] ) else: results["routing_key"] = NotifySplunk.unquote(results["user"]) # Store our action (if defined) if "action" in results["qsd"] and len(results["qsd"]["action"]): results["action"] = NotifySplunk.unquote(results["qsd"]["action"]) # store any custom mapping defined results["mapping"] = { NotifySplunk.unquote(x): NotifySplunk.unquote(y) for x, y in results["qsd:"].items() } return results @staticmethod def parse_native_url(url): """ Support https://alert.victorops.com/integrations/generic/20131114/ \ alert/apikey/routing_key """ result = re.match( r"^https?://alert\.victorops\.com/integrations/generic/" r"(?P[0-9]+)/alert/(?P[0-9a-z_-]+)" r"(/(?P[^?/]+))" r"(/(?P[^?]+))?/*" r"(?P\?.+)?$", url, re.I, ) if result: return NotifySplunk.parse_url( "{schema}://{routing_key}@{apikey}/{entity_id}{params}".format( schema=NotifySplunk.secure_protocol[0], apikey=result.group("apikey"), routing_key=result.group("routing_key"), entity_id=( "" if not result.group("entity_id") else result.group("entity_id") ), params=( "" if not result.group("params") else result.group("params") ), ) ) return None apprise-1.10.0/apprise/plugins/spugpush.py000066400000000000000000000127601517341665700206350ustar00rootroot00000000000000# # 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. # Details at: # https://docs.spug.dev/push/ import json import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import validate_regex from .base import NotifyBase class NotifySpugpush(NotifyBase): """A wrapper for SpugPush Notifications.""" # The default descriptive name associated with the Notification service_name = _("SpugPush") # The services URL service_url = "https://docs.spug.dev/push/" # The default secure protocol secure_protocol = "spugpush" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/spugpush/" # URL used to send notifications with notify_url = "https://push.spug.dev/send/" templates = ("{schema}://{token}",) template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Access Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-zA-Z0-9_-]{32,64}$", "i"), }, }, ) def __init__(self, token, **kwargs): """Initialize SpugPush Object.""" super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The SpugPush token ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.webhook_url = f"{self.notify_url}{self.token}" 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 ( f"{self.secure_protocol}://" f"{self.pprint(self.token, privacy, mode=PrivacyMode.Secret)}/" f"?{self.urlencode(params)}" ) @property def url_identifier(self): """Returns a unique identifier for this plugin instance.""" return (self.secure_protocol, self.token) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Send a SpugPush Notification.""" payload = { "title": title if title else body, "content": body, } headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } self.throttle() try: response = requests.post( self.webhook_url, headers=headers, data=json.dumps(payload), verify=self.verify_certificate, timeout=self.request_timeout, ) if response.status_code != requests.codes.ok: self.logger.warning( "SpugPush notification failed: %d - %s", response.status_code, response.text, ) return False except requests.RequestException as e: self.logger.warning(f"SpugPush Exception: {e}") return False self.logger.info("SpugPush notification sent successfully.") return True @staticmethod def parse_url(url): """Parses the URL and returns arguments to re-instantiate the object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: return results if "token" in results["qsd"] and results["qsd"]["token"]: results["token"] = NotifySpugpush.unquote(results["qsd"]["token"]) else: results["token"] = NotifySpugpush.unquote(results["host"]) return results @staticmethod def parse_native_url(url): """Parse native SpugPush webhook URL into Apprise format.""" match = re.match( r"^https://push\.spug\.dev/send/([a-z0-9_-]+)$", url, re.I ) if not match: return None return NotifySpugpush.parse_url( f"{NotifySpugpush.secure_protocol}://{match.group(1)}" ) apprise-1.10.0/apprise/plugins/streamlabs.py000066400000000000000000000404021517341665700211060ustar00rootroot00000000000000# 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 register an app # and generate an access token # # # This plugin will simply work using the url of: # streamlabs://access_token/ # # API Documentation on Webhooks: # - https://dev.streamlabs.com/ # import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase # calls class StrmlabsCall: ALERT = "ALERTS" DONATION = "DONATIONS" # A List of calls we can use for verification STRMLABS_CALLS = ( StrmlabsCall.ALERT, StrmlabsCall.DONATION, ) # alerts class StrmlabsAlert: FOLLOW = "follow" SUBSCRIPTION = "subscription" DONATION = "donation" HOST = "host" # A List of calls we can use for verification STRMLABS_ALERTS = ( StrmlabsAlert.FOLLOW, StrmlabsAlert.SUBSCRIPTION, StrmlabsAlert.DONATION, StrmlabsAlert.HOST, ) class NotifyStreamlabs(NotifyBase): """A wrapper to Streamlabs Donation Notifications.""" # The default descriptive name associated with the Notification service_name = "Streamlabs" # The services URL service_url = "https://streamlabs.com/" # The default secure protocol secure_protocol = "strmlabs" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/streamlabs/" # Streamlabs Api endpoint notify_url = "https://streamlabs.com/api/v1.0/" # The maximum allowable characters allowed in the body per message body_maxlen = 255 # Define object templates templates = ("{schema}://{access_token}/",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "access_token": { "name": _("Access Token"), "private": True, "required": True, "type": "string", "regex": (r"^[a-z0-9]{40}$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "call": { "name": _("Call"), "type": "choice:string", "values": STRMLABS_CALLS, "default": StrmlabsCall.ALERT, }, "alert_type": { "name": _("Alert Type"), "type": "choice:string", "values": STRMLABS_ALERTS, "default": StrmlabsAlert.DONATION, }, "image_href": { "name": _("Image Link"), "type": "string", "default": "", }, "sound_href": { "name": _("Sound Link"), "type": "string", "default": "", }, "duration": { "name": _("Duration"), "type": "int", "default": 1000, "min": 0, }, "special_text_color": { "name": _("Special Text Color"), "type": "string", "default": "", "regex": (r"^[A-Z]$", "i"), }, "amount": { "name": _("Amount"), "type": "int", "default": 0, "min": 0, }, "currency": { "name": _("Currency"), "type": "string", "default": "USD", "regex": (r"^[A-Z]{3}$", "i"), }, "name": { "name": _("Name"), "type": "string", "default": "Anon", "regex": (r"^[^\s].{1,24}$", "i"), }, "identifier": { "name": _("Identifier"), "type": "string", "default": "Apprise", }, }, ) def __init__( self, access_token, call=StrmlabsCall.ALERT, alert_type=StrmlabsAlert.DONATION, image_href="", sound_href="", duration=1000, special_text_color="", amount=0, currency="USD", name="Anon", identifier="Apprise", **kwargs, ): """Initialize Streamlabs Object.""" super().__init__(**kwargs) # access token is generated by user # using https://streamlabs.com/api/v1.0/token # Tokens for Streamlabs never need to be refreshed. self.access_token = validate_regex( access_token, *self.template_tokens["access_token"]["regex"] ) if not self.access_token: msg = "An invalid Streamslabs access token was specified." self.logger.warning(msg) raise TypeError(msg) # Store the call try: if call not in STRMLABS_CALLS: # allow the outer except to handle this common response raise else: self.call = call except Exception as e: # Invalid region specified msg = f"The streamlabs call specified ({call}) is invalid." self.logger.warning(msg) self.logger.debug(f"Socket Exception: {e!s}") raise TypeError(msg) from None # Store the alert_type # only applicable when calling /alerts try: if alert_type not in STRMLABS_ALERTS: # allow the outer except to handle this common response raise else: self.alert_type = alert_type except Exception as e: # Invalid region specified msg = f"The streamlabs alert type specified ({call}) is invalid." self.logger.warning(msg) self.logger.debug(f"Socket Exception: {e!s}") raise TypeError(msg) from None # params only applicable when calling /alerts self.image_href = image_href self.sound_href = sound_href self.duration = duration self.special_text_color = special_text_color # only applicable when calling /donations # The amount of this donation. self.amount = amount # only applicable when calling /donations # The 3 letter currency code for this donation. # Must be one of the supported currency codes. self.currency = validate_regex( currency, *self.template_args["currency"]["regex"] ) # only applicable when calling /donations if not self.currency: msg = "An invalid Streamslabs currency was specified." self.logger.warning(msg) raise TypeError(msg) # only applicable when calling /donations # The name of the donor self.name = validate_regex(name, *self.template_args["name"]["regex"]) if not self.name: msg = "An invalid Streamslabs donor was specified." self.logger.warning(msg) raise TypeError(msg) # An identifier for this donor, # which is used to group donations with the same donor. # only applicable when calling /donations self.identifier = identifier return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Streamlabs notification call (either donation or alert)""" headers = { "User-Agent": self.app_id, } if self.call == StrmlabsCall.ALERT: data = { "access_token": self.access_token, "type": self.alert_type.lower(), "image_href": self.image_href, "sound_href": self.sound_href, "message": title, "user_massage": body, "duration": self.duration, "special_text_color": self.special_text_color, } try: r = requests.post( self.notify_url + self.call.lower(), headers=headers, data=data, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyStreamlabs.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Streamlabs alert: " "{}{}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 Streamlabs alert.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Streamlabs alert." ) self.logger.debug(f"Socket Exception: {e!s}") return False if self.call == StrmlabsCall.DONATION: data = { "name": self.name, "identifier": self.identifier, "amount": self.amount, "currency": self.currency, "access_token": self.access_token, "message": body, } try: r = requests.post( self.notify_url + self.call.lower(), headers=headers, data=data, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyStreamlabs.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Streamlabs donation: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug(f"Response Details:\r\n{r.content}") return False else: self.logger.info("Sent Streamlabs donation.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Streamlabs donation." ) self.logger.debug(f"Socket Exception: {e!s}") 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.access_token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "call": self.call, # donation "name": self.name, "identifier": self.identifier, "amount": self.amount, "currency": self.currency, # alert "alert_type": self.alert_type, "image_href": self.image_href, "sound_href": self.sound_href, "duration": self.duration, "special_text_color": self.special_text_color, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{access_token}/?{params}".format( schema=self.secure_protocol, access_token=self.pprint(self.access_token, privacy, safe=""), params=NotifyStreamlabs.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object. Syntax: strmlabs://access_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 access code access_token = NotifyStreamlabs.unquote(results["host"]) results["access_token"] = access_token # call if "call" in results["qsd"] and results["qsd"]["call"]: results["call"] = NotifyStreamlabs.unquote( results["qsd"]["call"].strip().upper() ) # donation - amount if "amount" in results["qsd"] and results["qsd"]["amount"]: results["amount"] = NotifyStreamlabs.unquote( results["qsd"]["amount"] ) # donation - currency if "currency" in results["qsd"] and results["qsd"]["currency"]: results["currency"] = NotifyStreamlabs.unquote( results["qsd"]["currency"].strip().upper() ) # donation - name if "name" in results["qsd"] and results["qsd"]["name"]: results["name"] = NotifyStreamlabs.unquote( results["qsd"]["name"].strip().upper() ) # donation - identifier if "identifier" in results["qsd"] and results["qsd"]["identifier"]: results["identifier"] = NotifyStreamlabs.unquote( results["qsd"]["identifier"].strip().upper() ) # alert - alert_type if "alert_type" in results["qsd"] and results["qsd"]["alert_type"]: results["alert_type"] = NotifyStreamlabs.unquote( results["qsd"]["alert_type"] ) # alert - image_href if "image_href" in results["qsd"] and results["qsd"]["image_href"]: results["image_href"] = NotifyStreamlabs.unquote( results["qsd"]["image_href"] ) # alert - sound_href if "sound_href" in results["qsd"] and results["qsd"]["sound_href"]: results["sound_href"] = NotifyStreamlabs.unquote( results["qsd"]["sound_href"].strip().upper() ) # alert - duration if "duration" in results["qsd"] and results["qsd"]["duration"]: results["duration"] = NotifyStreamlabs.unquote( results["qsd"]["duration"].strip().upper() ) # alert - special_text_color if ( "special_text_color" in results["qsd"] and results["qsd"]["special_text_color"] ): results["special_text_color"] = NotifyStreamlabs.unquote( results["qsd"]["special_text_color"].strip().upper() ) return results apprise-1.10.0/apprise/plugins/synology.py000066400000000000000000000271431517341665700206430ustar00rootroot00000000000000# 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 requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from .base import NotifyBase # For API Details see: # https://kb.synology.com/en-au/DSM/help/Chat/chat_integration class NotifySynology(NotifyBase): """A wrapper for Synology Chat Notifications.""" # The default descriptive name associated with the Notification service_name = "Synology Chat" # The services URL service_url = "https://www.synology.com/" # The default protocol protocol = "synology" # The default secure protocol secure_protocol = "synologys" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/synology_chat/" # Title is to be part of body title_maxlen = 0 # Disable throttle rate for Synology requests since they are normally # local anyway request_rate_per_sec = 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, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "file_url": { "name": _("Upload"), "type": "string", }, "token": { "alias_of": "token", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, } def __init__(self, token=None, headers=None, file_url=None, **kwargs): """Initialize Synology Chat 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 = token if not self.token: msg = f"An invalid Synology Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) self.fullpath = kwargs.get("fullpath") # A URL to an attachment you want to upload (must be less then 32MB # Acording to API details (at the time of writing plugin) self.file_url = file_url self.headers = {} if headers: # Store our extra headers self.headers.update(headers) 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 if self.secure else self.protocol, self.user, self.password, self.host, self.port, self.token, self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self.file_url: params["file_url"] = self.file_url # 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()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifySynology.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifySynology.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return ( "{schema}://{auth}{hostname}{port}/{token}" "{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}" ), token=self.pprint(self.token, privacy, safe=""), fullpath=( NotifySynology.quote(self.fullpath, safe="/") if self.fullpath else "/" ), params=NotifySynology.urlencode(params), ) ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Synology Chat Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", "Accept": "*/*", } # Apply any/all header over-rides defined headers.update(self.headers) # prepare Synology Object payload = { "text": body, } if self.file_url: payload["file_url"] = self.file_url # Prepare our parameters params = { "api": "SYNO.Chat.External", "method": "incoming", "version": 2, "token": self.token, } 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}" # Prepare our Synology API URL url += self.fullpath + "/webapi/entry.cgi" self.logger.debug( "Synology Chat POST URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Synology Chat Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=f"payload={dumps(payload)}", params=params, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifySynology.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Synology Chat %s notification: " "%serror=%s.", 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 Synology Chat notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Synology " f"Chat notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @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"] = { NotifySynology.unquote(x): NotifySynology.unquote(y) for x, y in results["qsd+"].items() } # Set our token if found as an argument if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifySynology.unquote(results["qsd"]["token"]) else: # Get unquoted entries entries = NotifySynology.split_path(results["fullpath"]) if entries: # Pop the first element results["token"] = entries.pop(0) # Update our fullpath to not include our token results["fullpath"] = results["fullpath"][ len(results["token"]) + 1 : ] # Set upload/file_url if not otherwise set if "file_url" in results["qsd"] and len(results["qsd"]["file_url"]): results["file_url"] = NotifySynology.unquote( results["qsd"]["file_url"] ) return results apprise-1.10.0/apprise/plugins/syslog.py000066400000000000000000000250571517341665700203020ustar00rootroot00000000000000# 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 syslog from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool from .base import NotifyBase class SyslogFacility: """All of the supported facilities.""" KERN = "kern" USER = "user" MAIL = "mail" DAEMON = "daemon" AUTH = "auth" SYSLOG = "syslog" LPR = "lpr" NEWS = "news" UUCP = "uucp" CRON = "cron" LOCAL0 = "local0" LOCAL1 = "local1" LOCAL2 = "local2" LOCAL3 = "local3" LOCAL4 = "local4" LOCAL5 = "local5" LOCAL6 = "local6" LOCAL7 = "local7" SYSLOG_FACILITY_MAP = { SyslogFacility.KERN: syslog.LOG_KERN, SyslogFacility.USER: syslog.LOG_USER, SyslogFacility.MAIL: syslog.LOG_MAIL, SyslogFacility.DAEMON: syslog.LOG_DAEMON, SyslogFacility.AUTH: syslog.LOG_AUTH, SyslogFacility.SYSLOG: syslog.LOG_SYSLOG, SyslogFacility.LPR: syslog.LOG_LPR, SyslogFacility.NEWS: syslog.LOG_NEWS, SyslogFacility.UUCP: syslog.LOG_UUCP, SyslogFacility.CRON: syslog.LOG_CRON, SyslogFacility.LOCAL0: syslog.LOG_LOCAL0, SyslogFacility.LOCAL1: syslog.LOG_LOCAL1, SyslogFacility.LOCAL2: syslog.LOG_LOCAL2, SyslogFacility.LOCAL3: syslog.LOG_LOCAL3, SyslogFacility.LOCAL4: syslog.LOG_LOCAL4, SyslogFacility.LOCAL5: syslog.LOG_LOCAL5, SyslogFacility.LOCAL6: syslog.LOG_LOCAL6, SyslogFacility.LOCAL7: syslog.LOG_LOCAL7, } SYSLOG_FACILITY_RMAP = { syslog.LOG_KERN: SyslogFacility.KERN, syslog.LOG_USER: SyslogFacility.USER, syslog.LOG_MAIL: SyslogFacility.MAIL, syslog.LOG_DAEMON: SyslogFacility.DAEMON, syslog.LOG_AUTH: SyslogFacility.AUTH, syslog.LOG_SYSLOG: SyslogFacility.SYSLOG, syslog.LOG_LPR: SyslogFacility.LPR, syslog.LOG_NEWS: SyslogFacility.NEWS, syslog.LOG_UUCP: SyslogFacility.UUCP, syslog.LOG_CRON: SyslogFacility.CRON, syslog.LOG_LOCAL0: SyslogFacility.LOCAL0, syslog.LOG_LOCAL1: SyslogFacility.LOCAL1, syslog.LOG_LOCAL2: SyslogFacility.LOCAL2, syslog.LOG_LOCAL3: SyslogFacility.LOCAL3, syslog.LOG_LOCAL4: SyslogFacility.LOCAL4, syslog.LOG_LOCAL5: SyslogFacility.LOCAL5, syslog.LOG_LOCAL6: SyslogFacility.LOCAL6, syslog.LOG_LOCAL7: SyslogFacility.LOCAL7, } # Used as a lookup when handling the Apprise -> Syslog Mapping SYSLOG_PUBLISH_MAP = { NotifyType.INFO: syslog.LOG_INFO, NotifyType.SUCCESS: syslog.LOG_NOTICE, NotifyType.FAILURE: syslog.LOG_CRIT, NotifyType.WARNING: syslog.LOG_WARNING, } class NotifySyslog(NotifyBase): """A wrapper for Syslog Notifications.""" # The default descriptive name associated with the Notification service_name = "Syslog" # The services URL service_url = "https://tools.ietf.org/html/rfc5424" # The default protocol protocol = "syslog" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/syslog/" # 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 # Disable throttle rate for Syslog requests since they are normally # local anyway request_rate_per_sec = 0 # Define object templates templates = ( "{schema}://", "{schema}://{facility}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "facility": { "name": _("Facility"), "type": "choice:string", "values": list(SYSLOG_FACILITY_MAP), "default": SyslogFacility.USER, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "facility": { # We map back to the same element defined in template_tokens "alias_of": "facility", }, "logpid": { "name": _("Log PID"), "type": "bool", "default": True, "map_to": "log_pid", }, "logperror": { "name": _("Log to STDERR"), "type": "bool", "default": False, "map_to": "log_perror", }, }, ) def __init__( self, facility=None, log_pid=True, log_perror=False, **kwargs ): """Initialize Syslog Object.""" super().__init__(**kwargs) if facility: try: self.facility = SYSLOG_FACILITY_MAP[facility] except KeyError: msg = f"An invalid syslog facility ({facility}) was specified." self.logger.warning(msg) raise TypeError(msg) from None else: self.facility = SYSLOG_FACILITY_MAP[ self.template_tokens["facility"]["default"] ] # Logging Options self.logoptions = 0 # Include PID with each message. # This may not appear evident if using journalctl since the pid # will always display itself; however it will appear visible # for log_perror combinations self.log_pid = log_pid # Print to stderr as well. self.log_perror = log_perror if log_pid: self.logoptions |= syslog.LOG_PID if log_perror: self.logoptions |= syslog.LOG_PERROR # Initialize our logging syslog.openlog( self.app_id, logoption=self.logoptions, facility=self.facility ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Syslog Notification.""" SYSLOG_PUBLISH_MAP = { NotifyType.INFO: syslog.LOG_INFO, NotifyType.SUCCESS: syslog.LOG_NOTICE, NotifyType.FAILURE: syslog.LOG_CRIT, NotifyType.WARNING: syslog.LOG_WARNING, } if title: # Format title body = f"{title}: {body}" # Always call throttle before any remote server i/o is made self.throttle() try: syslog.syslog(SYSLOG_PUBLISH_MAP[notify_type], body) except KeyError: # An invalid notification type was specified self.logger.warning( f"An invalid notification type ({notify_type}) was specified." ) return False self.logger.info("Sent Syslog notification.") return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "logperror": "yes" if self.log_perror else "no", "logpid": "yes" if self.log_pid else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{facility}/?{params}".format( facility=( self.template_tokens["facility"]["default"] if self.facility not in SYSLOG_FACILITY_RMAP else SYSLOG_FACILITY_RMAP[self.facility] ), schema=self.protocol, params=NotifySyslog.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 tokens = [] if results["host"]: tokens.append(NotifySyslog.unquote(results["host"])) # Get our path values tokens.extend(NotifySyslog.split_path(results["fullpath"])) # Initialization facility = None if tokens: # Store the last entry as the facility facility = tokens[-1].lower() # However if specified on the URL, that will over-ride what was # identified if "facility" in results["qsd"] and len(results["qsd"]["facility"]): facility = results["qsd"]["facility"].lower() if facility and facility not in SYSLOG_FACILITY_MAP: # Find first match; if no match is found we set the result # to the matching key. This allows us to throw a TypeError # during the __init__() call. The benifit of doing this # check here is if we do have a valid match, we can support # short form matches like 'u' which will match against user facility = next( (f for f in SYSLOG_FACILITY_MAP if f.startswith(facility)), facility, ) # Save facility if set if facility: results["facility"] = facility # Include PID as part of the message logged results["log_pid"] = parse_bool( results["qsd"].get( "logpid", NotifySyslog.template_args["logpid"]["default"] ) ) # Print to stderr as well. results["log_perror"] = parse_bool( results["qsd"].get( "logperror", NotifySyslog.template_args["logperror"]["default"] ) ) return results apprise-1.10.0/apprise/plugins/techuluspush.py000066400000000000000000000164051517341665700215130ustar00rootroot00000000000000# 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 download the app # - Apple: https://itunes.apple.com/us/app/\ # push-by-techulus/id1444391917?ls=1&mt=8 # - Android: https://play.google.com/store/apps/\ # details?id=com.techulus.push # # You have to sign up through the account via your mobile device. # # Once you've got your account, you can get your API key from here: # https://push.techulus.com/login.html # # You can also just get the {apikey} right out of the phone app that is # installed. # # your {apikey} will look something like: # b444a40f-3db9-4224-b489-9a514c41c009 # # You will need to assemble all of your URLs for this plugin to work as: # push://{apikey} # # Resources # - https://push.techulus.com/ - Main Website # - https://pushtechulus.docs.apiary.io - API Documentation from json import dumps import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase # Token required as part of the API request # Used to prepare our UUID regex matching UUID4_RE = ( r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" ) class NotifyTechulusPush(NotifyBase): """A wrapper for Techulus Push Notifications.""" # The default descriptive name associated with the Notification service_name = "Techulus Push" # The services URL service_url = "https://push.techulus.com" # The default secure protocol secure_protocol = "push" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/techulus/" # Techulus Push uses the http protocol with JSON requests notify_url = "https://push.techulus.com/api/v1/notify" # The maximum allowable characters allowed in the body per message body_maxlen = 1000 # Define object templates templates = ("{schema}://{apikey}",) # Define our template apikeys template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "regex": (rf"^{UUID4_RE}$", "i"), }, }, ) def __init__(self, apikey, **kwargs): """Initialize Techulus Push Object.""" super().__init__(**kwargs) # The apikey associated with the account self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid Techulus Push API key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Techulus Push Notification.""" # Setup our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "x-api-key": self.apikey, } payload = { "title": title, "body": body, } self.logger.debug( "Techulus Push POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Techulus Push 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), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifyTechulusPush.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Techulus Push 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 Techulus Push notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Techulus Push " "notification." ) self.logger.debug(f"Socket Exception: {e!s}") 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.apikey) 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) return "{schema}://{apikey}/?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), params=NotifyTechulusPush.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 # The first apikey is stored in the hostname results["apikey"] = NotifyTechulusPush.unquote(results["host"]) return results apprise-1.10.0/apprise/plugins/telegram.py000066400000000000000000001202641517341665700205560ustar00rootroot00000000000000# 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 first access https://api.telegram.org # You need to create a bot and acquire it's Token Identifier (bot_token) # # Basically you need to create a chat with a user called the 'BotFather' # and type: /newbot # # Then follow through the wizard, it will provide you an api key # that looks like this:123456789:alphanumeri_characters # # For each chat_id a bot joins will have a chat_id associated with it. # You will need this value as well to send the notification. # # Log into the webpage version of the site if you like by accessing: # https://web.telegram.org # # You can't check out to see if your entry is working using: # https://api.telegram.org/botAPI_KEY/getMe # # Pay attention to the word 'bot' that must be present infront of your # api key that the BotFather gave you. # # For example, a url might look like this: # https://api.telegram.org/bot123456789:alphanumeric_characters/getMe # # Development API Reference:: # - https://core.telegram.org/bots/api from json import dumps, loads import os import re import requests from ..attachment.base import AttachBase from ..common import ( NotifyFormat, NotifyImageSize, NotifyType, PersistentStoreMode, ) from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, parse_list, validate_regex from .base import NotifyBase TELEGRAM_IMAGE_XY = NotifyImageSize.XY_256 # Chat ID is required # If the Chat ID is positive, then it's addressed to a single person # If the Chat ID is negative, then it's targeting a group # We can support :topic (an integer) if specified as well IS_CHAT_ID_RE = re.compile( r"^((?P-?[0-9]{1,32})|(@|%40)?(?P[a-z_-][a-z0-9_-]+))" r"((:|%3A)(?P[0-9]+))?$", re.IGNORECASE, ) class TelegramMarkdownVersion: """Telegram Markdown Version.""" # Classic (Original Telegram Markdown) ONE = "MARKDOWN" # Supports strikethrough and many other items TWO = "MarkdownV2" TELEGRAM_MARKDOWN_VERSION_MAP = { # v1 "v1": TelegramMarkdownVersion.ONE, "1": TelegramMarkdownVersion.ONE, # v2 "v2": TelegramMarkdownVersion.TWO, "2": TelegramMarkdownVersion.TWO, "default": TelegramMarkdownVersion.TWO, } TELEGRAM_MARKDOWN_VERSIONS = { # Note: This also acts as a reverse lookup mapping TelegramMarkdownVersion.ONE: "v1", TelegramMarkdownVersion.TWO: "v2", } class TelegramContentPlacement: """The Telegram Content Placement.""" # Before Attachments BEFORE = "before" # After Attachments AFTER = "after" # Identify Placement Categories TELEGRAM_CONTENT_PLACEMENT = ( TelegramContentPlacement.BEFORE, TelegramContentPlacement.AFTER, ) class NotifyTelegram(NotifyBase): """A wrapper for Telegram Notifications.""" # The default descriptive name associated with the Notification service_name = "Telegram" # The services URL service_url = "https://telegram.org/" # The default secure protocol secure_protocol = "tgram" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/telegram/" # Default Notify Format notify_format = NotifyFormat.HTML # Telegram uses the http protocol with JSON requests notify_url = "https://api.telegram.org/bot" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 # The maximum allowable characters allowed in the body per message body_maxlen = 4096 # The maximum number of characters a telegram attachment caption can be # If an attachment is provided and the body is within the caption limit # then it is captioned with the attachment instead. telegram_caption_maxlen = 1024 # Title is to be part of body title_maxlen = 0 # Telegram is limited to sending a maximum of 100 requests per second. request_rate_per_sec = 0.001 # Our default is to no not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.AUTO # Define object templates templates = ( "{schema}://{bot_token}", "{schema}://{bot_token}/{targets}", ) # Telegram Attachment Support mime_lookup = ( # This list is intentionally ordered so that it can be scanned # from top to bottom. The last entry is a catch-all # Animations are documented to only support gif or H.264/MPEG-4 # Source: https://core.telegram.org/bots/api#sendanimation { "regex": re.compile(r"^(image/gif|video/H264)", re.I), "function_name": "sendAnimation", "key": "animation", }, # This entry is intentially placed below the sendAnimiation allowing # it to catch gif files. This then becomes a catch all to remaining # image types. # Source: https://core.telegram.org/bots/api#sendphoto { "regex": re.compile(r"^image/.*", re.I), "function_name": "sendPhoto", "key": "photo", }, # Video is documented to only support .mp4 # Source: https://core.telegram.org/bots/api#sendvideo { "regex": re.compile(r"^video/mp4", re.I), "function_name": "sendVideo", "key": "video", }, # Voice supports ogg # Source: https://core.telegram.org/bots/api#sendvoice { "regex": re.compile(r"^(application|audio)/ogg", re.I), "function_name": "sendVoice", "key": "voice", }, # Audio supports mp3 and m4a only # Source: https://core.telegram.org/bots/api#sendaudio { "regex": re.compile(r"^audio/(mpeg|mp4a-latm)", re.I), "function_name": "sendAudio", "key": "audio", }, # Catch All (all other types) # Source: https://core.telegram.org/bots/api#senddocument { "regex": re.compile(r".*", re.I), "function_name": "sendDocument", "key": "document", }, ) # Telegram's HTML support doesn't like having HTML escaped # characters passed into it. to handle this situation, we need to # search the body for these sequences and convert them to the # output the user expected __telegram_escape_html_entries = ( # Comments (re.compile(r"\s*\s*", (re.I | re.M | re.S)), "", {}), # the following tags are not supported ( re.compile( r"\s*<\s*(!?DOCTYPE|p|div|span|body|script|link|" r"meta|html|font|head|label|form|input|textarea|select|iframe|" r"source|script)([^a-z0-9>][^>]*)?>\s*", (re.I | re.M | re.S), ), "", {}, ), # All closing tags to be removed are put here ( re.compile( r"\s*<\s*/(span|body|script|meta|html|font|head|" r"label|form|input|textarea|select|ol|ul|link|" r"iframe|source|script)([^a-z0-9>][^>]*)?>\s*", (re.I | re.M | re.S), ), "", {}, ), # Bold ( re.compile( r"<\s*(strong)([^a-z0-9>][^>]*)?>", (re.I | re.M | re.S) ), "", {}, ), ( re.compile( r"<\s*/\s*(strong)([^a-z0-9>][^>]*)?>", (re.I | re.M | re.S) ), "", {}, ), ( re.compile( r"\s*<\s*(h[1-6]|title)([^a-z0-9>][^>]*)?>\s*", (re.I | re.M | re.S), ), "{}", {"html": "\r\n"}, ), ( re.compile( r"\s*<\s*/\s*(h[1-6]|title)([^a-z0-9>][^>]*)?>\s*", (re.I | re.M | re.S), ), "{}", {"html": "
"}, ), # Italic ( re.compile( r"<\s*(caption|em)([^a-z0-9>][^>]*)?>", (re.I | re.M | re.S) ), "", {}, ), ( re.compile( r"<\s*/\s*(caption|em)([^a-z0-9>][^>]*)?>", (re.I | re.M | re.S), ), "", {}, ), # Bullet Lists ( re.compile(r"<\s*li([^a-z0-9>][^>]*)?>\s*", (re.I | re.M | re.S)), " -", {}, ), # New Lines ( re.compile( r"\s*<\s*/?\s*(ol|ul|br|hr)\s*/?>\s*", (re.I | re.M | re.S) ), "\r\n", {}, ), ( re.compile( r"\s*<\s*/\s*(br|p|hr|li|div)([^a-z0-9>][^>]*)?>\s*", (re.I | re.M | re.S), ), "\r\n", {}, ), # HTML Spaces ( ) and tabs ( ) aren't supported # See https://core.telegram.org/bots/api#html-style (re.compile(r"\ ?", re.I), " ", {}), # Tabs become 3 spaces (re.compile(r"\ ?", re.I), " ", {}), # Some characters get re-escaped by the Telegram upstream # service so we need to convert these back, (re.compile(r"\'?", re.I), "'", {}), (re.compile(r"\"?", re.I), '"', {}), # New line cleanup (re.compile(r"\r*\n[\r\n]+", re.I), "\r\n", {}), ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "bot_token": { "name": _("Bot Token"), "type": "string", "private": True, "required": True, # Token required as part of the API request, allow the word # 'bot' infront of it "regex": (r"^(bot)?(?P[0-9]+:[a-z0-9_-]+)$", "i"), }, "target_user": { "name": _("Target Chat ID"), "type": "string", "map_to": "targets", "regex": (r"^((-?[0-9]{1,32})|([a-z_-][a-z0-9_-]+))$", "i"), }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "image": { "name": _("Include Image"), "type": "bool", "default": False, "map_to": "include_image", }, "detect": { "name": _("Detect Bot Owner"), "type": "bool", "default": True, "map_to": "detect_owner", }, "silent": { "name": _("Silent Notification"), "type": "bool", "default": False, }, "preview": { "name": _("Web Page Preview"), "type": "bool", "default": False, }, "topic": { "name": _("Topic Thread ID"), "type": "int", }, "thread": { "alias_of": "topic", }, "mdv": { "name": _("Markdown Version"), "type": "choice:string", "values": ("v1", "v2"), "default": "v1", }, "to": { "alias_of": "targets", }, "content": { "name": _("Content Placement"), "type": "choice:string", "values": TELEGRAM_CONTENT_PLACEMENT, "default": TelegramContentPlacement.BEFORE, }, }, ) def __init__( self, bot_token, targets, detect_owner=True, include_image=False, silent=None, preview=None, topic=None, content=None, mdv=None, **kwargs, ): """Initialize Telegram Object.""" super().__init__(**kwargs) self.bot_token = validate_regex( bot_token, *self.template_tokens["bot_token"]["regex"], fmt="{key}" ) if not self.bot_token: err = f"The Telegram Bot Token specified ({bot_token}) is invalid." self.logger.warning(err) raise TypeError(err) # Get our Markdown Version self.markdown_ver = ( TELEGRAM_MARKDOWN_VERSION_MAP[ NotifyTelegram.template_args["mdv"]["default"] ] if mdv is None else next( ( v for k, v in TELEGRAM_MARKDOWN_VERSION_MAP.items() if str(mdv).lower().startswith(k) ), TELEGRAM_MARKDOWN_VERSION_MAP[ NotifyTelegram.template_args["mdv"]["default"] ], ) ) # Define whether or not we should make audible alarms self.silent = ( self.template_args["silent"]["default"] if silent is None else bool(silent) ) # Define whether or not we should display a web page preview self.preview = ( self.template_args["preview"]["default"] if preview is None else bool(preview) ) # Setup our content placement self.content = ( self.template_args["content"]["default"] if not isinstance(content, str) else content.lower() ) if self.content and self.content not in TELEGRAM_CONTENT_PLACEMENT: msg = f"The content placement specified ({content}) is invalid." self.logger.warning(msg) raise TypeError(msg) if topic: try: self.topic = int(topic) except (TypeError, ValueError) as exc: # Not a valid integer; ignore entry err = f"The Telegram Topic ID specified ({topic}) is invalid." self.logger.warning(err) raise TypeError(err) from exc else: # No Topic Thread self.topic = None # if detect_owner is set to True, we will attempt to determine who # the bot owner is based on the first person who messaged it. This # is not a fool proof way of doing things as over time Telegram removes # the message history for the bot. So what appears (later on) to be # the first message to it, maybe another user who sent it a message # much later. Users who set this flag should update their Apprise # URL later to directly include the user that we should message. self.detect_owner = detect_owner # Parse our list self.targets = [] for target in parse_list(targets): results = IS_CHAT_ID_RE.match(target) if not results: self.logger.warning( f"Dropped invalid Telegram chat/group ({target}) " "specified.", ) # Ensure we don't fall back to owner detection self.detect_owner = False continue if results.group("topic"): topic = int( results.group("topic") if results.group("topic") else self.topic ) else: # Default (if one set) topic = self.topic if results.group("name") is not None: # Name self.targets.append( ("@{}".format(results.group("name")), topic) ) else: # ID self.targets.append((int(results.group("idno")), topic)) # Track whether or not we want to send an image with our notification # or not. self.include_image = include_image def send_media(self, target, notify_type, payload=None, attach=None): """Sends a sticker based on the specified notify type.""" # Prepare our Headers if payload is None: payload = {} headers = { "User-Agent": self.app_id, } # Our function name and payload are determined on the path function_name = "SendPhoto" key = "photo" path = None if isinstance(attach, AttachBase): if not attach: # We could not access the attachment self.logger.error( f"Could not access attachment {attach.url(privacy=True)}." ) return False self.logger.debug( f"Posting Telegram attachment {attach.url(privacy=True)}" ) # Store our path to our file path = attach.path file_name = attach.name mimetype = attach.mimetype # Process our attachment function_name, key = next( (x["function_name"], x["key"]) for x in self.mime_lookup if x["regex"].match(mimetype) ) # pragma: no cover else: attach = self.image_path(notify_type) if attach is None else attach if attach is None: # Nothing specified to send return True # Take on specified attachent as path path = attach file_name = os.path.basename(path) url = f"{self.notify_url}{self.bot_token}/{function_name}" # Always call throttle before any remote server i/o is made; # Telegram throttles to occur before sending the image so that # content can arrive together. self.throttle() # Extract our target chat_id, topic = target payload["chat_id"] = chat_id if topic: payload["message_thread_id"] = topic try: with ( attach if isinstance(attach, AttachBase) else open(path, "rb") ) as f: # Configure file payload (for upload) files = {key: (file_name, f)} self.logger.debug( f"Telegram attachment POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) r = requests.post( url, headers=headers, files=files, data=payload, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyTelegram.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Telegram attachment: " "{}{}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 # Content was sent successfully if we got here return True except requests.RequestException as e: self.logger.warning( "A connection error occurred posting Telegram attachment." ) self.logger.debug(f"Socket Exception: {e!s}") 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: {path}") return False def detect_bot_owner(self): """Takes a bot and attempts to detect it's chat id from that.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } url = "{}{}/{}".format(self.notify_url, self.bot_token, "getUpdates") self.logger.debug( f"Telegram User Detection POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) # Track our response object response = None try: r = requests.post( url, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyTelegram.http_response_code_lookup( r.status_code ) try: # Try to get the error message if we can: error_msg = loads(r.content).get("description", "unknown") except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None error_msg = None if error_msg: self.logger.warning( "Failed to detect the Telegram user: " f"({r.status_code}) {error_msg}." ) else: self.logger.warning( "Failed to detect the Telegram user: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug(f"Response Details:\r\n{r.content}") return 0 # Load our response and attempt to fetch our userid response = loads(r.content) except (AttributeError, TypeError, ValueError): # Our response was not the JSON type we had expected it to be # - ValueError = r.content is Unparsable # - TypeError = r.content is None # - AttributeError = r is None self.logger.warning( "A communication error occurred detecting the Telegram User." ) return 0 except requests.RequestException as e: self.logger.warning( "A connection error occurred detecting the Telegram User." ) self.logger.debug(f"Socket Exception: {e!s}") return 0 # A Response might look something like this: # { # "ok":true, # "result":[{ # "update_id":645421321, # "message":{ # "message_id":1, # "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"}]}}] if response.get("ok", False): for entry in response.get("result", []): if "message" in entry and "from" in entry["message"]: id_ = entry["message"]["from"].get("id", 0) user = entry["message"]["from"].get("first_name") self.logger.info( "Detected Telegram user %s (userid=%d)", user, id_ ) # Return our detected userid self.store.set("bot_owner", id_) return id_ self.logger.warning( "Failed to detect a Telegram user; " "try sending your bot a message first." ) return 0 def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, body_format=None, **kwargs, ): """Perform Telegram Notification.""" if len(self.targets) == 0 and self.detect_owner: id_ = self.store.get("bot_owner") or self.detect_bot_owner() if id_: # Permanently store our id in our target list for next time self.targets.append((str(id_), self.topic)) self.logger.info( "Update your Telegram Apprise URL to read: " f"{self.url(privacy=True)}" ) if len(self.targets) == 0: self.logger.warning("There were not Telegram chat_ids to notify.") return False headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # error tracking (used for function return) has_error = False url = "{}{}/{}".format(self.notify_url, self.bot_token, "sendMessage") payload_ = { # Notification Audible Control "disable_notification": self.silent, # Display Web Page Preview (if possible) "disable_web_page_preview": not self.preview, } # Prepare Message Body if self.notify_format == NotifyFormat.MARKDOWN: if ( body_format not in (None, NotifyFormat.MARKDOWN) and self.markdown_ver == TelegramMarkdownVersion.TWO ): # Telegram Markdown v2 is not very accomodating to some # characters such as the hashtag (#) which is fine in v1. # To try and be accomodating we escape them in advance # See: https://stackoverflow.com/a/69892704/355584 # Also: https://core.telegram.org/bots/api#markdownv2-style body = re.sub(r"(?#+=|{}.!-])", r"\\\1", body) payload_["parse_mode"] = self.markdown_ver payload_["text"] = body else: # HTML # Use Telegram's HTML mode payload_["parse_mode"] = "HTML" for r, v, m in self.__telegram_escape_html_entries: if "html" in m: # Handle special cases where we need to alter new lines # for presentation purposes v = v.format( m["html"] if body_format in (NotifyFormat.HTML, NotifyFormat.MARKDOWN) else "" ) body = r.sub(v, body) # Prepare our payload based on HTML or TEXT payload_["text"] = body # Prepare our caption payload caption_payload = ( { "caption": payload_["text"], "show_caption_above_media": ( self.content == TelegramContentPlacement.BEFORE ), "parse_mode": payload_["parse_mode"], } if attach and body and len(payload_.get("text", "")) < self.telegram_caption_maxlen else {} ) # Handle payloads without a body specified (but an attachment present) attach_content = ( TelegramContentPlacement.AFTER if not body or caption_payload else self.content ) # Create a copy of the chat_ids list targets = list(self.targets) while len(targets): target = targets.pop(0) chat_id, topic = target # Printable chat_id details pchat_id = f"{chat_id}" if not topic else f"{chat_id}:{topic}" payload = payload_.copy() payload["chat_id"] = chat_id if topic: payload["message_thread_id"] = topic if self.include_image is True and not self.send_media( target, notify_type ): # We failed to send the image associated with our # notify_type self.logger.warning( "Failed to send Telegram attachment to {}.", pchat_id ) if ( attach and self.attachment_support and attach_content == TelegramContentPlacement.AFTER ): # Send our attachments now (if specified and if it exists) if not self._send_attachments( target, notify_type=notify_type, payload=caption_payload, attach=attach, ): has_error = True continue if not body: # Nothing more to do; move along to the next attachment continue if caption_payload: # nothing further to do; move along to the next attachment continue # Always call throttle before any remote server i/o is made; # Telegram throttles to occur before sending the image so that # content can arrive together. self.throttle() self.logger.debug( f"Telegram POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Telegram Payload: {payload!s}") try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyTelegram.http_response_code_lookup( r.status_code ) try: # Try to get the error message if we can: error_msg = loads(r.content).get( "description", "unknown" ) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None error_msg = None self.logger.warning( f"Failed to send Telegram notification to {pchat_id}: " f"{error_msg if error_msg else status_str}, " f"error={r.status_code}." ) self.logger.debug(f"Response Details:\r\n{r.content}") # Flag our error has_error = True continue except requests.RequestException as e: self.logger.warning( f"A connection error occurred sending Telegram:{pchat_id} " + "notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Flag our error has_error = True continue self.logger.info("Sent Telegram notification.") if ( attach and self.attachment_support and attach_content == TelegramContentPlacement.BEFORE and not self._send_attachments( target=target, notify_type=notify_type, attach=attach ) ): # Send our attachments now (if specified and if it exists) as # it was identified to send the content before the attachments # which is now done. has_error = True continue return not has_error def _send_attachments(self, target, notify_type, attach, payload=None): """Sends our attachments.""" if payload is None: payload = {} has_error = False # Send our attachments now (if specified and if it exists) for no, attachment in enumerate(attach, start=1): payload = payload if payload and no == 1 else {} payload.update( { "title": ( attachment.name if attachment.name else f"file{no:03}.dat" ) } ) if not self.send_media( target, notify_type, payload=payload, attach=attachment ): # We failed; don't continue has_error = True break self.logger.info(f"Sent Telegram attachment: {attachment}.") 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.bot_token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "image": self.include_image, "detect": "yes" if self.detect_owner else "no", "silent": "yes" if self.silent else "no", "preview": "yes" if self.preview else "no", "content": self.content, "mdv": TELEGRAM_MARKDOWN_VERSIONS[self.markdown_ver], } if self.topic: params["topic"] = self.topic # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) targets = [] for chat_id, topic_ in self.targets: topic = topic_ if topic_ else self.topic targets.append( "".join( [ ( NotifyTelegram.quote(f"{chat_id}", safe="@") if isinstance(chat_id, str) else f"{chat_id}" ), "" if not topic else f":{topic}", ] ) ) # No need to check the user token because the user automatically gets # appended into the list of chat ids return "{schema}://{bot_token}/{targets}/?{params}".format( schema=self.secure_protocol, bot_token=self.pprint(self.bot_token, privacy, safe=""), targets="/".join(targets), params=NotifyTelegram.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return 1 if not self.targets else len(self.targets) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" # This is a dirty hack; but it's the only work around to tgram:// # messages since the bot_token has a colon in it. It invalidates a # normal URL. # This hack searches for this bogus URL and corrects it so we can # properly load it further down. The other alternative is to ask users # to actually change the colon into a slash (which will work too), but # it's more likely to cause confusion... So this is the next best thing # we also check for %3A (incase the URL is encoded) as %3A == : try: tgram = re.match( rf"(?P{NotifyTelegram.secure_protocol}://)" r"(bot)?(?P([a-z0-9_-]+)" r"(:[a-z0-9_-]+)?@)?(?P[0-9]+)(:|%3A)+" r"(?P.*)$", url, re.I, ) except (TypeError, AttributeError): # url is bad; force tgram to be None tgram = None if not tgram: # Content is simply not parseable return None if tgram.group("prefix"): # Try again results = NotifyBase.parse_url( "{}{}{}/{}".format( tgram.group("protocol"), tgram.group("prefix"), tgram.group("btoken_a"), tgram.group("remaining"), ), verify_host=False, ) else: # Try again results = NotifyBase.parse_url( "{}{}/{}".format( tgram.group("protocol"), tgram.group("btoken_a"), tgram.group("remaining"), ), verify_host=False, ) # The first token is stored in the hostname bot_token_a = NotifyTelegram.unquote(results["host"]) # Get a nice unquoted list of path entries entries = NotifyTelegram.split_path(results["fullpath"]) # Now fetch the remaining tokens bot_token_b = entries.pop(0) bot_token = f"{bot_token_a}:{bot_token_b}" # Store our chat ids (as these are the remaining entries) results["targets"] = entries # content to be displayed 'before' or 'after' attachments if "content" in results["qsd"] and len(results["qsd"]["content"]): results["content"] = results["qsd"]["content"] # 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"] += NotifyTelegram.parse_list( results["qsd"]["to"] ) # Store our bot token results["bot_token"] = bot_token # Support Markdown Version if "mdv" in results["qsd"] and len(results["qsd"]["mdv"]): results["mdv"] = results["qsd"]["mdv"] # Support Thread Topic if "topic" in results["qsd"] and len(results["qsd"]["topic"]): results["topic"] = results["qsd"]["topic"] elif "thread" in results["qsd"] and len(results["qsd"]["thread"]): results["topic"] = results["qsd"]["thread"] # Silent (Sends the message Silently); users will receive # notification with no sound. results["silent"] = parse_bool(results["qsd"].get("silent", False)) # Show Web Page Preview results["preview"] = parse_bool(results["qsd"].get("preview", False)) # Include images with our message results["include_image"] = parse_bool( results["qsd"].get("image", False) ) # Include images with our message results["detect_owner"] = parse_bool( results["qsd"].get("detect", not results["targets"]) ) return results apprise-1.10.0/apprise/plugins/threema.py000066400000000000000000000300241517341665700203750ustar00rootroot00000000000000# 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. # Create an account https://gateway.threema.ch/en/ if you don't already have # one # # Read more about Threema Gateway API here: # - https://gateway.threema.ch/en/developer/api from itertools import chain import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_email, is_phone_no, parse_list, validate_regex from .base import NotifyBase class ThreemaRecipientTypes: """The supported recipient specifiers.""" THREEMA_ID = "to" PHONE = "phone" EMAIL = "email" class NotifyThreema(NotifyBase): """A wrapper for Threema Gateway Notifications.""" # The default descriptive name associated with the Notification service_name = "Threema Gateway" # The services URL service_url = "https://gateway.threema.ch/" # The default protocol secure_protocol = "threema" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/threema/" # Threema Gateway uses the http protocol with JSON requests notify_url = "https://msgapi.threema.ch/send_simple" # The maximum length of the body body_maxlen = 3500 # No title support title_maxlen = 0 # Define object templates templates = ("{schema}://{gateway_id}@{secret}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "gateway_id": { "name": _("Gateway ID"), "type": "string", "private": True, "required": True, "map_to": "user", }, "secret": { "name": _("API Secret"), "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_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "target_threema_id": { "name": _("Target Threema ID"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "gateway_id", }, "gwid": { "alias_of": "gateway_id", }, "secret": { "alias_of": "secret", }, "to": { "alias_of": "targets", }, }, ) def __init__(self, secret=None, targets=None, **kwargs): """Initialize Threema Gateway Object.""" super().__init__(**kwargs) # Validate our params here. if not self.user: msg = "Threema Gateway ID must be specified" self.logger.warning(msg) raise TypeError(msg) # Verify our Gateway ID if len(self.user) != 8: msg = "Threema Gateway ID must be 8 characters in length" self.logger.warning(msg) raise TypeError(msg) # Verify our secret self.secret = validate_regex(secret) if not self.secret: msg = f"An invalid Threema API Secret ({secret}) was specified" self.logger.warning(msg) raise TypeError(msg) # Parse our targets self.targets = [] # Used for URL generation afterwards only self.invalid_targets = [] for target in parse_list(targets, allow_whitespace=False): if len(target) == 8: # Store our user self.targets.append((ThreemaRecipientTypes.THREEMA_ID, target)) continue # Check if an email was defined result = is_email(target) if result: # Store our user self.targets.append( (ThreemaRecipientTypes.EMAIL, result["full_email"]) ) continue # Validate targets and drop bad ones: result = is_phone_no(target) if result: # store valid phone number self.targets.append( (ThreemaRecipientTypes.PHONE, result["full"]) ) continue self.logger.warning( f"Dropped invalid user/email/phone ({target}) specified", ) self.invalid_targets.append(target) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Threema Gateway Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning( "There were no Threema Gateway 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/x-www-form-urlencoded; charset=utf-8", "Accept": "*/*", } # Prepare our payload payload_ = { "secret": self.secret, "from": self.user, "text": body.encode("utf-8"), } # Create a copy of the targets list targets = list(self.targets) while len(targets): # Get our target to notify key, target = targets.pop(0) # Prepare a payload object payload = payload_.copy() # Set Target payload[key] = target # Some Debug Logging self.logger.debug( "Threema Gateway GET URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Threema Gateway Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, params=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyThreema.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Threema Gateway 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 # We wee successful self.logger.info( f"Sent Threema Gateway notification to {target}" ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Threema" f" Gateway:{target} notification" ) 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.user, self.secret) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) schemaStr = "{schema}://{gatewayid}@{secret}/{targets}?{params}" return schemaStr.format( schema=self.secure_protocol, gatewayid=NotifyThreema.quote(self.user), secret=self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( chain( [ NotifyThreema.quote(x[1], safe="@+") for x in self.targets ], [ NotifyThreema.quote(x, safe="@+") for x in self.invalid_targets ], ) ), params=NotifyThreema.urlencode(params), ) 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 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 results["targets"] = [] if "secret" in results["qsd"] and len(results["qsd"]["secret"]): results["secret"] = NotifyThreema.unquote(results["qsd"]["secret"]) else: results["secret"] = NotifyThreema.unquote(results["host"]) results["targets"] += NotifyThreema.split_path(results["fullpath"]) if "from" in results["qsd"] and len(results["qsd"]["from"]): results["user"] = NotifyThreema.unquote(results["qsd"]["from"]) elif "gwid" in results["qsd"] and len(results["qsd"]["gwid"]): results["user"] = NotifyThreema.unquote(results["qsd"]["gwid"]) if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyThreema.parse_list( results["qsd"]["to"], allow_whitespace=False ) return results apprise-1.10.0/apprise/plugins/twilio.py000066400000000000000000000527771517341665700203020ustar00rootroot00000000000000# 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 Twilio account to which you can get your # AUTH_TOKEN and ACCOUNT SID right from your console/dashboard at: # https://www.twilio.com/console # # You will also need to send the SMS or do the call From a phone number or # account id name. # This is identified as the source (or where the SMS message or the call will # originate from). Activated phone numbers can be found on your dashboard here: # - https://www.twilio.com/console/phone-numbers/incoming # # Alternatively, you can open your wallet and request a different Twilio # phone # from: # https://www.twilio.com/console/phone-numbers/search # # or consider purchasing a short-code from here: # https://www.twilio.com/docs/glossary/what-is-a-short-code # from json import loads 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_phone_no, validate_regex from .base import NotifyBase # Twilio Mode Detection MODE_DETECT_RE = re.compile( r"\s*((?P[^:]+)\s*:\s*)?(?P.+)$", re.I ) class TwilioNotificationMethod: """Twilio Notification Method.""" SMS = "sms" CALL = "call" TWILIO_NOTIFICATION_METHODS = ( TwilioNotificationMethod.SMS, TwilioNotificationMethod.CALL, ) class TwilioMessageMode: """Twilio Message Mode.""" # SMS/MMS TEXT = "T" # via WhatsApp WHATSAPP = "W" class NotifyTwilio(NotifyBase): """A wrapper for Twilio Notifications.""" # The default descriptive name associated with the Notification service_name = "Twilio" # The services URL service_url = "https://www.twilio.com/" # All notification requests are secure secure_protocol = "twilio" # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.20 # the number of seconds undelivered messages should linger for # in the Twilio queue validity_period = 14400 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/twilio/" # Twilio uses the http protocol with JSON message requests notify_sms_url = ( "https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json" ) # Twilio uses the http protocol with JSON call requests notify_call_url = ( "https://api.twilio.com/2010-04-01/Accounts/{sid}/Calls.json" ) # The maximum length of the sms body body_sms_maxlen = 160 # The maximum length of the call body in xml format body_call_maxlen = 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}://{account_sid}:{auth_token}@{from_phone}", "{schema}://{account_sid}:{auth_token}@{from_phone}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "account_sid": { "name": _("Account SID"), "type": "string", "private": True, "required": True, "regex": (r"^AC[a-f0-9]+$", "i"), }, "auth_token": { "name": _("Auth Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, "from_phone": { "name": _("From Phone No"), "type": "string", "required": True, "regex": (r"^([a-z]+:)?\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^([a-z]+:)?[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "short_code": { "name": _("Target Short Code"), "type": "string", "regex": (r"^[0-9]{5,6}$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "from_phone", }, "sid": { "alias_of": "account_sid", }, "token": { "alias_of": "auth_token", }, "apikey": { "name": _("API Key"), "type": "string", "private": True, "regex": (r"^SK[a-f0-9]+$", "i"), }, "method": { "name": _("Notification Method: sms or call"), "type": "choice:string", "values": TWILIO_NOTIFICATION_METHODS, "default": TWILIO_NOTIFICATION_METHODS[0], }, "to": { "alias_of": "targets", }, }, ) def __init__( self, account_sid, auth_token, source, targets=None, apikey=None, method=None, **kwargs, ): """Initialize Twilio Object.""" super().__init__(**kwargs) # The Account SID associated with the account self.account_sid = validate_regex( account_sid, *self.template_tokens["account_sid"]["regex"] ) if not self.account_sid: msg = ( f"An invalid Twilio Account SID ({account_sid}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # The Authentication Token associated with the account self.auth_token = validate_regex( auth_token, *self.template_tokens["auth_token"]["regex"] ) if not self.auth_token: msg = ( "An invalid Twilio Authentication Token " f"({auth_token}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # The API Key associated with the account (optional) self.apikey = validate_regex( apikey, *self.template_args["apikey"]["regex"] ) # Set notification method if isinstance(method, str) and method: self.method = next( ( a for a in TWILIO_NOTIFICATION_METHODS if a.startswith(method.lower()) ), None, ) if self.method not in TWILIO_NOTIFICATION_METHODS: msg = ( f"The Twilio notification method specified ({method}) " "is invalid." ) self.logger.warning(msg) raise TypeError(msg) else: self.method = self.template_args["method"]["default"] # Detect mode result = MODE_DETECT_RE.match(source) if not result: msg = ( "The Account (From) Phone # or Short-code specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # prepare our default mode to use for all numbers that follow in # target definitions self.default_mode = ( TwilioMessageMode.WHATSAPP if result.group("mode") and result.group("mode")[0].lower() == "w" else TwilioMessageMode.TEXT ) # Check compatibility between notification method and mode if ( self.method == TwilioNotificationMethod.CALL and self.default_mode == TwilioMessageMode.WHATSAPP ): msg = ( "The notification method Call is not valid along " "message mode Whatsapp." ) self.logger.warning(msg) raise TypeError(msg) result = is_phone_no(result.group("phoneno"), min_len=5) if not result: msg = ( "The Account (From) Phone # or Short-code specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Store The Source Phone # and/or short-code self.source = result["full"] if len(self.source) < 11 or len(self.source) > 14: # https://www.twilio.com/docs/glossary/what-is-a-short-code # A short code is a special 5 or 6 digit telephone number # that's shorter than a full phone number. if len(self.source) not in (5, 6): msg = ( "The Account (From) Phone # specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # else... it as a short code so we're okay else: # We're dealing with a phone number; so we need to just # place a plus symbol at the end of it self.source = f"+{self.source}" # Parse our targets self.targets = [] for entry in parse_phone_no(targets, prefix=True): # Detect mode # w: (or whatsapp:) will trigger whatsapp message otherwise # sms/mms as normal result = MODE_DETECT_RE.match(entry) mode = ( TwilioMessageMode.WHATSAPP if result.group("mode") and result.group("mode")[0].lower() == "w" else self.default_mode ) # Validate targets and drop bad ones: result = is_phone_no(result.group("phoneno")) if not result: self.logger.warning( f"Dropped invalid phone # ({entry}) specified.", ) continue # We can't use WhatsApp using short-codes as our source or # for phone calls if ( len(self.source) in (5, 6) or self.method == TwilioNotificationMethod.CALL ) and mode is TwilioMessageMode.WHATSAPP: self.logger.warning( f"Dropped WhatsApp phone # ({entry}) because source" " provided is a short-code or because notification" " method is phone call.", ) continue # store valid phone number self.targets.append((mode, "+{}".format(result["full"]))) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Twilio Notification.""" if not self.targets and len(self.source) in (5, 6): # Generate a warning since we're a short-code. We need # a number to message at minimum self.logger.warning("There are no valid Twilio 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 payload payload = {} # Prepare our Twilio URL and payload parameter according # to notification method if self.method == TwilioNotificationMethod.SMS: url = self.notify_sms_url.format(sid=self.account_sid) payload["Body"] = body else: url = self.notify_call_url.format(sid=self.account_sid) payload["Twiml"] = body # Create a copy of the targets list targets = list(self.targets) # Set up our authentication. Prefer the API Key if provided. auth = (self.apikey or self.account_sid, self.auth_token) if len(targets) == 0 and self.method != TwilioNotificationMethod.CALL: # No sources specified, use our own phone only with messages targets.append((self.default_mode, self.source)) while len(targets): # Get our target to notify (mode, target) = targets.pop(0) # Prepare our user if mode is TwilioMessageMode.TEXT: payload["From"] = self.source payload["To"] = target else: # WhatsApp support (via Twilio) payload["From"] = f"whatsapp:{self.source}" payload["To"] = f"whatsapp:{target}" # Some Debug Logging self.logger.debug( "Twilio POST URL:" f" {url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Twilio Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, auth=auth, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) 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 try: # Update our status response if we can json_response = loads(r.content) status_code = json_response.get("code", status_code) 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. pass self.logger.warning( "Failed to send Twilio notification to {}: " "{}{}error={}.".format( 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 Twilio notification to {target}.") except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending Twilio:{target} " + "notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @property def body_maxlen(self): """The maximum allowable characters allowed in the body per message. It is dependent on the notification method.""" return ( self.body_sms_maxlen if self.method == TwilioNotificationMethod.SMS else self.body_call_maxlen ) @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.account_sid, self.auth_token, self.source, ) 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) params["method"] = self.method if self.apikey is not None: # apikey specified; pass it back on the url params["apikey"] = self.apikey return "{schema}://{sid}:{token}@{source}/{targets}/?{params}".format( schema=self.secure_protocol, sid=self.pprint( self.account_sid, privacy, mode=PrivacyMode.Tail, safe="" ), token=self.pprint(self.auth_token, privacy, safe=""), source=NotifyTwilio.quote( ( self.source if self.default_mode is TwilioMessageMode.TEXT else f"w:{self.source}" ), safe="", ), targets="/".join( [ NotifyTwilio.quote( ( x[1] if x[0] is TwilioMessageMode.TEXT else f"w:{x[1]}" ), safe="", ) for x in self.targets ] ), params=NotifyTwilio.urlencode(params), ) 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 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"] = NotifyTwilio.split_path(results["fullpath"]) # The hostname is our source number results["source"] = NotifyTwilio.unquote(results["host"]) # Get our account_side and auth_token from the user/pass config results["account_sid"] = NotifyTwilio.unquote(results["user"]) results["auth_token"] = NotifyTwilio.unquote(results["password"]) # Auth Token if "token" in results["qsd"] and len(results["qsd"]["token"]): # Extract the account sid from an argument results["auth_token"] = NotifyTwilio.unquote( results["qsd"]["token"] ) # Account SID if "sid" in results["qsd"] and len(results["qsd"]["sid"]): # Extract the account sid from an argument results["account_sid"] = NotifyTwilio.unquote( results["qsd"]["sid"] ) # API Key if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): results["apikey"] = results["qsd"]["apikey"] # 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"] = NotifyTwilio.unquote(results["qsd"]["from"]) if "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyTwilio.unquote(results["qsd"]["source"]) # 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"] += NotifyTwilio.parse_phone_no( results["qsd"]["to"], prefix=True ) # Notification method if "method" in results["qsd"] and len(results["qsd"]["method"]): # Extract the notification method from an argument results["method"] = NotifyTwilio.unquote(results["qsd"]["method"]) return results apprise-1.10.0/apprise/plugins/twist.py000066400000000000000000000646261517341665700201410ustar00rootroot00000000000000# 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. # # All of the documentation needed to work with the Twist API can be found # here: https://developer.twist.com/v3/ from itertools import chain from json import loads import re import requests from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_email, parse_list from .base import NotifyBase # A workspace can also be interpreted as a team name too! IS_CHANNEL = re.compile( r"^#?(?P((?P[A-Za-z0-9_-]+):)?" r"(?P[^\s]{1,64}))$" ) IS_CHANNEL_ID = re.compile( r"^(?P((?P[0-9]+):)?(?P[0-9]+))$" ) # Used to break apart list of potential tags by their delimiter # into a usable list. LIST_DELIM = re.compile(r"[ \t\r\n,\\/]+") class NotifyTwist(NotifyBase): """A wrapper for Notify Twist Notifications.""" # The default descriptive name associated with the Notification service_name = "Twist" # The services URL service_url = "https://twist.com" # The default secure protocol secure_protocol = "twist" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/twist/" # The maximum size of the message body_maxlen = 1000 # Default to markdown notify_format = NotifyFormat.MARKDOWN # The default Notification URL to use api_url = "https://api.twist.com/api/v3/" # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.2 # The default channel to notify if no targets are specified default_notification_channel = "general" # Define object templates templates = ( "{schema}://{password}:{email}", "{schema}://{password}:{email}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "email": { "name": _("Email"), "type": "string", "required": True, }, "target_channel": { "name": _("Target Channel"), "type": "string", "prefix": "#", "map_to": "targets", }, "target_channel_id": { "name": _("Target Channel ID"), "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", }, }, ) def __init__(self, email=None, targets=None, **kwargs): """Initialize Notify Twist Object.""" super().__init__(**kwargs) # Initialize channels list self.channels = set() # Initialize Channel ID which are stored as: # : self.channel_ids = set() # The token is None if we're not logged in and False if we # failed to log in. Otherwise it is set to the actual token self.token = None # Our default workspace (associated with our token) self.default_workspace = None # A set of all of the available workspaces self._cached_workspaces = set() # A mapping of channel names, the layout is as follows: # { # : { # : , # : , # ... # }, # : { # : , # : , # ... # }, # } self._cached_channels = {} # Initialize our Email Object self.email = email if email else f"{self.user}@{self.host}" # Check if it is valid result = is_email(self.email) if not result: # let outer exception handle this msg = f"The Twist Auth email specified ({self.email}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Re-assign email based on what was parsed self.email = result["full_email"] if email: # Force user/host to be that of the defined email for # consistency. This is very important for those initializing # this object with the the email object would could potentially # cause inconsistency to contents in the NotifyBase() object self.user = result["user"] self.host = result["domain"] if not self.password: msg = f"No Twist password was specified with account: {self.email}" self.logger.warning(msg) raise TypeError(msg) # Validate recipients and drop bad ones: for recipient in parse_list(targets): result = IS_CHANNEL_ID.match(recipient) if result: # store valid channel id self.channel_ids.add(result.group("name")) continue result = IS_CHANNEL.match(recipient) if result: # store valid device self.channels.add(result.group("name").lower()) continue self.logger.warning( f"Dropped invalid channel/id ({recipient}) specified.", ) if len(self.channels) + len(self.channel_ids) == 0: # Notify our default channel self.channels.add(self.default_notification_channel) self.logger.warning( "Added default notification channel " f"{self.default_notification_channel}" ) 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 if self.secure else self.protocol, self.user, self.password, self.host, self.port, ) 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) return ( "{schema}://{password}:{user}@{host}/{targets}/?{params}".format( schema=self.secure_protocol, password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), user=self.quote(self.user, safe=""), host=self.host, targets="/".join( [ NotifyTwist.quote(x, safe="") for x in chain( # Channels are prefixed with a pound/hashtag symbol [f"#{x}" for x in self.channels], # Channel IDs self.channel_ids, ) ] ), params=NotifyTwist.urlencode(params), ) ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.channels) + len(self.channel_ids) def login(self): """A simple wrapper to authenticate with the Twist Server.""" # Prepare our payload payload = { "email": self.email, "password": self.password, } # Reset our default workspace self.default_workspace = None # Reset our cached objects self._cached_workspaces = set() self._cached_channels = {} # Send Login Information postokay, response = self._fetch( "users/login", payload=payload, # We set this boolean so internal recursion doesn't take place. login=True, ) if not postokay or not response: # Setting this variable to False as a way of letting us know # we failed to authenticate on our last attempt self.token = False return False # Our response object looks like this (content has been altered for # presentation purposes): # { # "contact_info": null, # "profession": null, # "timezone": "UTC", # "avatar_id": null, # "id": 123456, # "first_name": "Jordan", # "comet_channel": # "124371-34be423219130343030d4ec0a3dabbbbbe565eee", # "restricted": false, # "default_workspace": 92020, # "snooze_dnd_end": null, # "email": "user@example.com", # "comet_server": "https://comet.twist.com", # "snooze_until": null, # "lang": "en", # "feature_flags": [], # "short_name": "Jordan P.", # "away_mode": null, # "time_format": "12", # "client_id": "cb01f37e-a5b2-13e9-ba2a-023a33d10dc0", # "removed": false, # "emails": [ # { # "connected": [], # "email": "user@example.com", # "primary": true # } # ], # "scheduled_banners": [ # "threads_3", # "threads_1", # "notification_permissions", # "search_1", # "messages_1", # "team_1", # "inbox_2", # "inbox_1" # ], # "snooze_dnd_start": null, # "name": "Jordan Peterson", # "off_days": [], # "bot": false, # "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", # "snoozed": false, # "setup_pending": false, # "date_format": "MM/DD/YYYY" # } # Store our default workspace self.default_workspace = response.get("default_workspace") # Acquire our token self.token = response.get("token") self.logger.info(f"Authenticated to Twist as {self.email}") return True def logout(self): """A simple wrapper to log out of the server.""" if not self.token: # Nothing more to do return True # Send Logout Message _postokay, _response = self._fetch("users/logout") # reset our token self.token = None # There is no need to handling failed log out attempts at this time return True def get_workspaces(self): """Returns all workspaces associated with this user account as a set. This returned object is either an empty dictionary or one that looks like this: { 'workspace': , 'workspace': , 'workspace': , } All workspaces are made lowercase for comparison purposes """ if not self.token and not self.login(): # Nothing more to do return {} postokay, response = self._fetch("workspaces/get") if not postokay or not response: # We failed to retrieve return {} # The response object looks like so: # [ # { # "created_ts": 1563044447, # "name": "apprise", # "creator": 123571, # "color": 1, # "default_channel": 13245, # "plan": "free", # "default_conversation": 63022, # "id": 12345 # } # ] # Knowing our response, we can iterate over each object and cache our # object result = {} for entry in response: result[entry.get("name", "").lower()] = entry.get("id", "") return result def get_channels(self, wid): """Simply returns the channel objects associated with the specified workspace id. This returned object is either an empty dictionary or one that looks like this: { 'channel1': , 'channel2': , 'channel3': , } All channels are made lowercase for comparison purposes """ if not self.token and not self.login(): # Nothing more to do return {} payload = {"workspace_id": wid} postokay, response = self._fetch("channels/get", payload=payload) if not postokay or not isinstance(response, list): # We failed to retrieve return {} # Response looks like this: # [ # { # "id": 123, # "name": "General" # "workspace_id": 12345, # "color": 1, # "description": "", # "archived": false, # "public": true, # "user_ids": [ # 8754 # ], # "created_ts": 1563044447, # "creator": 123571, # } # ] # # Knowing our response, we can iterate over each object and cache our # object result = {} for entry in response: result[entry.get("name", "").lower()] = entry.get("id", "") return result def _channel_migration(self): """A simple wrapper to get all of the current workspaces including the default one. This plays a role in what channel(s) get notified and where. A cache lookup has overhead, and is only required to be preformed if the user specified channels by their string value """ if not self.token and not self.login(): # Nothing more to do return False if not len(self.channels): # Nothing to do; take an early exit return True if ( self.default_workspace and self.default_workspace not in self._cached_channels ): # Get our default workspace entries self._cached_channels[self.default_workspace] = self.get_channels( self.default_workspace ) # initialize our error tracking has_error = False while len(self.channels): # Pop our channel off of the stack result = IS_CHANNEL.match(self.channels.pop()) # Populate our key variables workspace = result.group("workspace") channel = result.group("channel").lower() # Acquire our workspace_id if we can if workspace: # We always work with the workspace in it's lowercase form workspace = workspace.lower() # A workspace was defined if not len(self._cached_workspaces): # cache our workspaces; this only needs to be done once self._cached_workspaces = self.get_workspaces() if workspace not in self._cached_workspaces: # not found self.logger.warning( f"The Twist User {self.email} is not associated " f"with the Team {workspace}" ) # Toggle our return flag has_error = True continue # Store the workspace id workspace_id = self._cached_workspaces[workspace] else: # use default workspace workspace_id = self.default_workspace # Check to see if our channel exists in our default workspace if ( workspace_id in self._cached_channels and channel in self._cached_channels[workspace_id] ): # Store our channel ID self.channel_ids.add( f"{workspace_id}" f":{self._cached_channels[workspace_id][channel]}" ) continue # if we reach here, we failed to add our channel self.logger.warning( "The Channel #{} was not found{}.".format( channel, "" if not workspace else f" with Team {workspace}", ) ) # Toggle our return flag has_error = True continue # There is no need to handling failed log out attempts at this time return not has_error def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Twist Notification.""" # error tracking (used for function return) has_error = False if not self.token and not self.login(): # We failed to authenticate - we're done return False if len(self.channels) > 0: # Converts channels to their maped IDs if found; this is the only # way to send notifications to Twist self._channel_migration() if not len(self.channel_ids): # We have nothing to notify self.logger.warning("There are no Twist targets to notify") return False # Notify all of our identified channels ids = list(self.channel_ids) while len(ids) > 0: # Retrieve our Channel Object result = IS_CHANNEL_ID.match(ids.pop()) # We need both the workspace/team id and channel id channel_id = int(result.group("channel")) # Prepare our payload payload = { "channel_id": channel_id, "title": title, "content": body, } postokay, _response = self._fetch( "threads/add", payload=payload, ) # only toggle has_error flag if we had an error if not postokay: # Mark our failure has_error = True continue # If we reach here, we were successful self.logger.info( "Sent Twist notification to {}.".format(result.group("name")) ) return not has_error def _fetch(self, url, payload=None, method="POST", login=False): """Wrapper to Twist API requests object.""" # use what was specified, otherwise build headers dynamically headers = { "User-Agent": self.app_id, } headers["Content-Type"] = ( "application/x-www-form-urlencoded; charset=utf-8" ) if self.token: # Set our token headers["Authorization"] = f"Bearer {self.token}" # Prepare our api url api_url = f"{self.api_url}{url}" # Some Debug Logging self.logger.debug( f"Twist {method} URL: {api_url} " f"(cert_verify={self.verify_certificate})" ) self.logger.debug(f"Twist Payload: {payload!s}") # Always call throttle before any remote server i/o is made; self.throttle() # 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( api_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Get our JSON content if it's possible try: content = 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 = {} # handle authentication errors where our token has just simply # expired. The error response content looks like this: # { # "error_code": 200, # "error_uuid": "af80bd0715434231a649f2258d7fb946", # "error_extra": {}, # "error_string": "Invalid token" # } # # Authentication related codes: # 120 = You are not logged in # 200 = Invalid Token # # Source: https://developer.twist.com/v3/#errors # # We attempt to login again and retry the original request # if we aren't in the process of handling a login already if ( r.status_code != requests.codes.ok and login is False and isinstance(content, dict) and content.get("error_code") in (120, 200) and self.login() ): r = fn( api_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Get our JSON content if it's possible try: content = 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 = {} if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyTwist.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Twist {} to {}: {}error={}.".format( method, api_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 Twist {method} to {api_url}" ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure return (False, content) return (True, content) @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 if not results.get("user"): # A username is required return None # Acquire our targets results["targets"] = NotifyTwist.split_path(results["fullpath"]) if not results.get("password"): # Password is required; we will accept the very first entry on the # path as a password instead if len(results["targets"]) == 0: # No targets to get our password from return None # We need to requote contents since this variable will get # unquoted later on in the process. This step appears a bit # hacky, but it allows us to support the password in this location # - twist://user@example.com/password results["password"] = NotifyTwist.quote( results["targets"].pop(0), safe="" ) else: # Now we handle our format: # twist://password:email # # since URL logic expects # schema://user:password@host # # you can see how this breaks. The colon at the front delmits # passwords and you can see the twist:// url inverts what we # expect: # twist://password:user@example.com # # twist://abc123:bob@example.com using normal conventions would # have interpreted 'bob' as the password and 'abc123' as the user. # For the purpose of apprise simplifying this for us, we need to # swap these arguments when we prepare the email. password = results["user"] results["user"] = results["password"] results["password"] = password # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyTwist.parse_list(results["qsd"]["to"]) return results def __del__(self): """Destructor.""" self.logout() apprise-1.10.0/apprise/plugins/twitter.py000066400000000000000000000764061517341665700204700ustar00rootroot00000000000000# 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. # See https://developer.twitter.com/en/docs/direct-messages/\ # sending-and-receiving/api-reference/new-event.html import contextlib from copy import deepcopy from datetime import datetime, timezone from json import dumps, loads import re import requests from requests_oauthlib import OAuth1 from ..attachment.base import AttachBase from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool, parse_list, validate_regex from .base import NotifyBase IS_USER = re.compile(r"^\s*@?(?P[A-Z0-9_]+)$", re.I) class TwitterMessageMode: """Twitter Message Mode.""" # DM (a Direct Message) DM = "dm" # A Public Tweet TWEET = "tweet" # Define the types in a list for validation purposes TWITTER_MESSAGE_MODES = ( TwitterMessageMode.DM, TwitterMessageMode.TWEET, ) class NotifyTwitter(NotifyBase): """A wrapper to Twitter Notifications.""" # The default descriptive name associated with the Notification service_name = "Twitter" # The services URL service_url = "https://twitter.com/" # The default secure protocol is twitter. secure_protocol = ("x", "twitter", "tweet") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/twitter/" # Support attachments attachment_support = True # Do not set body_maxlen as it is set in a property value below # since the length varies depending if we are doing a direct message # or a tweet # body_maxlen = see below @propery defined # Twitter does have titles when creating a message title_maxlen = 0 # Twitter API Reference To Acquire Someone's Twitter ID twitter_lookup = "https://api.twitter.com/1.1/users/lookup.json" # Twitter API Reference To Acquire Current Users Information twitter_whoami = ( "https://api.twitter.com/1.1/account/verify_credentials.json" ) # Twitter API Reference To Send A Private DM twitter_dm = "https://api.twitter.com/1.1/direct_messages/events/new.json" # Twitter API Reference To Send A Public Tweet twitter_tweet = "https://api.twitter.com/1.1/statuses/update.json" # it is documented on the site that the maximum images per tweet # is 4 (unless it's a GIF, then it's only 1) __tweet_non_gif_images_batch = 4 # Twitter Media (Attachment) Upload Location twitter_media = "https://upload.twitter.com/1.1/media/upload.json" # Twitter is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: # X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our # rate-limit to be reset. # X-Rate-Limit-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) # Default to 1000; users can send up to 1000 DM's and 2400 tweets a day # This value only get's adjusted if the server sets it that way ratelimit_remaining = 1 templates = ( "{schema}://{ckey}/{csecret}/{akey}/{asecret}", "{schema}://{ckey}/{csecret}/{akey}/{asecret}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "ckey": { "name": _("Consumer Key"), "type": "string", "private": True, "required": True, }, "csecret": { "name": _("Consumer Secret"), "type": "string", "private": True, "required": True, }, "akey": { "name": _("Access Key"), "type": "string", "private": True, "required": True, }, "asecret": { "name": _("Access Secret"), "type": "string", "private": True, "required": True, }, "target_user": { "name": _("Target User"), "type": "string", "prefix": "@", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "mode": { "name": _("Message Mode"), "type": "choice:string", "values": TWITTER_MESSAGE_MODES, "default": TwitterMessageMode.DM, }, "cache": { "name": _("Cache Results"), "type": "bool", "default": True, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": True, }, }, ) def __init__( self, ckey, csecret, akey, asecret, targets=None, mode=None, cache=True, batch=True, **kwargs, ): """Initialize Twitter Object.""" super().__init__(**kwargs) self.ckey = validate_regex(ckey) if not self.ckey: msg = "An invalid Twitter Consumer Key was specified." self.logger.warning(msg) raise TypeError(msg) self.csecret = validate_regex(csecret) if not self.csecret: msg = "An invalid Twitter Consumer Secret was specified." self.logger.warning(msg) raise TypeError(msg) self.akey = validate_regex(akey) if not self.akey: msg = "An invalid Twitter Access Key was specified." self.logger.warning(msg) raise TypeError(msg) self.asecret = validate_regex(asecret) if not self.asecret: msg = "An invalid Access Secret was specified." self.logger.warning(msg) raise TypeError(msg) # Store our webhook mode self.mode = ( self.template_args["mode"]["default"] if not isinstance(mode, str) else mode.lower() ) if mode and isinstance(mode, str): self.mode = next( (a for a in TWITTER_MESSAGE_MODES if a.startswith(mode)), None ) if self.mode not in TWITTER_MESSAGE_MODES: msg = ( f"The Twitter message mode specified ({mode}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) else: self.mode = self.template_args["mode"]["default"] # Set Cache Flag self.cache = cache # Prepare Image Batch Mode Flag self.batch = batch # Track any errors has_error = False # Identify our targets self.targets = [] for target in parse_list(targets): match = IS_USER.match(target) if match and match.group("user"): self.targets.append(match.group("user")) continue has_error = True self.logger.warning( f"Dropped invalid Twitter user ({target}) specified.", ) if has_error and not self.targets: # We have specified that we want to notify one or more individual # and we failed to load any of them. Since it's also valid to # notify no one at all (which means we notify ourselves), it's # important we don't switch from the users original intentions self.targets = None # Initialize our cache values self._whoami_cache = None self._user_cache = {} return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Twitter Notification.""" if self.targets is None: self.logger.warning("No valid Twitter targets to notify.") return False # Build a list of our attachments attachments = [] if attach and self.attachment_support: # 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 Twitter attachment " f"{attachment.url(privacy=True)}." ) continue self.logger.debug( "Preparing Twitter attachment " f"{attachment.url(privacy=True)}" ) # Upload our image and get our id associated with it # see: https://developer.twitter.com/en/docs/twitter-api/v1/\ # media/upload-media/api-reference/post-media-upload postokay, response = self._fetch( self.twitter_media, 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("media_id") ): self.logger.debug( "Could not attach the file to Twitter: %s (mime=%s)", filename, attachment.mimetype, ) continue # If we get here, our output will look something like this: # { # "media_id": 710511363345354753, # "media_id_string": "710511363345354753", # "media_key": "3_710511363345354753", # "size": 11065, # "expires_after_secs": 86400, # "image": { # "image_type": "image/jpeg", # "w": 800, # "h": 320 # } # } response.update( { # Update our response to additionally include the # attachment details "file_name": filename, "file_mime": attachment.mimetype, "file_path": attachment.path, } ) # Save our pre-prepared payload for attachment posting attachments.append(response) # - calls _send_tweet if the mode is set so # - calls _send_dm (direct message) otherwise return getattr(self, f"_send_{self.mode}")( body=body, title=title, notify_type=notify_type, attachments=attachments, **kwargs, ) def _send_tweet( self, body, title="", notify_type=NotifyType.INFO, attachments=None, **kwargs, ): """Twitter Public Tweet.""" # Error Tracking has_error = False payload = { "status": body, } payloads = [] if not attachments: payloads.append(payload) else: # Group our images if batch is set to do so batch_size = ( 1 if not self.batch else self.__tweet_non_gif_images_batch ) # Track our batch control in our message generation batches = [] batch = [] for attachment in attachments: batch.append(str(attachment["media_id"])) # Twitter supports batching images together. This allows # the batching of multiple images together. Twitter also # makes it clear that you can't batch `gif` files; they need # to be separate. So the below preserves the ordering that # a user passed their attachments in. if 4-non-gif images # are passed, they are all part of a single message. # # however, if they pass in image, gif, image, gif. The # gif's inbetween break apart the batches so this would # produce 4 separate tweets. # # If you passed in, image, image, gif, image. <- This would # produce 3 images (as the first 2 images could be lumped # together as a batch) if ( not re.match( r"^image/(png|jpe?g)", attachment["file_mime"], re.I ) or len(batch) >= batch_size ): batches.append(",".join(batch)) batch = [] if batch: batches.append(",".join(batch)) for no, media_ids in enumerate(batches): payload_ = deepcopy(payload) payload_["media_ids"] = media_ids if no or not body: # strip text and replace it with the image representation payload_["status"] = f"{no + 1:02d}/{len(batches):02d}" payloads.append(payload_) for no, payload in enumerate(payloads, start=1): # Send Tweet postokay, response = self._fetch( self.twitter_tweet, payload=payload, json=False, ) if not postokay: # Track our error has_error = True errors = [] with contextlib.suppress(KeyError, TypeError): errors = [ "Error Code {}: {}".format( e.get("code", "unk"), e.get("message") ) for e in response["errors"] ] for error in errors: self.logger.debug( "Tweet [%.2d/%.2d] Details: %s", no, len(payloads), error, ) continue try: url = "https://twitter.com/{}/status/{}".format( response["user"]["screen_name"], response["id_str"] ) except (KeyError, TypeError): url = "unknown" self.logger.debug( "Tweet [%.2d/%.2d] Details: %s", no, len(payloads), url ) self.logger.info( "Sent [%.2d/%.2d] Twitter notification as public tweet.", no, len(payloads), ) return not has_error def _send_dm( self, body, title="", notify_type=NotifyType.INFO, attachments=None, **kwargs, ): """Twitter Direct Message.""" # Error Tracking has_error = False payload = { "event": { "type": "message_create", "message_create": { "target": { # This gets assigned "recipient_id": None, }, "message_data": { "text": body, }, }, } } # Lookup our users (otherwise we look up ourselves) targets = ( self._whoami(lazy=self.cache) if not len(self.targets) else self._user_lookup(self.targets, lazy=self.cache) ) if not targets: # We failed to lookup any users self.logger.warning( "Failed to acquire user(s) to Direct Message via Twitter" ) return False payloads = [] if not attachments: payloads.append(payload) else: for no, attachment in enumerate(attachments): payload_ = deepcopy(payload) data = payload_["event"]["message_create"]["message_data"] data["attachment"] = { "type": "media", "media": {"id": attachment["media_id"]}, "additional_owners": ",".join( [str(x) for x in targets.values()] ), } if no or not body: # strip text and replace it with the image representation data["text"] = f"{no + 1:02d}/{len(attachments):02d}" payloads.append(payload_) for no, payload in enumerate(payloads, start=1): for screen_name, user_id in targets.items(): # Assign our user target = payload["event"]["message_create"]["target"] target["recipient_id"] = user_id # Send Twitter DM postokay, _response = self._fetch( self.twitter_dm, payload=payload, ) if not postokay: # Track our error has_error = True continue self.logger.info( f"Sent [{no:02d}/{len(payloads):02d}] " f"Twitter DM notification to @{screen_name}." ) return not has_error def _whoami(self, lazy=True): """Looks details of current authenticated user.""" if lazy and self._whoami_cache is not None: # Use cached response return self._whoami_cache # Contains a mapping of screen_name to id results = {} # Send Twitter DM postokay, response = self._fetch( self.twitter_whoami, method="GET", json=False, ) if postokay: try: results[response["screen_name"]] = response["id"] self._whoami_cache = { response["screen_name"]: response["id"], } self._user_cache.update(results) except (TypeError, KeyError): pass return results def _user_lookup(self, screen_name, lazy=True): """Looks up a screen name and returns the user id. the screen_name can be a list/set/tuple as well """ # Contains a mapping of screen_name to id results = {} # Build a unique set of names names = parse_list(screen_name) if lazy and self._user_cache: # Use cached response results = {k: v for k, v in self._user_cache.items() if k in names} # limit our names if they already exist in our cache names = [name for name in names if name not in results] if not len(names): # They're is nothing further to do return results # Twitters API documents that it can lookup to 100 # results at a time. # https://developer.twitter.com/en/docs/accounts-and-users/\ # follow-search-get-users/api-reference/get-users-lookup for i in range(0, len(names), 100): # Look up our names by their screen_name postokay, response = self._fetch( self.twitter_lookup, payload={ "screen_name": names[i : i + 100], }, json=False, ) if not postokay or not isinstance(response, list): # Track our error continue # Update our user index for entry in response: with contextlib.suppress(TypeError, KeyError): results[entry["screen_name"]] = entry["id"] # Cache our response for future use; this saves on un-nessisary extra # hits against the Twitter API when we already know the answer self._user_cache.update(results) return results def _fetch(self, url, payload=None, method="POST", json=True): """Wrapper to Twitter API requests object.""" headers = { "User-Agent": self.app_id, } data = None files = None # Open our attachment path if required: if isinstance(payload, AttachBase): # prepare payload files = { "media": ( payload.name, # file handle is safely closed in `finally`; inline open is # intentional open(payload.path, "rb"), # noqa: SIM115 ), } elif json: headers["Content-Type"] = "application/json" data = dumps(payload) else: data = payload auth = OAuth1( self.ckey, client_secret=self.csecret, resource_owner_key=self.akey, resource_owner_secret=self.asecret, ) # Some Debug Logging self.logger.debug( f"Twitter {method} URL: {url} " f"(cert_verify={self.verify_certificate})" ) self.logger.debug(f"Twitter Payload: {payload!s}") # 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.5 seconds to the end just to allow a grace # period. wait = (self.ratelimit_reset - now).total_seconds() + 0.5 # Default content response object content = {} # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) # acquire our request mode fn = requests.post if method == "POST" else requests.get try: r = fn( url, data=data, files=files, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) try: content = loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyTwitter.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Twitter {} 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) try: # Capture rate limiting if possible self.ratelimit_remaining = int( r.headers.get("x-rate-limit-remaining") ) self.ratelimit_reset = datetime.fromtimestamp( int(r.headers.get("x-rate-limit-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 except requests.RequestException as e: self.logger.warning( f"Exception received when sending Twitter {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) finally: # Close our file (if it's open) stored in the second element # of our files tuple (index 1) if files: files["media"][1].close() return (True, content) @property def body_maxlen(self): """The maximum allowable characters allowed in the body per message This is used during a Private DM Message Size (not Public Tweets which are limited to 280 characters)""" return 10000 if self.mode == TwitterMessageMode.DM else 280 @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.ckey, self.csecret, self.akey, self.asecret, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "mode": self.mode, "batch": "yes" if self.batch else "no", "cache": "yes" if self.cache else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return ( "{schema}://{ckey}/{csecret}/{akey}/{asecret}" "/{targets}?{params}".format( schema=self.secure_protocol[0], ckey=self.pprint(self.ckey, privacy, safe=""), csecret=self.pprint( self.csecret, privacy, mode=PrivacyMode.Secret, safe="" ), akey=self.pprint(self.akey, privacy, safe=""), asecret=self.pprint( self.asecret, privacy, mode=PrivacyMode.Secret, safe="" ), targets=( "/".join( [ NotifyTwitter.quote(f"@{target}", safe="@") for target in self.targets ] ) if self.targets else "" ), params=NotifyTwitter.urlencode(params), ) ) 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 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 # Acquire remaining tokens tokens = NotifyTwitter.split_path(results["fullpath"]) # The consumer token is stored in the hostname results["ckey"] = NotifyTwitter.unquote(results["host"]) # # Now fetch the remaining tokens # # Consumer Secret results["csecret"] = tokens.pop(0) if tokens else None # Access Token Key results["akey"] = tokens.pop(0) if tokens else None # Access Token Secret results["asecret"] = tokens.pop(0) if tokens else None # The defined twitter mode if "mode" in results["qsd"] and len(results["qsd"]["mode"]): results["mode"] = NotifyTwitter.unquote(results["qsd"]["mode"]) elif results["schema"].startswith("tweet"): results["mode"] = TwitterMessageMode.TWEET results["targets"] = [] # if a user has been defined, add it to the list of targets if results.get("user"): results["targets"].append(results.get("user")) # Store any remaining items as potential targets results["targets"].extend(tokens) # Get Cache Flag (reduces lookup hits) if "cache" in results["qsd"] and len(results["qsd"]["cache"]): results["cache"] = parse_bool(results["qsd"]["cache"], True) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyTwitter.template_args["batch"]["default"] ) ) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyTwitter.parse_list( results["qsd"]["to"] ) return results apprise-1.10.0/apprise/plugins/vapid/000077500000000000000000000000001517341665700175025ustar00rootroot00000000000000apprise-1.10.0/apprise/plugins/vapid/__init__.py000066400000000000000000000504551517341665700216240ustar00rootroot00000000000000# 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 itertools import chain from json import dumps import os import time import requests from ...common import NotifyImageSize, NotifyType, PersistentStoreMode from ...locale import gettext_lazy as _ from ...utils import pem as _pem from ...utils.base64 import base64_urlencode from ...utils.parse import is_email, parse_bool, parse_list from ..base import NotifyBase from . import subscription class VapidPushMode: """Supported Vapid Push Services.""" CHROME = "chrome" FIREFOX = "firefox" EDGE = "edge" OPERA = "opera" APPLE = "apple" SAMSUNG = "samsung" BRAVE = "brave" GENERIC = "generic" VAPID_API_LOOKUP = { VapidPushMode.CHROME: "https://fcm.googleapis.com/fcm/send", VapidPushMode.FIREFOX: ( "https://updates.push.services.mozilla.com/wpush/v1" ), VapidPushMode.EDGE: ( "https://fcm.googleapis.com/fcm/send" ), # Edge uses FCM too VapidPushMode.OPERA: ( "https://fcm.googleapis.com/fcm/send" ), # Opera is Chromium-based VapidPushMode.APPLE: ( "https://web.push.apple.com" ), # Apple Web Push base endpoint VapidPushMode.BRAVE: "https://fcm.googleapis.com/fcm/send", VapidPushMode.SAMSUNG: "https://fcm.googleapis.com/fcm/send", VapidPushMode.GENERIC: "https://fcm.googleapis.com/fcm/send", } VAPID_PUSH_MODES = ( VapidPushMode.CHROME, VapidPushMode.FIREFOX, VapidPushMode.EDGE, VapidPushMode.OPERA, VapidPushMode.APPLE, ) class NotifyVapid(NotifyBase): """A wrapper for WebPush/Vapid notifications.""" # Set our global enabled flag enabled = subscription.CRYPTOGRAPHY_SUPPORT and _pem.PEM_SUPPORT requirements = { # Define our required packaging in order to work "packages_required": "cryptography" } # The default descriptive name associated with the Notification service_name = "Vapid Web Push Notifications" # The services URL service_url = ( "https://datatracker.ietf.org/doc/html/draft-thomson-webpush-vapid" ) # The default protocol secure_protocol = "vapid" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/vapid/" # There is no reason we should exceed 5KB when reading in a PEM file. # If it is more than this, then it is not accepted. max_vapid_keyfile_size = 5000 # There is no reason we should exceed 5MB when reading in a JSON file. # If it is more than this, then it is not accepted. max_vapid_subfile_size = 5242880 # The maximum length of the messge can be 4096 # just choosing a safe number below this to allow for padding and # encryption body_maxlen = 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 # 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 # 43200 = 12 hours vapid_jwt_expiration_sec = 43200 # Subscription file vapid_subscription_file = "subscriptions.json" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # Define object templates templates = ( "{schema}://{subscriber}", "{schema}://{subscriber}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "subscriber": { "name": _("API Key"), "type": "string", "private": True, "required": True, }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template args template_args = dict( NotifyBase.template_tokens, **{ "mode": { "name": _("Mode"), "type": "choice:string", "values": VAPID_PUSH_MODES, "default": VAPID_PUSH_MODES[0], "map_to": "mode", }, # Default Time To Live (defined in seconds) # 0 (Zero) - message will be delivered only if the device is # reacheable "ttl": { "name": _("ttl"), "type": "int", "default": 0, "min": 0, "max": 60, }, "to": { "alias_of": "targets", }, "from": { "alias_of": "subscriber", }, "keyfile": { # A Private Keyfile is required to sign header "name": _("PEM Private KeyFile"), "type": "string", "private": True, }, "subfile": { # A Subscripion File is required to sign header "name": _("Subscripion File"), "type": "string", "private": True, }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, }, ) def __init__( self, subscriber, mode=None, targets=None, keyfile=None, subfile=None, include_image=None, ttl=None, **kwargs, ): """Initialize Vapid Messaging.""" super().__init__(**kwargs) # Path to our Private Key file self.keyfile = None # Path to our subscription.json file self.subfile = None # # Our Targets # self.targets = [] self._invalid_targets = [] # default subscriptions self.subscriptions = {} self.subscriptions_loaded = False self.private_key_loaded = False # Set our Time to Live Flag self.ttl = self.template_args["ttl"]["default"] if ttl is not None: with contextlib.suppress(ValueError, TypeError): # Store our TTL (Time To live) if it is a valid integer self.ttl = int(ttl) if ( self.ttl < self.template_args["ttl"]["min"] or self.ttl > self.template_args["ttl"]["max"] ): msg = f"The Vapid TTL specified ({self.ttl}) is out of range." self.logger.warning(msg) raise TypeError(msg) # Place a thumbnail image inline with the message body self.include_image = ( self.template_args["image"]["default"] if include_image is None else include_image ) result = is_email(subscriber) if not result: msg = f"An invalid Vapid Subscriber({subscriber}) was specified." self.logger.warning(msg) raise TypeError(msg) self.subscriber = result["full_email"] # Store our Mode/service try: self.mode = ( NotifyVapid.template_args["mode"]["default"] if mode is None else mode.lower() ) if self.mode not in VAPID_PUSH_MODES: # allow the outer except to handle this common response raise IndexError() except (AttributeError, IndexError, TypeError): # Invalid region specified msg = f"The Vapid mode specified ({mode}) is invalid." self.logger.warning(msg) raise TypeError(msg) from None # Our Private keyfile self.keyfile = keyfile # Our Subscription file self.subfile = subfile # Prepare our PEM Object self.pem = _pem.ApprisePEMController(self.store.path, asset=self.asset) # Create our subscription object self.subscriptions = subscription.WebPushSubscriptionManager( asset=self.asset ) if ( self.subfile is None and self.store.mode != PersistentStoreMode.MEMORY and self.asset.pem_autogen ): self.subfile = os.path.join( self.store.path, self.vapid_subscription_file ) if not os.path.exists(self.subfile) and self.subscriptions.write( self.subfile ): self.logger.info( "Vapid auto-generated %s/%s", os.path.basename(self.store.path), self.vapid_subscription_file, ) # Acquire our targets for parsing self.targets = parse_list(targets) if not self.targets: # Add ourselves self.targets.append(self.subscriber) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Vapid Notification.""" if not self.private_key_loaded and ( ( self.keyfile and not self.pem.private_key(autogen=False, autodetect=False) and not self.pem.load_private_key(self.keyfile) ) or (not self.keyfile and not self.pem) ): self.logger.warning( "Provided Vapid/WebPush (PEM) Private Key file could " "not be loaded." ) self.private_key_loaded = True return False else: self.private_key_loaded = True if not self.targets: # There is no one to notify; we're done self.logger.warning("There are no Vapid targets to notify") return False if not self.subscriptions_loaded and self.subfile: # Toggle our loaded flag to prevent trying again later self.subscriptions_loaded = True if not self.subscriptions.load( self.subfile, byte_limit=self.max_vapid_subfile_size ): self.logger.warning( "Provided Vapid/WebPush subscriptions file could not be " "loaded." ) return False if not self.subscriptions: self.logger.warning("Vapid could not load subscriptions") return False if not self.pem.private_key(autogen=False, autodetect=False): self.logger.warning( "No Vapid/WebPush (PEM) Private Key file could be loaded." ) return False # Prepare our notify URL (based on our mode) notify_url = VAPID_API_LOOKUP[self.mode] headers = { "User-Agent": self.app_id, "TTL": str(self.ttl), "Content-Encoding": "aes128gcm", "Content-Type": "application/octet-stream", "Authorization": f"vapid t={self.jwt_token}, k={self.public_key}", } has_error = False # Create a copy of the targets list targets = list(self.targets) while len(targets): target = targets.pop(0) if target not in self.subscriptions: self.logger.warning( "Dropped Vapid user " f"({target}) specified - not found in subscriptions.json.", ) # Save ourselves from doing this again self._invalid_targets.append(target) self.targets.remove(target) has_error = True continue # Encrypt our payload encrypted_payload = self.pem.encrypt_webpush( body, public_key=self.subscriptions[target].public_key, auth_secret=self.subscriptions[target].auth_secret, ) self.logger.debug( "Vapid %s POST URL: %s (cert_verify=%r)", self.mode, notify_url, self.verify_certificate, ) self.logger.debug( "Vapid %s Encrypted Payload: %d byte(s)", self.mode, len(body) ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=encrypted_payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) 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 ) self.logger.warning( "Failed to send {} Vapid notification: " "{}{}error={}.".format( self.mode, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000] ) has_error = True else: self.logger.info("Sent %s Vapid notification.", self.mode) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Vapid notification." ) self.logger.debug("Socket Exception: %s", e) has_error = True 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.mode, self.subscriber) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "mode": self.mode, "ttl": str(self.ttl), } if self.keyfile: # Include our keyfile if specified params["keyfile"] = self.keyfile if self.subfile: # Include our subfile if specified params["subfile"] = self.subfile # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) targets = ( self.targets if not ( self.targets == 1 and self.targets[0].lower() == self.subscriber.lower() ) else [] ) return "{schema}://{subscriber}/{targets}?{params}".format( schema=self.secure_protocol, subscriber=NotifyVapid.quote(self.subscriber, safe="@"), targets="/".join( chain( [str(t) for t in targets], [ NotifyVapid.quote(x, safe="@") for x in self._invalid_targets ], ) ), params=NotifyVapid.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) return targets if 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 targets results["targets"] = [] if "from" in results["qsd"] and len(results["qsd"]["from"]): results["subscriber"] = NotifyVapid.unquote(results["qsd"]["from"]) if results["user"] and results["host"]: # whatever is left on the URL goes results["targets"].append( "{}@{}".format( NotifyVapid.unquote(results["user"]), NotifyVapid.unquote(results["host"]), ) ) elif results["host"]: results["targets"].append(NotifyVapid.unquote(results["host"])) else: # Acquire our subscriber information results["subscriber"] = "{}@{}".format( NotifyVapid.unquote(results["user"]), NotifyVapid.unquote(results["host"]), ) results["targets"].extend(NotifyVapid.split_path(results["fullpath"])) # Get our mode results["mode"] = results["qsd"].get("mode") # Get Image Flag results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyVapid.template_args["image"]["default"] ) ) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyVapid.parse_list(results["qsd"]["to"]) # Our Private Keyfile (PEM) if "keyfile" in results["qsd"] and results["qsd"]["keyfile"]: results["keyfile"] = NotifyVapid.unquote(results["qsd"]["keyfile"]) # Our Subscription File (JSON) if "subfile" in results["qsd"] and results["qsd"]["subfile"]: results["subfile"] = NotifyVapid.unquote(results["qsd"]["subfile"]) # Support the 'ttl' variable if "ttl" in results["qsd"] and len(results["qsd"]["ttl"]): results["ttl"] = NotifyVapid.unquote(results["qsd"]["ttl"]) return results @property def jwt_token(self): """Returns our VAPID Token based on class details.""" # JWT header header = {"alg": "ES256", "typ": "JWT"} # JWT payload payload = { "aud": VAPID_API_LOOKUP[self.mode], "exp": int(time.time()) + self.vapid_jwt_expiration_sec, "sub": f"mailto:{self.subscriber}", } # Base64 URL encode header and payload header_b64 = base64_urlencode( dumps(header, separators=(",", ":")).encode("utf-8") ) payload_b64 = base64_urlencode( dumps(payload, separators=(",", ":")).encode("utf-8") ) signing_input = f"{header_b64}.{payload_b64}".encode() signature_b64 = base64_urlencode(self.pem.sign(signing_input)) # Return final token return f"{header_b64}.{payload_b64}.{signature_b64}" @property def public_key(self): """Returns our public key representation.""" return self.pem.x962_str @staticmethod def runtime_deps(): """Return a tuple of top-level Python package names that this plugin imported as optional runtime dependencies. """ return ("cryptography",) apprise-1.10.0/apprise/plugins/vapid/subscription.py000066400000000000000000000315231517341665700226040ustar00rootroot00000000000000# 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 from typing import Optional, Union from ...apprise_attachment import AppriseAttachment from ...asset import AppriseAsset from ...exception import AppriseInvalidData from ...utils.base64 import base64_urldecode try: from cryptography.hazmat.primitives.asymmetric import ec # Cryptography Support enabled CRYPTOGRAPHY_SUPPORT = True except ImportError: # Cryptography Support disabled CRYPTOGRAPHY_SUPPORT = False class WebPushSubscription: """WebPush Subscription.""" # Format: # { # "endpoint": "https://fcm.googleapis.com/fcm/send/abc123...", # "keys": { # "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...", # "auth": "k9Xzm43nBGo=", # } # } def __init__(self, content: Union[str, dict, None] = None) -> None: """Prepares a webpush object provided with content Content can be a dictionary, or JSON String.""" # Our variables self.__endpoint = None self.__p256dh = None self.__auth = None self.__auth_secret = None self.__public_key = None if content is not None and not self.load(content): raise AppriseInvalidData("Could not load subscription") def load(self, content: Union[str, dict, None] = None) -> bool: """Performs the loading/validation of the object.""" # Reset our variables self.__endpoint = None self.__p256dh = None self.__auth = None self.__auth_secret = None self.__public_key = None if not CRYPTOGRAPHY_SUPPORT: return False if isinstance(content, str): try: content = json.loads(content) except (json.decoder.JSONDecodeError, TypeError, OSError): # Bad data return False if not isinstance(content, dict): # We could not load he result set return False # Retreive our contents for validation endpoint = content.get("endpoint") if not isinstance(endpoint, str): return False try: p256dh = base64_urldecode(content["keys"]["p256dh"]) if not p256dh: return False auth_secret = base64_urldecode(content["keys"]["auth"]) if not auth_secret: return False except KeyError: return False try: # Store our data self.__public_key = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), p256dh, ) except ValueError: # Invalid p256dh key (Can't load Public Key) return False self.__endpoint = endpoint self.__p256dh = content["keys"]["p256dh"] self.__auth = content["keys"]["auth"] self.__auth_secret = auth_secret return True def write(self, path: str, indent: int = 2) -> bool: """Writes content to disk based on path specified. Content is a JSON file, so ideally you may wish to have `.json' as it's extension for clarity """ if not self.__public_key: return False try: with open(path, "w", encoding="utf-8") as f: json.dump(self.dict, f, indent=indent) except (TypeError, OSError): # Could not write content return False return True @property def auth(self) -> Optional[str]: return self.__auth if self.__public_key else None @property def endpoint(self) -> Optional[str]: return self.__endpoint if self.__public_key else None @property def p256dh(self) -> Optional[str]: return self.__p256dh if self.__public_key else None @property def auth_secret(self) -> Optional[bytes]: return self.__auth_secret if self.__public_key else None @property def public_key(self) -> Optional["ec.EllipticCurvePublicKey"]: return self.__public_key @property def dict(self) -> dict: return ( { "endpoint": self.__endpoint, "keys": { "p256dh": self.__p256dh, "auth": self.__auth, }, } if self.__public_key else { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123...", "keys": { "p256dh": "", "auth": "", }, } ) def json(self, indent: int = 2) -> str: """Returns JSON representation of the object.""" return json.dumps(self.dict, indent=indent) def __bool__(self) -> bool: """Handle 'if' statement.""" return bool(self.__public_key) def __str__(self) -> str: """Returns our JSON entry as a string.""" # Return the first 16 characters of the detected endpoint subscription # id return ( "" if not self.__endpoint else self.__endpoint.split("/")[-1][:16] ) class WebPushSubscriptionManager: """WebPush Subscription Manager.""" # Format: # { # "name1": { # "endpoint": "https://fcm.googleapis.com/fcm/send/abc123...", # "keys": { # "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...", # "auth": "k9Xzm43nBGo=", # } # }, # "name2": { # "endpoint": "https://fcm.googleapis.com/fcm/send/abc123...", # "keys": { # "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...", # "auth": "k9Xzm43nBGo=", # } # }, # Defines the number of failures we can accept before we abort and assume # the file is bad max_load_failure_count = 3 def __init__(self, asset: Optional["AppriseAsset"] = None) -> None: """Webpush Subscription Manager.""" # Our subscriptions self.__subscriptions = {} # Prepare our Asset Object self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) def __getitem__(self, key: str) -> WebPushSubscription: """Returns our indexed value if it exists.""" return self.__subscriptions[key.lower()] def __setitem__( self, name: str, subscription: Union[WebPushSubscription, str, dict] ) -> None: """Set's our object if possible.""" if not self.add(subscription, name=name.lower()): raise AppriseInvalidData("Invalid subscription provided") def add( self, subscription: Union[WebPushSubscription, str, dict], name: Optional[str] = None, ) -> bool: """Add a subscription into our manager.""" if not isinstance(subscription, WebPushSubscription): try: # Support loading our object subscription = WebPushSubscription(subscription) except AppriseInvalidData: return False if name is None: name = str(subscription) self.__subscriptions[name.lower()] = subscription return True def __bool__(self) -> bool: """True is returned if at least one subscription has been loaded.""" return bool(self.__subscriptions) 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 len(self.__subscriptions) def __iadd__( self, subscription: Union[WebPushSubscription, str, dict] ) -> "WebPushSubscriptionManager": if not self.add(subscription): raise AppriseInvalidData("Invalid subscription provided") return self def __contains__(self, key: str) -> bool: """Checks if the key exists.""" return key.lower() in self.__subscriptions def clear(self) -> None: """Empties our server list.""" self.__subscriptions.clear() @property def dict(self) -> dict: """Returns a dictionary of all entries.""" return ( {k: v.dict for k, v in self.__subscriptions.items()} if self.__subscriptions else {} ) def load(self, path: str, byte_limit=0) -> bool: """Writes content to disk based on path specified. Content is a JSON file, so ideally you may wish to have `.json' as it's extension for clarity. if byte_limit is zero, then we do not limit our file size, otherwise set this to the bytes you want to restrict yourself by """ # Reset our object self.clear() # Create our attachment object attach = AppriseAttachment(asset=self.asset) # Add our path attach.add(path) if byte_limit > 0: # Enforce maximum file size attach[0].max_file_size = byte_limit if not attach.sync(): return False try: # Otherwise open our path with open(attach[0].path, encoding="utf-8") as f: content = json.load(f) except (json.decoder.JSONDecodeError, TypeError, OSError): # Could not read return False if not isinstance(content, dict): # Not a list of dictionaries return False # Verify if we're dealing with a single element: # { # "endpoint": "https://fcm.googleapis.com/fcm/send/abc123...", # "keys": { # "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...", # "auth": "k9Xzm43nBGo=", # } # } # # or if we're dealing with a multiple set # # { # "name1": { # "endpoint": "https://fcm.googleapis.com/fcm/send/abc123...", # "keys": { # "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...", # "auth": "k9Xzm43nBGo=", # } # }, # "name2": { # "endpoint": "https://fcm.googleapis.com/fcm/send/abc123...", # "keys": { # "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...", # "auth": "k9Xzm43nBGo=", # } # }, error_count = 0 if "endpoint" in content and "keys" in content: if not self.add(content): return False else: for name, subscription in content.items(): if not self.add(subscription, name=name.lower()): error_count += 1 if error_count > self.max_load_failure_count: self.clear() return False return True def write(self, path: str, indent: int = 2) -> bool: """Writes content to disk based on path specified. Content is a JSON file, so ideally you may wish to have `.json' as it's extension for clarity """ try: with open(path, "w", encoding="utf-8") as f: json.dump(self.dict, f, indent=indent) except (TypeError, OSError): # Could not write content return False return True def json(self, indent: int = 2) -> str: """Returns JSON representation of the object.""" return json.dumps(self.dict, indent=indent) apprise-1.10.0/apprise/plugins/viber.py000066400000000000000000000265551517341665700200750ustar00rootroot00000000000000# 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://creators.viber.com/docs/bots-api/\ # resources/messaging/send-message from __future__ import annotations from collections.abc import Iterable from json import dumps, loads from typing import Any, Optional import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_list, validate_regex from .base import NotifyBase class NotifyViber(NotifyBase): """Send a Viber Bot message using the Viber REST Bot API.""" # The default descriptive name associated with the Notification service_name = _("Viber") # The Services URL service_url = "https://www.viber.com/" # The default protocol secure_protocol = "viber" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/viber/" # Viber notification endpoint notify_url = "https://chatapi.viber.com/pa/send_message" # Service limits (documented maximum is 30KB) # Note: this is not exact byte accounting (UTF-8 vs chars), but it keeps # messages in the expected range. body_maxlen = 30000 # We don't support titles for Viber notifications title_maxlen = 0 # Maximum characters allowed in sender name viber_sender_name_limit = 28 # Minimal URL; endpoint is fixed, token is the first path entry. templates = ("{schema}://{token}/{targets}",) template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Authentication Token"), "type": "string", "private": True, "required": True, }, "targets": { "name": _("Receiver IDs"), "type": "list:string", "required": True, }, }, ) template_args = dict( NotifyBase.template_args, **{ # Viber requires sender.name "from": { "name": _("Bot Name"), "type": "string", "map_to": "source", }, # Optional sender.avatar URL "avatar": { "name": _("Bot Avatar URL"), "type": "string", }, "token": { "alias_of": "token", }, # Allow targets to also come from query string "to": {"alias_of": "targets"}, }, ) def __init__( self, token: str, targets: Optional[Iterable[str]] = None, source: Optional[str] = None, avatar: Optional[str] = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) self.token = validate_regex(token) if not self.token: msg = "An invalid Viber authentication token was specified." self.logger.warning(msg) raise TypeError(msg) # Sender name is required by the API; provide a safe default sourcev = (source or "").strip() if len(sourcev) > self.viber_sender_name_limit: self.logger.warning( f"Viber sender name exceeds {self.viber_sender_name_limit} " "characters, truncating." ) sourcev = sourcev[: self.viber_sender_name_limit] self.source: str = sourcev self.avatar: Optional[str] = (avatar or "").strip() or None # Store our targets self.targets = parse_list(targets) def __len__(self) -> int: """Number of outbound HTTP requests this configuration will perform.""" return max(1, len(self.targets)) def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Rebuild the Apprise URL with secrets redacted.""" # Define any URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) if self.source: params["from"] = self.source if self.avatar: params["avatar"] = self.avatar # Path targets tgt = "" if self.targets: tgt = "/".join(self.quote(t, safe="") for t in self.targets) # Token in first path element token = self.pprint( self.token, privacy, mode=PrivacyMode.Secret, safe="" ) query = self.urlencode(params) return ( f"{self.secure_protocol}://{token}/" + tgt + (f"?{query}" if query else "") ) @property def url_identifier(self) -> str: """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 send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Send a Viber notification to each configured receiver ID.""" if not self.targets: # There were no services to notify self.logger.warning("There were no Viber targets to notify") return False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "X-Viber-Auth-Token": self.token, } # Prepare our payload payload: dict[str, Any] = { "type": "text", "text": body, "sender": { "name": self.source if self.source else self.app_desc[: self.viber_sender_name_limit] }, } if self.avatar: payload["sender"]["avatar"] = self.avatar content = None status_str = None has_error = False for dest in self.targets: payload["receiver"] = dest self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Viber returns the following on success: # {"status":0,"status_message":"ok",...} try: content = loads(r.content) except (AttributeError, TypeError, ValueError, KeyError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None # KeyError = 'result' is not found in result content = {} self.logger.warning( "Invalid JSON response from Viber sending " f"notification to {dest}" ) self.logger.debug("Response Details:\n%s", r.content) has_error = True continue if r.status_code != requests.codes.ok: # We had a problem status_str = ( content.get("status_message") if content.get("status_message") else self.http_response_code_lookup(r.status_code) ) self.logger.warning( f"Failed to send Viber notification to {dest} - " f"{status_str} error={r.status_code}." ) self.logger.debug("Response Details:\n%s", r.content) # Mark our failure has_error = True continue if int(content.get("status", -1)) != 0: self.logger.warning( f"Failed to send Viber notification to {dest} - " "Viber Error {%s} (status=%s)", content.get("status_message", "unknown"), content.get("status", "unknown"), ) self.logger.debug("Response Details:\n%s", r.content) # Mark our failure has_error = True continue except requests.RequestException as e: self.logger.warning( "A Connection error occured sending Viber notification " "to %s", dest, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @staticmethod def parse_url(url: str) -> dict[str, Any]: """Parse the URL and return arguments to 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 a Full path to work with results["targets"] = [ NotifyViber.unquote(results["host"]), *NotifyViber.split_path(results["fullpath"]), ] if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyViber.unquote(results["qsd"]["token"]) else: results["token"] = results["targets"][0] results["targets"] = results["targets"][1:] # 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"] += results["qsd"]["to"] # Map 'from' -> source if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyViber.unquote(results["qsd"]["from"]) # Map avatar if "avatar" in results["qsd"] and len(results["qsd"]["avatar"]): results["avatar"] = NotifyViber.unquote(results["qsd"]["avatar"]) return results apprise-1.10.0/apprise/plugins/voipms.py000066400000000000000000000322051517341665700202700ustar00rootroot00000000000000# 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. # Create an account https://voip.ms/ if you don't already have one # # Enable API and set an API password here: # - https://voip.ms/m/api.php # # Read more about VoIP.ms API here: # - https://voip.ms/m/apidocs.php import contextlib from json import loads import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_email, is_phone_no, parse_phone_no from .base import NotifyBase class NotifyVoipms(NotifyBase): """A wrapper for VoIPms Notifications.""" # The default descriptive name associated with the Notification service_name = "VoIPms" # The services URL service_url = "https://voip.ms" # The default protocol secure_protocol = "voipms" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/voipms/" # VoIPms uses the http protocol with JSON requests notify_url = "https://voip.ms/api/v1/rest.php" # The maximum length of the body body_maxlen = 160 # The supported country code by VoIP.ms voip_ms_country_code = "1" # 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}://{password}:{email}/{from_phone}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "email": { "name": _("User Email"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "from_phone": { "name": _("From Phone No"), "type": "string", "required": True, "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "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, **{ "to": { "alias_of": "targets", }, "from": { "alias_of": "from_phone", }, }, ) def __init__(self, email, source=None, targets=None, **kwargs): """Initialize VoIPms Object.""" super().__init__(**kwargs) # Validate our params here. if self.password is None: msg = "Password has to be specified." self.logger.warning(msg) raise TypeError(msg) # User is the email associated with the account result = is_email(email) if not result: msg = f"An invalid VoIPms user email: ({email}) was specified." self.logger.warning(msg) raise TypeError(msg) self.email = result["full_email"] # Validate our source Phone # result = is_phone_no(source) if not result: msg = f"An invalid VoIPms source phone # ({source}) was specified." self.logger.warning(msg) raise TypeError(msg) # Source Phone # only supports +1 country code # Allow 7 digit phones (presume they're local with +1 country code) if ( result["country"] and result["country"] != self.voip_ms_country_code ): msg = ( "VoIPms only supports +1 country code " f"({source}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Store our source phone number (without country code) self.source = result["area"] + result["line"] # Parse our targets self.targets = [] if targets: for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) # Target Phone # only supports +1 country code if ( result["country"] and result["country"] != self.voip_ms_country_code ): self.logger.warning( f"Ignoring invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["area"] + result["line"]) else: # Send a message to ourselves self.targets.append(self.source) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform VoIPms Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no VoIPms 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/x-www-form-urlencoded", } # Prepare our payload payload = { "api_username": self.email, "api_password": self.password, "did": self.source, "message": body, "method": "sendSMS", # Gets filled in the loop below "dst": None, } # Create a copy of the targets list targets = list(self.targets) while len(targets): # Get our target to notify target = targets.pop(0) # Add target Phone # payload["dst"] = target # Some Debug Logging self.logger.debug( "VoIPms GET URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"VoIPms Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() response = {"status": "unknown", "message": ""} try: r = requests.get( self.notify_url, params=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) with contextlib.suppress( AttributeError, TypeError, ValueError ): # Load our JSON object if valid # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None response = loads(r.content) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyVoipms.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send VoIPms SMS 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 # VoIPms sends 200 OK even if there is an error # check if status in response and if it is not success if response is not None and response["status"] != "success": self.logger.warning( "Failed to send VoIPms SMS notification to {}: " "status: {}, message: {}".format( target, response["status"], response["message"] ) ) # Mark our failure has_error = True continue else: self.logger.info( f"Sent VoIPms SMS notification to {target}" ) except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending VoIPms:{target} " "SMS notification." ) 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.email, self.password, self.source, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) schemaStr = ( "{schema}://{password}:{email}/{from_phone}/{targets}/?{params}" ) return schemaStr.format( schema=self.secure_protocol, email=self.email, password=self.pprint(self.password, privacy, safe=""), from_phone=self.voip_ms_country_code + self.pprint(self.source, privacy, safe=""), targets="/".join( [ self.voip_ms_country_code + NotifyVoipms.quote(x, safe="") for x in self.targets ] ), params=NotifyVoipms.urlencode(params), ) 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 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 results["targets"] = NotifyVoipms.split_path(results["fullpath"]) if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyVoipms.unquote(results["qsd"]["from"]) elif results["targets"]: # The from phone no is the first entry in the list otherwise results["source"] = results["targets"].pop(0) # Swap user for pass since our input is: password:email # where email is user@hostname (or user@domain) user = results["password"] password = results["user"] results["password"] = password results["user"] = user results["email"] = "{}@{}".format( NotifyVoipms.unquote(user), NotifyVoipms.unquote(results["host"]), ) if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyVoipms.parse_phone_no( results["qsd"]["to"] ) return results apprise-1.10.0/apprise/plugins/vonage.py000066400000000000000000000327151517341665700202400ustar00rootroot00000000000000# 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://dashboard.nexmo.com/ # # Get your (api) key and secret here: # - https://dashboard.nexmo.com/getting-started-guide # import contextlib import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_phone_no, validate_regex from .base import NotifyBase class NotifyVonage(NotifyBase): """A wrapper for Vonage Notifications.""" # The default descriptive name associated with the Notification service_name = "Vonage" # The services URL service_url = "https://dashboard.nexmo.com/" # The default protocol (nexmo kept for backwards compatibility) secure_protocol = ("vonage", "nexmo") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/vonage/" # Vonage uses the http protocol with JSON requests notify_url = "https://rest.nexmo.com/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}@{from_phone}", "{schema}://{apikey}:{secret}@{from_phone}/{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"), }, "from_phone": { "name": _("From Phone No"), "type": "string", "required": True, "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "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", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "from_phone", }, "key": { "alias_of": "apikey", }, "secret": { "alias_of": "secret", }, # Default Time To Live # By default Vonage attempt delivery for 72 hours, however the # maximum effective value depends on the operator and is typically # 24 - 48 hours. We recommend this value should be kept at its # default or at least 30 minutes. "ttl": { "name": _("ttl"), "type": "int", "default": 900000, "min": 20000, "max": 604800000, }, "to": { "alias_of": "targets", }, }, ) def __init__( self, apikey, secret, source, targets=None, ttl=None, **kwargs ): """Initialize Vonage 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 Vonage 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 Vonage API Secret ({secret}) was specified." self.logger.warning(msg) raise TypeError(msg) # Set our Time to Live Flag self.ttl = self.template_args["ttl"]["default"] with contextlib.suppress(ValueError, TypeError): # update our ttl if we're dealing with an integer self.ttl = int(ttl) if ( self.ttl < self.template_args["ttl"]["min"] or self.ttl > self.template_args["ttl"]["max"] ): msg = f"The Vonage TTL specified ({self.ttl}) is out of range." self.logger.warning(msg) raise TypeError(msg) # The Source Phone # self.source = source 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) # Store our parsed value self.source = result["full"] # 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 Vonage Notification.""" # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", } # Prepare our payload payload = { "api_key": self.apikey, "api_secret": self.secret, "ttl": self.ttl, "from": self.source, "text": body, # The to gets populated in the loop below "to": None, } # Create a copy of the targets list targets = list(self.targets) if len(targets) == 0: # No sources specified, use our own phone no targets.append(self.source) while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["to"] = target # Some Debug Logging self.logger.debug( "Vonage POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Vonage 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, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyVonage.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Vonage 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 Vonage notification to {target}.") except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending Vonage:{target} " "notification." ) 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[0], self.apikey, self.secret) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "ttl": str(self.ttl), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{key}:{secret}@{source}/{targets}/?{params}".format( schema=self.secure_protocol[0], key=self.pprint(self.apikey, privacy, safe=""), secret=self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ), source=NotifyVonage.quote(self.source, safe=""), targets="/".join( [NotifyVonage.quote(x, safe="") for x in self.targets] ), params=NotifyVonage.urlencode(params), ) 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 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"] = NotifyVonage.split_path(results["fullpath"]) # The hostname is our source number results["source"] = NotifyVonage.unquote(results["host"]) # Get our account_side and auth_token from the user/pass config results["apikey"] = NotifyVonage.unquote(results["user"]) results["secret"] = NotifyVonage.unquote(results["password"]) # API Key if "key" in results["qsd"] and len(results["qsd"]["key"]): # Extract the API Key from an argument results["apikey"] = NotifyVonage.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"] = NotifyVonage.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"] = NotifyVonage.unquote(results["qsd"]["from"]) if "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyVonage.unquote(results["qsd"]["source"]) # Support the 'ttl' variable if "ttl" in results["qsd"] and len(results["qsd"]["ttl"]): results["ttl"] = NotifyVonage.unquote(results["qsd"]["ttl"]) # 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"] += NotifyVonage.parse_phone_no( results["qsd"]["to"] ) return results apprise-1.10.0/apprise/plugins/webexteams.py000066400000000000000000000550431517341665700211240ustar00rootroot00000000000000# 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. # At the time I created this plugin, their website had lots of issues with the # Firefox Browser. I fell back to Chrome and had no problems. # There are 2 ways to use this plugin... # # Method 1: Via Webhook (default mode): # Visit https://teams.webex.com and make yourself an account if you don't # already have one. You'll want to create at least one 'space' before # getting the 'incoming webhook'. # # Next you'll need to install the 'Incoming webhook' plugin found under # the 'other' category here: https://apphub.webex.com/integrations/ # # These links may not always work as time goes by and websites always # change, but at the time of creating this plugin this was a direct link # to it: # https://apphub.webex.com/integrations/incoming-webhooks-cisco-systems # # If you're logged in, you'll be able to click on the 'Connect' button. # From there you'll need to accept the permissions it will ask of you. # Give the webhook a name such as 'apprise'. # When you're complete, you will recieve a URL that looks something like: # https://api.ciscospark.com/v1/webhooks/incoming/\ # Y3lzY29zcGkyazovL3VzL1dFQkhPT0sajkkzYWU4fTMtMGE4Yy00 # # The last part of the URL is all you need to be interested in. Think of # this url as: # https://api.ciscospark.com/v1/webhooks/incoming/{token} # # You will need to assemble all of your URLs for this plugin to work as: # wxteams://{token} # # # Method 2: Via Bot/API (supports file attachments): # Visit https://developer.webex.com/my-apps and create a new Bot. # After creating the bot, you'll receive a Bot Access Token. # # You will also need to know the Room ID(s) you want to post to. # Room IDs can be retrieved from the Webex API: # https://developer.webex.com/docs/api/v1/rooms/list-rooms # # Assemble your Apprise URL as: # wxteams://{access_token}/{room_id} # wxteams://{access_token}/{room_id1}/{room_id2} # # The plugin auto-detects the mode: # - If the token is 80-160 lowercase alphanumeric chars -> Webhook mode # - Otherwise -> Bot mode (requires at least one room ID) # You may also force the mode with ?mode=webhook or ?mode=bot. # # Resources # - https://developer.webex.com/docs/api/basics - markdown/post syntax # - https://developer.webex.com/docs/api/v1/messages/create-a-message # - https://developer.cisco.com/ecosystem/webex/apps/\ # incoming-webhooks-cisco-systems/ - Simple webhook example from json import dumps import re import requests from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_list, validate_regex from .base import NotifyBase # Extend HTTP Error Messages # Based on: https://developer.webex.com/docs/api/basics/rate-limiting WEBEX_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", 415: "Unsuported media specified", 429: "To many consecutive requests were made.", 503: "Service is overloaded, try again later", } class WebexTeamsMode: """Tracks the mode of which we're using Webex Teams.""" # We're dealing with an incoming webhook # Token is 80-160 lowercase alphanumeric chars WEBHOOK = "webhook" # We're dealing with a Bot/API access token (supports attachments) # Token is a Bearer access token from the Webex developer portal BOT = "bot" # Define our Webex Teams Modes WEBEX_TEAMS_MODES = ( WebexTeamsMode.WEBHOOK, WebexTeamsMode.BOT, ) class NotifyWebexTeams(NotifyBase): """A wrapper for Webex Teams Notifications.""" # The default descriptive name associated with the Notification service_name = "Cisco Webex Teams" # The services URL service_url = "https://webex.teams.com/" # The default secure protocol secure_protocol = ("wxteams", "webex") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/wxteams/" # Webex Teams Webhook URL notify_url = "https://api.ciscospark.com/v1/webhooks/incoming/" # Webex Teams Bot API URL (used in Bot mode) api_url = "https://webexapis.com/v1/messages" # Bot mode supports attachments attachment_support = True # Do not set body_maxlen as it is set in a property value below # since the length varies depending on whether we are using a # webhook (1000 chars) or the bot API (7439 chars) # body_maxlen = see below @property defined # We don't support titles for Webex notifications title_maxlen = 0 # Default to markdown; fall back to text notify_format = NotifyFormat.MARKDOWN # Define object URL templates templates = ( # Webhook mode (existing) "{schema}://{token}", # Bot mode (access_token in host, one or more room IDs in path) "{schema}://{access_token}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Webhook Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]{80,160}$", "i"), }, "access_token": { "name": _("Bot Access Token"), "type": "string", "private": True, "required": True, }, "targets": { "name": _("Room IDs"), "type": "list:string", }, }, ) template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, "mode": { "name": _("Mode"), "type": "choice:string", "values": WEBEX_TEAMS_MODES, # mode is auto-detected if not specified }, "to": { "alias_of": "targets", }, }, ) def __init__( self, token=None, access_token=None, targets=None, mode=None, **kwargs, ): """Initialize Webex Teams Object.""" super().__init__(**kwargs) # Resolve mode: explicit override wins, otherwise auto-detect if mode and isinstance(mode, str): self.mode = next( (m for m in WEBEX_TEAMS_MODES if m.startswith(mode)), None ) if self.mode not in WEBEX_TEAMS_MODES: msg = f"The Webex Teams mode specified ({mode}) is invalid." self.logger.warning(msg) raise TypeError(msg) else: # Auto-detect: webhook tokens are 80-160 lowercase alphanumeric _candidate = access_token or token if token and validate_regex( token, *self.template_tokens["token"]["regex"] ): self.mode = WebexTeamsMode.WEBHOOK elif _candidate: # Non-webhook token length/format -> assume BOT self.mode = WebexTeamsMode.BOT else: # No usable token at all; will fail below self.mode = WebexTeamsMode.WEBHOOK if self.mode == WebexTeamsMode.WEBHOOK: self.access_token = None self.targets = [] # Webhook token: prefer 'token', fall back to 'access_token' _tok = token or access_token self.token = validate_regex( _tok, *self.template_tokens["token"]["regex"] ) if not self.token: msg = ( "The Webex Teams webhook token" f" specified ({_tok}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) else: # WebexTeamsMode.BOT self.token = None # Bot access token: prefer 'access_token', fall back to 'token' _at = access_token or token if not _at: msg = "A Webex Teams bot access token must be specified." self.logger.warning(msg) raise TypeError(msg) self.access_token = _at self.targets = parse_list(targets) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Webex Teams Notification.""" if self.mode == WebexTeamsMode.WEBHOOK: if attach and self.attachment_support: self.logger.warning( "Webex Teams Webhooks do not support" " attachments; use bot mode." ) return self._send_webhook(body) return self._send_bot(body, attach=attach) def _send_webhook(self, body): """Post via incoming webhook (no attachment support).""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } url = f"{self.notify_url}/{self.token}" payload = { ( "markdown" if (self.notify_format == NotifyFormat.MARKDOWN) else "text" ): body, } self.logger.debug( "Webex Teams Webhook POST URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Webex Teams Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): status_str = NotifyWebexTeams.http_response_code_lookup( r.status_code, WEBEX_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Webex Teams 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 self.logger.info("Sent Webex Teams notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Webex Teams notification." ) self.logger.debug(f"Socket Exception: {e!s}") return False return True def _send_bot(self, body, attach=None): """Post via Bot/API to one or more rooms (supports attachments).""" if not self.targets: self.logger.warning( "Webex Teams Bot mode has no room IDs to notify, aborting." ) return False has_error = False for room_id in self.targets: if not self._post_to_room(body, room_id, attach=attach): has_error = True return not has_error def _post_to_room(self, body, room_id, attach=None): """Send a single message (and optional attachments) to a room.""" headers = { "User-Agent": self.app_id, "Authorization": f"Bearer {self.access_token}", } text_key = ( "markdown" if self.notify_format == NotifyFormat.MARKDOWN else "text" ) has_attachment = attach and self.attachment_support and len(attach) > 0 if not has_attachment: # --- Text-only message sent as JSON --- headers["Content-Type"] = "application/json" payload = { "roomId": room_id, text_key: body, } self.logger.debug( "Webex Teams Bot POST URL:" f" {self.api_url}" f" (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Webex Teams Bot Payload: {payload!s}") self.throttle() try: r = requests.post( self.api_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: status_str = NotifyWebexTeams.http_response_code_lookup( r.status_code, WEBEX_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Webex Teams Bot" " 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 self.logger.info( f"Sent Webex Teams Bot notification to room {room_id}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending" " Webex Teams Bot notification." ) self.logger.debug(f"Socket Exception: {e!s}") return False return True # --- Multipart attachment(s) --- for no, attachment in enumerate(attach, start=1): if not attachment: self.logger.error( "Could not access Webex Teams attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Posting Webex Teams attachment" f" {attachment.url(privacy=True)}" ) try: # open() returns a BytesIO for memory attachments; the # context manager guarantees the handle is closed on exit with attachment.open() as fp: files = { "files": ( ( attachment.name if attachment.name else f"file{no:03}.dat" ), fp, attachment.mimetype, ), } data = {"roomId": room_id} # Include message body only with the first attachment if no == 1: data[text_key] = body self.logger.debug( "Webex Teams Bot attachment POST URL:" f" {self.api_url}" f" (cert_verify={self.verify_certificate!r})" ) self.throttle() r = requests.post( self.api_url, data=data, headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: status_str = NotifyWebexTeams.http_response_code_lookup( r.status_code, WEBEX_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Webex Teams attachment" " {}: {}{}error={}.".format( attachment.name, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000], ) return False self.logger.info( "Sent Webex Teams attachment" f" {attachment.name} to room {room_id}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred posting" " Webex Teams attachment." ) self.logger.debug(f"Socket Exception: {e!s}") return False except OSError as e: self.logger.warning( "An I/O error occurred while reading {}.".format( attachment.name if attachment.name else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False return True @property def body_maxlen(self): """The maximum allowable characters allowed in the body per message. Webhook mode is limited to 1000 chars; the Bot API allows 7439.""" return 1000 if self.mode == WebexTeamsMode.WEBHOOK else 7439 @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. """ if self.mode == WebexTeamsMode.WEBHOOK: return (self.secure_protocol[0], self.token) # BOT mode return (self.secure_protocol[0], self.access_token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = {"mode": self.mode} params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if self.mode == WebexTeamsMode.WEBHOOK: return "{schema}://{token}/?{params}".format( schema=self.secure_protocol[0], token=self.pprint(self.token, privacy, safe=""), params=NotifyWebexTeams.urlencode(params), ) # BOT mode return "{schema}://{token}/{targets}/?{params}".format( schema=self.secure_protocol[0], token=self.pprint(self.access_token, privacy, safe=""), targets="/".join( [NotifyWebexTeams.quote(r, safe="") for r in self.targets] ), params=NotifyWebexTeams.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: return results # Explicit mode parameter wins if "mode" in results["qsd"] and results["qsd"]["mode"]: results["mode"] = NotifyWebexTeams.unquote(results["qsd"]["mode"]) # Pull additional room-ID path entries (bot mode) entries = NotifyWebexTeams.split_path(results["fullpath"]) # Support ?to= for room IDs if "to" in results["qsd"] and results["qsd"]["to"]: entries += NotifyWebexTeams.split_path( NotifyWebexTeams.unquote(results["qsd"]["to"]) ) # Support ?token= for the primary token/access_token if "token" in results["qsd"] and results["qsd"]["token"]: host = NotifyWebexTeams.unquote(results["qsd"]["token"]) else: host = NotifyWebexTeams.unquote(results["host"]) # Determine whether this is webhook or bot mode. # Path entries mean bot mode (room IDs present). # Explicit mode= has already been stored above. explicit_mode = results.get("mode", "") if explicit_mode == WebexTeamsMode.BOT or ( entries and explicit_mode != WebexTeamsMode.WEBHOOK ): results["access_token"] = host results["targets"] = entries else: results["token"] = host return results @staticmethod def parse_native_url(url): """ Support: https://api.ciscospark.com/v1/webhooks/incoming/WEBHOOK_TOKEN https://webexapis.com/v1/webhooks/incoming/WEBHOOK_TOKEN """ result = re.match( r"^https?://(api\.ciscospark\.com|webexapis\.com)" r"/v[1-9][0-9]*/webhooks/incoming/" r"(?P[A-Z0-9_-]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: return NotifyWebexTeams.parse_url( "{schema}://{webhook_token}/{params}".format( schema=NotifyWebexTeams.secure_protocol[0], webhook_token=result.group("webhook_token"), params=( "" if not result.group("params") else result.group("params") ), ) ) return None apprise-1.10.0/apprise/plugins/wecombot.py000066400000000000000000000217301517341665700205730ustar00rootroot00000000000000# 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. # WeCom for PC # 1. On WeCom for PC, find the target WeCom group for receiving alarm # notifications. # 2. Right-click the WeCom group. In the window that appears, click # "Add Group Bot". # 3. In the window that appears, click Create a Bot. # 4. In the window that appears, enter a custom bot name and click Add. # 5. You will be provided a Webhook URL that looks like: # https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abcd # # WeCom for Web # 1. On WebCom for Web, open the target WeCom group for receiving alarm # notifications. # 2. Click the group settings icon in the upper-right corner. # 3. On the group settings page, choose Group Bots > Add a Bot. # 4. On the management page for adding bots, enter a custom name for the new # bot. # 5. Click Add, copy the webhook address, and configure the API callback by # following Step 2. # the URL will look something like this: # https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abcd # ^ # | # webhook key # # This plugin also supports taking the URL (as identified above) directly # as well. from json import dumps import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase class NotifyWeComBot(NotifyBase): """A wrapper for WeCom Bot Notifications.""" # The default descriptive name associated with the Notification service_name = "WeCom Bot" # The services URL service_url = "https://weixin.qq.com/" # The default secure protocol secure_protocol = "wecombot" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/wecombot/" # Plain Text Notification URL notify_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}" # Define object templates templates = ("{schema}://{key}",) # The title is not used title_maxlen = 0 # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ # The Bot Key can be found at the end of the webhook provided # (?key=) "key": { "name": _("Bot Webhook Key"), "type": "string", "required": True, "private": True, "regex": (r"^[a-z0-9_-]+$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ # You can optionally pass IRC colors into "key": { "alias_of": "key", }, }, ) def __init__(self, key, **kwargs): """Initialize WeCom Bot Object.""" super().__init__(**kwargs) # Assign our bot webhook self.key = validate_regex(key, *self.template_tokens["key"]["regex"]) if not self.key: msg = f"An invalid WeCom Bot Webhook Key ({key}) was specified." self.logger.warning(msg) raise TypeError(msg) # Prepare our notification URL now: self.api_url = self.notify_url.format( key=self.key, ) 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.key) 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}://{key}/?{params}".format( schema=self.secure_protocol, key=self.pprint(self.key, privacy, safe=""), params=NotifyWeComBot.urlencode(params), ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Wrapper to _send since we can alert more then one channel.""" # prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # Prepare our payload payload = { "msgtype": "text", "text": { "content": body, }, } self.logger.debug( "WeCom Bot GET URL:" f" {self.api_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"WeCom Bot Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.api_url, data=dumps(payload).encode("utf-8"), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyWeComBot.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send WeCom Bot 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 WeCom Bot notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending WeCom Bot notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @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 first token is stored in the hostname results["key"] = NotifyWeComBot.unquote(results["host"]) # The 'key' makes it easier to use yaml configuration if "key" in results["qsd"] and len(results["qsd"]["key"]): results["key"] = NotifyWeComBot.unquote(results["qsd"]["key"]) return results @staticmethod def parse_native_url(url): """ Support https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=BOTKEY """ result = re.match( r"^https?://qyapi\.weixin\.qq\.com/cgi-bin/webhook/send/?\?key=" r"(?P[A-Z0-9_-]+)/?" r"&?(?P.+)?$", url, re.I, ) if result: return NotifyWeComBot.parse_url( "{schema}://{key}{params}".format( schema=NotifyWeComBot.secure_protocol, key=result.group("key"), params=( "" if not result.group("params") else "?" + result.group("params") ), ) ) return None apprise-1.10.0/apprise/plugins/whatsapp.py000066400000000000000000000507761517341665700206170ustar00rootroot00000000000000# 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 Source: # https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages # # 1. Register a developer account with Meta: # https://developers.facebook.com/docs/whatsapp/cloud-api/get-started # 2. Enable 2 Factor Authentication (2FA) with your account (if not done # already) # 3. Create a App using WhatsApp Product. There are 2 to create an app from # Do NOT chose the WhatsApp Webhook one (choose the other) # # When you click on the API Setup section of your new app you need to record # both the access token and the From Phone Number ID. Note that this not the # from phone number itself, but it's ID. It's displayed below and contains # way more numbers then your typical phone number from json import dumps, loads import re 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 NotifyWhatsApp(NotifyBase): """A wrapper for WhatsApp Notifications.""" # The default descriptive name associated with the Notification service_name = "WhatsApp" # The services URL service_url = ( "https://developers.facebook.com/docs/whatsapp/cloud-api/get-started" ) # All notification requests are secure secure_protocol = "whatsapp" # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.20 # Facebook Graph version fb_graph_version = "v17.0" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/whatsapp/" # WhatsApp Message Notification URL notify_url = "https://graph.facebook.com/{fb_ver}/{phone_id}/messages" # The maximum length of the body body_maxlen = 1024 # 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}@{from_phone_id}/{targets}", "{schema}://{template}:{token}@{from_phone_id}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Access Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, "template": { "name": _("Template Name"), "type": "string", "required": False, "regex": (r"^[^\s]+$", "i"), }, "from_phone_id": { "name": _("From Phone ID"), "type": "string", "private": True, "required": True, "regex": (r"^[0-9]+$", "i"), }, "language": { "name": _("Language"), "type": "string", "default": "en_US", "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, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "from_phone_id", }, "token": { "alias_of": "token", }, "template": { "alias_of": "template", }, "lang": { "alias_of": "language", }, "to": { "alias_of": "targets", }, }, ) # Our supported mappings and component keys component_key_re = re.compile( r"(?P((?P[1-9][0-9]*)|(?Pbody|type)))", re.IGNORECASE ) # Define any kwargs we're using template_kwargs = { "template_mapping": { "name": _("Template Mapping"), "prefix": ":", }, } def __init__( self, token, from_phone_id, template=None, targets=None, language=None, template_mapping=None, **kwargs, ): """Initialize WhatsApp Object.""" super().__init__(**kwargs) # The Access Token associated with the account self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"An invalid WhatsApp Access Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # The From Phone ID associated with the account self.from_phone_id = validate_regex( from_phone_id, *self.template_tokens["from_phone_id"]["regex"] ) if not self.from_phone_id: msg = ( "An invalid WhatsApp From Phone ID " f"({from_phone_id}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # The template to associate with the message if template: self.template = validate_regex( template, *self.template_tokens["template"]["regex"] ) if not self.template: msg = ( "An invalid WhatsApp Template Name " f"({template}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # The Template language Code to use if language: self.language = validate_regex( language, *self.template_tokens["language"]["regex"] ) if not self.language: msg = ( "An invalid WhatsApp Template Language Code " f"({language}) was specified." ) self.logger.warning(msg) raise TypeError(msg) else: self.language = self.template_tokens["language"]["default"] else: # # Message Mode # self.template = None # 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("+{}".format(result["full"])) self.template_mapping = {} if template_mapping: # Store our extra payload entries self.template_mapping.update(template_mapping) # Validate Mapping and prepare Components self.components = {} self.component_keys = [] for key, val in self.template_mapping.items(): matched = self.component_key_re.match(key) if not matched: msg = ( f"An invalid Template Component ID ({key}) was specified." ) self.logger.warning(msg) raise TypeError(msg) if matched.group("id"): # # Manual Component Assigment (by id) # index = matched.group("id") map_to = { "type": "text", "text": val, } else: # matched.group('map') map_to = matched.group("map").lower() matched = self.component_key_re.match(val) if not (matched and matched.group("id")): msg = ( "An invalid Template Component Mapping " f"(:{key}={val}) was specified." ) self.logger.warning(msg) raise TypeError(msg) index = matched.group("id") if index in self.components: msg = ( "The Template Component index " f"({key}) was already assigned." ) self.logger.warning(msg) raise TypeError(msg) self.components[index] = map_to self.component_keys = self.components.keys() # Adjust sorting and assume that the user put the order correctly; # if not Facebook just won't be very happy and will reject the # message sorted(self.component_keys) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform WhatsApp Notification.""" if not self.targets: self.logger.warning( "There are no valid WhatsApp targets to notify." ) return False # error tracking (used for function return) has_error = False # Prepare our URL url = self.notify_url.format( fb_ver=self.fb_graph_version, phone_id=self.from_phone_id, ) # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {self.token}", } payload = { "messaging_product": "whatsapp", # The To gets populated in the loop below "to": None, } if not self.template: # # Send Message # payload.update( { "recipient_type": "individual", "type": "text", "text": {"body": body}, } ) else: # # Send Template # payload.update( { "type": "template", "template": { "name": self.template, "language": {"code": self.language}, }, } ) if self.components: payload["template"]["components"] = [ { "type": "body", "parameters": [], } ] for key in self.component_keys: if isinstance(self.components[key], dict): # Manual Assignment payload["template"]["components"][0][ "parameters" ].append(self.components[key]) continue # Mapping of body and/or notify type payload["template"]["components"][0]["parameters"].append( { "type": "text", "text": ( body if self.components[key] == "body" else notify_type.value ), } ) # Create a copy of the targets list targets = list(self.targets) while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["to"] = target # Some Debug Logging self.logger.debug( "WhatsApp POST URL:" f" {url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"WhatsApp Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) 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 try: # Update our status response if we can json_response = loads(r.content) status_code = json_response["error"].get( "code", status_code ) status_str = json_response["error"].get( "message", status_str ) except (AttributeError, TypeError, ValueError, KeyError): # KeyError = r.content is parseable but does not # contain 'error' # 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. pass self.logger.warning( "Failed to send WhatsApp notification to {}: " "{}{}error={}.".format( 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 WhatsApp notification to {target}." ) except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending WhatsApp:{target} " + "notification." ) 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.from_phone_id, self.token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self.template: # Add language to our URL params["lang"] = self.language # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Payload body extras prefixed with a ':' sign # Append our payload extras into our parameters params.update({f":{k}": v for k, v in self.template_mapping.items()}) return "{schema}://{template}{token}@{from_id}/{targets}/?{params}".format( schema=self.secure_protocol, from_id=self.pprint(self.from_phone_id, privacy, safe=""), token=self.pprint(self.token, privacy, safe=""), template=( "" if not self.template else "{}:".format(NotifyWhatsApp.quote(self.template, safe="")) ), targets="/".join( [NotifyWhatsApp.quote(x, safe="") for x in self.targets] ), params=NotifyWhatsApp.urlencode(params), ) 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 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"] = NotifyWhatsApp.split_path(results["fullpath"]) # The hostname is our From Phone ID results["from_phone_id"] = NotifyWhatsApp.unquote(results["host"]) # Determine if we have a Template, otherwise load our token if results["password"]: # # Template Mode # results["template"] = NotifyWhatsApp.unquote(results["user"]) results["token"] = NotifyWhatsApp.unquote(results["password"]) else: # # Message Mode # results["token"] = NotifyWhatsApp.unquote(results["user"]) # Access token if "token" in results["qsd"] and len(results["qsd"]["token"]): # Extract the account sid from an argument results["token"] = NotifyWhatsApp.unquote(results["qsd"]["token"]) # Template if "template" in results["qsd"] and len(results["qsd"]["template"]): results["template"] = results["qsd"]["template"] # Template Language if "lang" in results["qsd"] and len(results["qsd"]["lang"]): results["language"] = results["qsd"]["lang"] # 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["from_phone_id"] = NotifyWhatsApp.unquote( results["qsd"]["from"] ) if "source" in results["qsd"] and len(results["qsd"]["source"]): results["from_phone_id"] = NotifyWhatsApp.unquote( results["qsd"]["source"] ) # 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"] += NotifyWhatsApp.parse_phone_no( results["qsd"]["to"] ) # store any additional payload extra's defined results["template_mapping"] = { NotifyWhatsApp.unquote(x): NotifyWhatsApp.unquote(y) for x, y in results["qsd:"].items() } return results apprise-1.10.0/apprise/plugins/windows.py000066400000000000000000000221111517341665700204400ustar00rootroot00000000000000# 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 time import sleep 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_WINDOWS_SUPPORT_ENABLED = False try: # 3rd party modules (Windows Only) import win32api import win32con import win32gui # We're good to go! NOTIFY_WINDOWS_SUPPORT_ENABLED = True except ImportError: # No problem; we just simply can't support this plugin because we're # either using Linux, or simply do not have pywin32 installed. pass class NotifyWindows(NotifyBase): """A wrapper for local Windows Notifications.""" # Set our global enabled flag enabled = NOTIFY_WINDOWS_SUPPORT_ENABLED requirements = { # Define our required packaging in order to work "details": _("A local Microsoft Windows environment is required.") } # The default descriptive name associated with the Notification service_name = "Windows Notification" # The default protocol protocol = "windows" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/windows/" # Disable throttle rate for Windows requests since they are normally # local anyway request_rate_per_sec = 0 # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Limit results to just the first 2 line otherwise there is just to much # content to display body_max_line_count = 2 # The number of seconds to display the popup for default_popup_duration_sec = 12 # 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, **{ "duration": { "name": _("Duration"), "type": "int", "min": 1, "default": 12, }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, }, ) def __init__(self, include_image=True, duration=None, **kwargs): """Initialize Windows Object.""" super().__init__(**kwargs) # Number of seconds to display notification for self.duration = ( self.default_popup_duration_sec if not (isinstance(duration, int) and duration > 0) else duration ) # Define our handler self.hwnd = None # Track whether or not we want to send an image with our notification # or not. self.include_image = include_image def _on_destroy(self, hwnd, msg, wparam, lparam): """Destroy callback function.""" nid = (self.hwnd, 0) win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, nid) win32api.PostQuitMessage(0) return 0 def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Windows Notification.""" # Always call throttle before any remote server i/o is made self.throttle() try: # Register destruction callback message_map = { win32con.WM_DESTROY: self._on_destroy, } # Register the window class. self.wc = win32gui.WNDCLASS() self.hinst = self.wc.hInstance = win32api.GetModuleHandle(None) self.wc.lpszClassName = "PythonTaskbar" self.wc.lpfnWndProc = message_map self.classAtom = win32gui.RegisterClass(self.wc) # Styling and window type style = win32con.WS_OVERLAPPED | win32con.WS_SYSMENU self.hwnd = win32gui.CreateWindow( self.classAtom, "Taskbar", style, 0, 0, win32con.CW_USEDEFAULT, win32con.CW_USEDEFAULT, 0, 0, self.hinst, None, ) win32gui.UpdateWindow(self.hwnd) # image path (if configured to acquire) icon_path = ( None if not self.include_image else self.image_path(notify_type, extension=".ico") ) if icon_path: icon_flags = win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE try: hicon = win32gui.LoadImage( self.hinst, icon_path, win32con.IMAGE_ICON, 0, 0, icon_flags, ) except Exception as e: self.logger.warning( "Could not load windows notification icon" f" ({icon_path}): {e}" ) # disable icon hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION) else: # disable icon hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION) # Taskbar icon flags = win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP nid = ( self.hwnd, 0, flags, win32con.WM_USER + 20, hicon, "Tooltip", ) win32gui.Shell_NotifyIcon(win32gui.NIM_ADD, nid) win32gui.Shell_NotifyIcon( win32gui.NIM_MODIFY, ( self.hwnd, 0, win32gui.NIF_INFO, win32con.WM_USER + 20, hicon, "Balloon Tooltip", body, 200, title, ), ) # take a rest then destroy sleep(self.duration) win32gui.DestroyWindow(self.hwnd) win32gui.UnregisterClass(self.wc.lpszClassName, None) self.logger.info("Sent Windows notification.") except Exception as e: self.logger.warning("Failed to send Windows notification.") self.logger.debug("Windows Exception: {}", str(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", "duration": str(self.duration), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return f"{self.protocol}://?{NotifyWindows.urlencode(params)}" @staticmethod def parse_url(url): """There are no parameters nessisary for this protocol; simply having windows:// 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) ) # Set duration with contextlib.suppress(TypeError, ValueError): # Update our duration if we're dealing with an integer results["duration"] = int(results["qsd"].get("duration")) # return results return results apprise-1.10.0/apprise/plugins/workflows.py000066400000000000000000000551501517341665700210140ustar00rootroot00000000000000# 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 create a MS Teams Azure Webhook Workflow: # https://support.microsoft.com/en-us/office/browse-and-add-workflows-\ # in-microsoft-teams-4998095c-8b72-4b0e-984c-f2ad39e6ba9a # Your webhook will look somthing like this (legacy): # https://prod-161.westeurope.logic.azure.com:443/\ # workflows/643e69f83c8944438d68119179a10a64/triggers/manual/\ # paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&\ # sv=1.0&sig=KODuebWbDGYFr0z0eu-6Rj8aUKz7108W3wrNJZxFE5A # # Or it may now look something like this: # https://prod-161.westeurope.logic.azure.com:443/\ # powerautomate/automations/direct/\ # workflows/643e69f83c8944438d68119179a10a64/triggers/manual/\ # paths/invoke?api-version=2022-03-01-preview&sp=%2Ftriggers%2Fmanual%2F\ # run&sv=1.0&sig=KODuebWbDGYFr0z0eu-6Rj8aUKz7108W3wrNJZxFE5A # # Yes... The URL is that big... But it looks like this (greatly simplified): # https://HOST:PORT/workflows/ABCD/triggers/manual/path/...sig=DEFG # ^ ^ ^ ^ # | | | | # These are important <---------^------------------------------^ # # # Apprise can support this webhook as is (directly passed into it) # Alternatively it can be shortend to: # These 3 tokens need to be placed in the URL after the Team # workflows://HOST:PORT/ABCD/DEFG/ # import json from json.decoder import JSONDecodeError import re import requests from ..apprise_attachment import AppriseAttachment from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, validate_regex from ..utils.templates import TemplateType, apply_template from .base import NotifyBase class APIVersion: """ Define API Versions """ WORKFLOW = "2016-06-01" POWER_AUTOMATE = "2022-03-01-preview" class NotifyWorkflows(NotifyBase): """A wrapper for Microsoft Workflows (MS Teams) Notifications.""" # The default descriptive name associated with the Notification service_name = "Power Automate / Workflows (for MSTeams)" # The services URL service_url = ( "https://www.microsoft.com/power-platform/products/power-automate" ) # The default secure protocol secure_protocol = ("workflow", "workflows") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/workflows/" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_32 # The maximum allowable characters allowed in the body per message body_maxlen = 1000 # Default Notification Format notify_format = NotifyFormat.MARKDOWN # 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_workflows_template_size = 35000 # Adaptive Card Version adaptive_card_version = "1.4" # Define object templates templates = ( "{schema}://{host}/{workflow}/{signature}", "{schema}://{host}:{port}/{workflow}/{signature}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, # workflow identifier "workflow": { "name": _("Workflow ID"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9_-]+$", "i"), }, # Signature "signature": { "name": _("Signature"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9_-]+$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "id": { "alias_of": "workflow", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "pa": { "name": _("Use Power Automate URL"), "type": "bool", "default": False, "map_to": "power_automate", }, "powerautomate": {"alias_of": "pa"}, "wrap": { "name": _("Wrap Text"), "type": "bool", "default": True, }, "template": { "name": _("Template Path"), "type": "string", "private": True, }, # Below variable shortforms are taken from the Workflows webhook # for consistency "sig": { "alias_of": "signature", }, "ver": { "name": _("API Version"), "type": "string", "map_to": "version", }, "api-version": {"alias_of": "ver"}, }, ) # Define our token control template_kwargs = { "tokens": { "name": _("Template Tokens"), "prefix": ":", }, } def __init__( self, workflow, signature, include_image=None, power_automate=None, version=None, template=None, tokens=None, wrap=None, **kwargs, ): """Initialize Microsoft Workflows Object.""" super().__init__(**kwargs) self.workflow = validate_regex( workflow, *self.template_tokens["workflow"]["regex"] ) if not self.workflow: msg = f"An invalid Workflows ID ({workflow}) was specified." self.logger.warning(msg) raise TypeError(msg) self.signature = validate_regex( signature, *self.template_tokens["signature"]["regex"] ) if not self.signature: msg = f"An invalid Signature ({signature}) was specified." self.logger.warning(msg) raise TypeError(msg) # Place a thumbnail image inline with the message body self.include_image = bool( include_image if include_image is not None else self.template_args["image"]["default"] ) # Power Automate status self.power_automate = bool( power_automate if power_automate is not None else self.template_args["pa"]["default"] ) # Wrap Text self.wrap = bool( wrap if wrap is not None else self.template_args["wrap"]["default"] ) # 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) # Enforce maximum file size self.template[0].max_file_size = self.max_workflows_template_size # Prepare Version # The default is taken from the template_args # - If using power_automate, the API version required is different. default_api_version = ( APIVersion.POWER_AUTOMATE if self.power_automate else APIVersion.WORKFLOW ) self.api_version = ( version if version is not None else default_api_version ) # Template functionality self.tokens = {} if isinstance(tokens, dict): self.tokens.update(tokens) elif tokens: msg = ( "The specified Workflows 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 ): """This function generates our payload whether it be the generic one Apprise generates by default, or one provided by a specified external template.""" # Acquire our to-be footer icon if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) body_content = [] if image_url: body_content.append( { "type": "Image", "url": image_url, "height": "32px", "altText": notify_type.value, } ) if title: body_content.append( { "type": "TextBlock", "text": f"{title}", "style": "heading", "weight": "Bolder", "size": "Large", "id": "title", } ) body_content.append( { "type": "TextBlock", "text": body, "style": "default", "wrap": self.wrap, "id": "body", } ) if not self.template: # By default we use a generic working payload if there was # no template specified schema = "http://adaptivecards.io/schemas/adaptive-card.json" payload = { "type": "message", "attachments": [ { "contentType": ( "application/vnd.microsoft.card.adaptive" ), "contentUrl": None, "content": { "$schema": schema, "type": "AdaptiveCard", "version": self.adaptive_card_version, "body": body_content, # Additionally "msteams": {"width": "full"}, }, } ], } return payload # If our code reaches here, then we generate ourselves the payload template = self.template[0] if not template: # We could not access the attachment self.logger.error( "Could not access Workflow template" f" {template.url(privacy=True)}." ) return False # Take a copy of our token dictionary tokens = self.tokens.copy() # Apply some defaults template values 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) tokens["app_image_url"] = image_url tokens["app_url"] = self.app_url # Enforce Application mode tokens["app_mode"] = TemplateType.JSON try: with open(template.path) as fp: content = json.loads(apply_template(fp.read(), **tokens)) except OSError: self.logger.error( f"MSTeam template {template.url(privacy=True)} could not be" " read." ) return None except JSONDecodeError as e: self.logger.error( f"MSTeam template {template.url(privacy=True)} contains" " invalid JSON." ) self.logger.debug(f"JSONDecodeError: {e}") return None return content def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Microsoft Teams Notification.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } params = { "api-version": self.api_version, "sp": "/triggers/manual/run", "sv": "1.0", "sig": self.signature, } # The URL changes depending on whether we're using power automate or # not path = ( "/powerautomate/automations/direct" if self.power_automate else "" ) notify_url = ( "https://{host}{port}{path}/workflows/{workflow}/" "triggers/manual/paths/invoke".format( host=self.host, port="" if not self.port else f":{self.port}", path=path, workflow=self.workflow, ) ) # Generate our payload if it's possible payload = self.gen_payload( body=body, title=title, notify_type=notify_type, **kwargs ) if not payload: # No need to present a reason; that will come from the # gen_payload() function itself return False self.logger.debug( "Workflows POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Workflows Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, params=params, data=json.dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.accepted, ): # We had a problem status_str = NotifyWorkflows.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Workflows 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] ) # We failed return False else: self.logger.info("Sent Workflows notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Workflows notification." ) self.logger.debug(f"Socket Exception: {e!s}") # We failed 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[0], self.host, self.port, self.workflow, self.signature, ) 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", "wrap": "yes" if self.wrap else "no", "pa": "yes" if self.power_automate else "no", } if self.template: params["template"] = NotifyWorkflows.quote( self.template[0].url(), safe="" ) # Store our version if it differs from default if ( self.api_version != APIVersion.WORKFLOW and not self.power_automate ) or ( self.api_version != APIVersion.POWER_AUTOMATE and self.power_automate ): # But only do so if we're not using power automate with the # default version for that. params["ver"] = self.api_version # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Store any template entries if specified params.update({f":{k}": v for k, v in self.tokens.items()}) return ( "{schema}://{host}{port}/{workflow}/{signature}/?{params}".format( schema=self.secure_protocol[0], host=self.host, port="" if not self.port else f":{self.port}", workflow=self.pprint(self.workflow, privacy, safe=""), signature=self.pprint(self.signature, privacy, safe=""), params=NotifyWorkflows.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 values if provided entries = NotifyWorkflows.split_path(results["fullpath"]) # Display image? results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyWorkflows.template_args["image"]["default"] ) ) # Support Power Automate URL results["power_automate"] = parse_bool( results["qsd"].get( "powerautomate", results["qsd"].get( "pa", NotifyWorkflows.template_args["pa"]["default"] ), ) ) # Wrap Text? results["wrap"] = parse_bool( results["qsd"].get( "wrap", NotifyWorkflows.template_args["wrap"]["default"] ) ) # Template Handling if "template" in results["qsd"] and results["qsd"]["template"]: results["template"] = NotifyWorkflows.unquote( results["qsd"]["template"] ) if "workflow" in results["qsd"] and results["qsd"]["workflow"]: results["workflow"] = NotifyWorkflows.unquote( results["qsd"]["workflow"] ) elif "id" in results["qsd"] and results["qsd"]["id"]: results["workflow"] = NotifyWorkflows.unquote(results["qsd"]["id"]) else: results["workflow"] = ( None if not entries else NotifyWorkflows.unquote(entries.pop(0)) ) # Signature if "signature" in results["qsd"] and results["qsd"]["signature"]: results["signature"] = NotifyWorkflows.unquote( results["qsd"]["signature"] ) elif "sig" in results["qsd"] and results["qsd"]["sig"]: results["signature"] = NotifyWorkflows.unquote( results["qsd"]["sig"] ) else: # Read information from path results["signature"] = ( None if not entries else NotifyWorkflows.unquote(entries.pop(0)) ) # Version if "api-version" in results["qsd"] and results["qsd"]["api-version"]: results["version"] = NotifyWorkflows.unquote( results["qsd"]["api-version"] ) elif "ver" in results["qsd"] and results["qsd"]["ver"]: results["version"] = NotifyWorkflows.unquote(results["qsd"]["ver"]) # Store our tokens results["tokens"] = results["qsd:"] return results @staticmethod def parse_native_url(url): """ Support parsing the webhook straight out of workflows https://HOST:443/workflows/WORKFLOWID/triggers/manual/paths/invoke or https://HOST:443/powerautomate/automations/direct/workflows /WORKFLOWID/triggers/manual/paths/invoke """ # Match our workflows webhook URL and re-assemble result = re.match( r"^https?://(?P[A-Z0-9_.-]+)" r"(?P:[1-9][0-9]{0,5})?" # The new URL structure includes /powerautomate/automations/direct r"(?P/powerautomate/automations/direct)?" r"/workflows/" r"(?P[A-Z0-9_-]+)" r"/triggers/manual/paths/invoke/?" r"(?P\?.+)$", url, re.I, ) if result: # Determine if we're using power automate or not power_automate = ( "&pa=yes" if result.group("power_automate") else "" ) # Construct our URL return NotifyWorkflows.parse_url( "{schema}://{host}{port}/{workflow}/{params}{pa}".format( schema=NotifyWorkflows.secure_protocol[0], host=result.group("host"), port=( "" if not result.group("port") else result.group("port") ), workflow=result.group("workflow"), params=result.group("params"), pa=power_automate, ) ) return None apprise-1.10.0/apprise/plugins/wxpusher.py000066400000000000000000000315621517341665700206450ustar00rootroot00000000000000# 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 at https://wxpusher.zjiecode.com/ # # Login and acquire your App Token # - Open the backend of the application: # https://wxpusher.zjiecode.com/admin/ # - Find the appToken menu from the left menu bar, here you can reset the # appToken, please note that after resetting, the old appToken will be # invalid immediately and the call interface will fail. from itertools import chain import json import re 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 # Topics are always numerical IS_TOPIC = re.compile(r"^\s*(?P[1-9][0-9]{0,20})\s*$") # users always start with UID_ IS_USER = re.compile( r"^\s*(?P(?PUID_)(?P[^\s]+))\s*$", re.I ) WXPUSHER_RESPONSE_CODES = { 1000: "The request was processed successfully.", 1001: "The token provided in the request is missing.", 1002: "The token provided in the request is incorrect or expired.", 1003: "The body of the message was not provided.", 1004: ( "The user or topic you're trying to send the message to does not exist" ), 1005: "The app or topic binding process failed.", 1006: "There was an error in sending the message.", 1007: "The message content exceeds the allowed length.", 1008: ( "The API call frequency is too high and the server rejected the " "request." ), 1009: ( "There might be other issues that are not explicitly covered by " "the above codes" ), 1010: "The IP address making the request is not whitelisted.", } class WxPusherContentType: """Defines the different supported content types.""" TEXT = 1 HTML = 2 MARKDOWN = 3 class SubscriptionType: # Verify Subscription Time UNVERIFIED = 0 PAID_USERS = 1 UNSUBSCRIBED = 2 class NotifyWxPusher(NotifyBase): """A wrapper for WxPusher Notifications.""" # The default descriptive name associated with the Notification service_name = "WxPusher" # The services URL service_url = "https://wxpusher.zjiecode.com/" # The default protocol secure_protocol = "wxpusher" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/wxpusher/" # WxPusher notification endpoint notify_url = "https://wxpusher.zjiecode.com/api/send/message" # Define object templates templates = ("{schema}://{token}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("App Token"), "type": "string", "required": True, "regex": (r"^AT_[^\s]+$", "i"), "private": True, }, "target_topic": { "name": _("Target Topic"), "type": "int", "map_to": "targets", }, "target_user": { "name": _("Target User ID"), "type": "string", "regex": (r"^UID_[^\s]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, "token": { "alias_of": "token", }, }, ) # Used for mapping the content type to our output since Apprise supports # The same formats that WxPusher does. __content_type_map = { NotifyFormat.MARKDOWN: WxPusherContentType.MARKDOWN, NotifyFormat.TEXT: WxPusherContentType.TEXT, NotifyFormat.HTML: WxPusherContentType.HTML, } def __init__(self, token, targets=None, **kwargs): """Initialize WxPusher Object.""" super().__init__(**kwargs) # App Token (associated with WxPusher account) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"An invalid WxPusher App Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # Used for URL generation afterwards only self._invalid_targets = [] # For storing what is detected self._users = [] self._topics = [] # Parse our targets for target in parse_list(targets): # Validate targets and drop bad ones: result = IS_USER.match(target) if result: # store valid user self._users.append(result["full"]) continue result = IS_TOPIC.match(target) if result: # store valid topic self._topics.append(int(result["topic"])) continue self.logger.warning( f"Dropped invalid WxPusher user/topic ({target}) specified.", ) self._invalid_targets.append(target) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform WxPusher Notification.""" if not self._users and not self._topics: # There were no services to notify self.logger.warning("There were no WxPusher targets to notify") return False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", } # Prepare our payload payload = { "appToken": self.token, "content": body, "summary": title, "contentType": self.__content_type_map[self.notify_format], "topicIds": self._topics, "uids": self._users, # unsupported at this time # 'verifyPay': False, # 'verifyPayType': 0, "url": None, } # Some Debug Logging self.logger.debug( f"WxPusher POST URL: {self.notify_url} " f"(cert_verify={self.verify_certificate})" ) self.logger.debug(f"WxPusher 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).encode("utf-8"), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) try: content = json.loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} # 1000 is the expected return code for a successful query if ( r.status_code == requests.codes.ok and content and content.get("code") == 1000 ): # We're good! self.logger.info( "Sent WxPusher notification to %d targets.", len(self._users) + len(self._topics), ) else: error_str = ( content.get("msg") if content else ( WXPUSHER_RESPONSE_CODES.get( content.get("code") if content else None, "An unknown error occured.", ) ) ) # We had a problem status_str = ( error_str if error_str else NotifyWxPusher.http_response_code_lookup( r.status_code ) ) self.logger.warning( "Failed to send WxPusher notification, " "code={}/{}: {}".format( r.status_code, "unk" if not content else content.get("code"), status_str, ) ) self.logger.debug( "Response Details:\r\n%r", content if content else (r.content or b"")[:2000], ) # Mark our failure return False except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending WxPusher notification." ) self.logger.debug(f"Socket Exception: {e!s}") 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.token) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{token}/{targets}/?{params}".format( schema=self.secure_protocol, token=self.pprint( self.token, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( chain( [str(t) for t in self._topics], self._users, [ NotifyWxPusher.quote(x, safe="") for x in self._invalid_targets ], ) ), params=NotifyWxPusher.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 # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = NotifyWxPusher.split_path(results["fullpath"]) # App Token if "token" in results["qsd"] and len(results["qsd"]["token"]): # Extract the App token from an argument results["token"] = NotifyWxPusher.unquote(results["qsd"]["token"]) # Any host entry defined is actually part of the path # store it's element (if defined) if results["host"]: results["targets"].append( NotifyWxPusher.split_path(results["host"]) ) else: # The hostname is our source number results["token"] = NotifyWxPusher.unquote(results["host"]) # 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"] += NotifyWxPusher.parse_list( results["qsd"]["to"] ) return results apprise-1.10.0/apprise/plugins/xmpp/000077500000000000000000000000001517341665700173635ustar00rootroot00000000000000apprise-1.10.0/apprise/plugins/xmpp/__init__.py000066400000000000000000000027301517341665700214760ustar00rootroot00000000000000# 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. """XMPP Notifications.""" from .base import NotifyXMPP __all__ = [ "NotifyXMPP", ] apprise-1.10.0/apprise/plugins/xmpp/adapter.py000066400000000000000000001032171517341665700213610ustar00rootroot00000000000000# 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. """A minimal, self-contained Slixmpp adapter. This module provides a wrapper to Slixmpp for Apprise. """ from __future__ import annotations import contextlib import logging import re import ssl import threading import time from typing import Any, Callable, Optional import certifi from ...compat import dataclass_compat as dataclass from .common import SECURE_MODES, SecureXMPPMode # Default our global support flag SLIXMPP_SUPPORT_AVAILABLE = False try: import asyncio from concurrent.futures import TimeoutError as FuturesTimeoutError import slixmpp SLIXMPP_SUPPORT_AVAILABLE = True except ImportError: # Slixmpp is not available if code reaches here slixmpp = None # type: ignore[assignment] asyncio = None # type: ignore[assignment] FuturesTimeoutError = Exception # type: ignore[misc] @dataclass(frozen=True, slots=True) class XMPPConfig: """Connection configuration.""" host: str port: int jid: str password: str secure: str = SecureXMPPMode.STARTTLS verify_certificate: bool = True # --------------------------------------------------------------------------- # Logging Bridge # --------------------------------------------------------------------------- LOGGING_ID = "apprise.xmpp" _LOG_BRIDGE_LOCK = threading.Lock() _LOG_BRIDGED = False def bridge_slixmpp_logging() -> None: """Bridge Slixmpp logging into Apprise logging handlers. This is intentionally idempotent to prevent handler duplication when many notifications are sent within the same process. """ global _LOG_BRIDGED if _LOG_BRIDGED: return with _LOG_BRIDGE_LOCK: if _LOG_BRIDGED: return apprise_logger = logging.getLogger("apprise") slix_logger = logging.getLogger("slixmpp") existing = {id(h) for h in slix_logger.handlers} for handler in apprise_logger.handlers: if id(handler) not in existing: slix_logger.addHandler(handler) existing.add(id(handler)) slix_logger.setLevel(apprise_logger.getEffectiveLevel()) # Prevent duplicates via propagation chains. slix_logger.propagate = False _LOG_BRIDGED = True def _close_awaitable(obj: Any) -> None: """Best-effort close for coroutine-like objects. Some test patches raise before awaiting, leaving coroutines to be garbage collected and triggering runtime warnings. """ close = getattr(obj, "close", None) if callable(close): with contextlib.suppress(Exception): close() # --------------------------------------------------------------------------- # Internal Slixmpp Client Factory # --------------------------------------------------------------------------- _CLIENT_SUBCLASS_CACHE: dict[int, type[Any]] = {} def _get_client_subclass(base_cls: type[Any]) -> type[Any]: """Return (and cache) the internal client subclass for a given base class. The tests monkeypatch `xmpp_adapter.slixmpp.ClientXMPP`, so we must resolve the base class dynamically at runtime, not at import time. We still cache the derived subclass per base class identity to avoid repeated class creation overhead in production. """ key = id(base_cls) cached = _CLIENT_SUBCLASS_CACHE.get(key) if cached is not None: return cached class _Client(base_cls): # type: ignore[misc] """Internal Slixmpp client for both one-shot and keepalive flows.""" def __init__( self, jid: str, password: str, *, oneshot: bool, logger: Optional[logging.Logger] = None, targets: Optional[list[(str, str)]] = None, subject: str = "", body: str = "", before_message: Optional[Callable[[], None]] = None, # Multi-User Chat want_muc: bool = False, nick: str = "", want_roster: bool = False, roster_timeout: float = 0.0, session_started_evt: Optional[asyncio.Event] = None, # type: ignore[name-defined] ) -> None: super().__init__(jid, password) # Store the logger so _log_task can find it global LOGGING_ID self.logger = logger or logging.getLogger(LOGGING_ID) # Behaviour self._oneshot = bool(oneshot) # Send payload (only used in oneshot mode) self._targets = list(targets or []) self._subject = subject self._body = body self._before_message = before_message # Roster behaviour (both modes) self._want_roster = bool(want_roster) self._roster_timeout = float(roster_timeout) # Multi-User Chat self._want_muc = bool(want_muc) self._nick = nick if self._want_muc: with contextlib.suppress(Exception): self.register_plugin("xep_0045") # Keepalive coordination (keepalive mode only) self._session_started_evt = session_started_evt # State self._auth_failed = False self.add_event_handler("session_start", self._on_session_start) self.add_event_handler("failed_auth", self._failed_auth) self.add_event_handler("disconnected", self._disconnected) # Keep behaviour predictable and close quickly. self.auto_reconnect = False async def _session_start( self, *args: object, **kwargs: object ) -> None: try: with contextlib.suppress(Exception): self.send_presence() if self._want_roster and self._roster_timeout > 0: roster_coro = self.get_roster() try: await asyncio.wait_for( roster_coro, timeout=self._roster_timeout, ) except Exception: _close_awaitable(roster_coro) # One-shot mode sends messages immediately on session_start and # then disconnects. Keepalive mode just signals readiness. if self._oneshot: for mtype, target in self._targets: if self._before_message: self._before_message() if mtype == "groupchat": nick = ( self._nick or getattr(self.boundjid, "user", "") or "apprise" ) muc_coro = self.plugin["xep_0045"].join_muc( target, nick ) try: await asyncio.wait_for(muc_coro, timeout=5.0) except Exception: _close_awaitable(muc_coro) self.send_message( mto=target, msubject=self._subject, mbody=self._body, mtype=mtype, ) finally: if self._oneshot: self.disconnect() elif self._session_started_evt is not None: self._session_started_evt.set() def _failed_auth(self, *args: object, **kwargs: object) -> None: # Authentication failure is always a hard failure. self._auth_failed = True if self._session_started_evt is not None: self._session_started_evt.clear() self.disconnect() def _on_session_start(self, *args: object, **kwargs: object) -> Any: """Slixmpp event handler entrypoint. Must be synchronous. Also, we must never fall back to asyncio.create_task() because that can bind to the wrong loop, or no loop, and leak coroutines. """ coro = self._session_start(*args, **kwargs) # Schedule on the event loop for both one-shot and keepalive. loop = getattr(self, "loop", None) # If the loop is missing or already closing, we MUST close the # coroutine immediately to prevent "never awaited" warnings. if loop is None or not loop.is_running(): with contextlib.suppress(Exception): coro.close() return None try: task = loop.create_task(coro) def _log_task(t: asyncio.Task[Any]) -> None: if t.cancelled(): return exc = t.exception() if exc is not None: self.logger.error("XMPP task failed: %s", exc) task.add_done_callback(_log_task) except Exception: # Fallback closure if loop.create_task fails with contextlib.suppress(Exception): coro.close() return None def _disconnected(self, *args: object, **kwargs: object) -> None: if self._session_started_evt is not None: self._session_started_evt.clear() _CLIENT_SUBCLASS_CACHE[key] = _Client return _Client def _build_client(*args: Any, **kwargs: Any) -> Any: return _get_client_subclass(slixmpp.ClientXMPP)(*args, **kwargs) # --------------------------------------------------------------------------- # Adapter # --------------------------------------------------------------------------- class SlixmppAdapter: """Send a message to one or more targets. When keepalive is False, process() performs a one-shot connect, send, disconnect. When keepalive is True, send_message() keeps a session alive across calls. The connection is closed only when close() is called or the instance is garbage collected. """ # Define a Slixmpp reference version to prevent this tool from working # under non-supported versions _supported_version = (1, 10, 0) # Flag to control if we are enabled or not # effectively.. .is the dependent slixmpp library available to us # or not _enabled = SLIXMPP_SUPPORT_AVAILABLE def __init__( self, config: XMPPConfig, targets: list[(str, str)], subject: str, body: str, timeout: float = 30.0, roster: bool = False, before_message: Optional[Callable[[], None]] = None, keepalive: bool = False, want_muc: bool = False, default_nickname: Optional[str] = None, ) -> None: self.config, self.targets, self.subject, self.body = ( config, targets, subject, body, ) self.timeout = max(5.0, float(timeout)) self.roster, self.before_message, self.keepalive = ( roster, before_message, keepalive, ) self._want_muc = want_muc global LOGGING_ID self.logger = logging.getLogger(LOGGING_ID) self.nickname = default_nickname or "apprise" bridge_slixmpp_logging() # Keepalive internals (only used when keepalive=True) self._state_lock = threading.RLock() self._closing = False self._thread: Optional[threading.Thread] = None self._loop: Optional[asyncio.AbstractEventLoop] = None # type: ignore[name-defined] self._client: Optional[slixmpp.ClientXMPP] = None # type: ignore[name-defined] self._loop_ready = threading.Event() # asyncio primitives created inside the loop thread self._connect_lock: Optional[asyncio.Lock] = None # type: ignore[name-defined] self._session_started: Optional[asyncio.Event] = None # type: ignore[name-defined] def __del__(self) -> None: """Best effort close for keepalive sessions.""" with contextlib.suppress(Exception): self.close() @staticmethod def _ssl_context(verify: bool) -> ssl.SSLContext: ctx = ssl.create_default_context(cafile=certifi.where()) if not verify: ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx @staticmethod def _loop_tick(loop: asyncio.AbstractEventLoop) -> None: """Run one final loop tick, closing the coroutine on error.""" tick = asyncio.sleep(0) try: loop.run_until_complete(tick) except Exception: _close_awaitable(tick) @staticmethod def _finalize_loop(loop: asyncio.AbstractEventLoop) -> None: """Best-effort loop shutdown to avoid resource warnings.""" with contextlib.suppress(Exception): # Cancel any pending tasks tasks = asyncio.all_tasks(loop) for task in tasks: task.cancel() if tasks: loop.run_until_complete( asyncio.gather(*tasks, return_exceptions=True) ) # Give the loop one final tick to process cancellations SlixmppAdapter._loop_tick(loop) with contextlib.suppress(Exception): loop.stop() # Only attempt to shutdown generators if the loop is still open. # Otherwise, creating the coroutine without running it triggers a # RuntimeWarning. if not loop.is_closed(): with contextlib.suppress(Exception): ag_coro = loop.shutdown_asyncgens() try: loop.run_until_complete(ag_coro) except Exception: _close_awaitable(ag_coro) # Detach loop from thread policy with contextlib.suppress(Exception): asyncio.set_event_loop(None) if not loop.is_closed(): with contextlib.suppress(Exception): loop.close() def close(self) -> None: """Close any persistent connection and stop the keepalive worker.""" with self._state_lock: self._closing = True loop, client, thread = self._loop, self._client, self._thread if loop is None or thread is None: return def _shutdown() -> None: try: if client is not None: client.disconnect() finally: with contextlib.suppress(Exception): loop.stop() with contextlib.suppress(Exception): loop.call_soon_threadsafe(_shutdown) # Give a moment to exit gracefully. thread.join(max(1.0, min(5.0, self.timeout / 2.0))) # If the worker is still alive, do not clear state. alive = getattr(thread, "is_alive", None) if callable(alive) and alive(): return with self._state_lock: # Detach from any thread-local loop to avoid creating a new # loop implicitly (Python 3.12+ may warn about it). with contextlib.suppress(Exception): asyncio.set_event_loop(None) self._thread = None self._loop = None self._client = None self._connect_lock = None self._session_started = None # ----------------------------------------------------------------------- # One-shot behaviour (no keepalive) # ----------------------------------------------------------------------- def process(self) -> bool: """Send the message, always returning within timeout.""" done = threading.Event() result: list[Optional[bool]] = [None] if not self._enabled: # We are not turned on return False shared: dict[str, Any] = {"loop": None, "client": None} def runner() -> None: loop: Optional[asyncio.AbstractEventLoop] = None # type: ignore[name-defined] start = time.monotonic() try: loop = asyncio.new_event_loop() # type: ignore[union-attr] asyncio.set_event_loop(loop) # type: ignore[union-attr] shared["loop"] = loop targets = ( list(self.targets) if self.targets else [("chat", self.config.jid)] ) roster_timeout = ( max(2.0, min(10.0, self.timeout / 3.0)) if self.roster else 0.0 ) client = _build_client( jid=self.config.jid, password=self.config.password, oneshot=True, logger=self.logger, targets=targets, subject=self.subject, body=self.body, before_message=self.before_message, want_muc=self._want_muc, nick=self.nickname, want_roster=self.roster, roster_timeout=roster_timeout, session_started_evt=None, ) shared["client"] = client # Prevent Slixmpp from owning loop lifecycle with contextlib.suppress(Exception): client.loop = loop # type: ignore[assignment] # Resolve connection behaviour from secure mode mode_cfg = SECURE_MODES.get(self.config.secure) if not mode_cfg: raise ValueError( f"Unsupported XMPP secure mode: {self.config.secure}" ) client.enable_plaintext = bool(mode_cfg["enable_plaintext"]) client.enable_starttls = bool(mode_cfg["enable_starttls"]) client.enable_direct_tls = bool(mode_cfg["enable_direct_tls"]) # Only attach an SSL context when TLS may be used if not client.enable_plaintext: client.ssl_context = self._ssl_context( self.config.verify_certificate ) # Slixmpp >= 1.10.0 connect() returns a Future. connect_timeout = max(3.0, min(15.0, self.timeout / 3.0)) connect_fut = client.connect( host=self.config.host, port=self.config.port, ) try: ok = loop.run_until_complete( asyncio.wait_for( connect_fut, timeout=connect_timeout, ) ) if ok is False: self.logger.warning("XMPP connect failed.") with contextlib.suppress(Exception): client.disconnect() result[0] = False return except asyncio.TimeoutError: self.logger.warning( "XMPP connect timed out after %.2fs", connect_timeout ) result[0] = False return except Exception as e: self.logger.debug("XMPP connect failed: %s", e) result[0] = False return # Run until disconnected, but still respect our overall # timeout. elapsed = time.monotonic() - start remaining = max(0.0, self.timeout - elapsed) run_timeout = max(1.0, remaining) try: loop.run_until_complete( asyncio.wait_for( # type: ignore[arg-type] client.disconnected, timeout=run_timeout ) ) except asyncio.TimeoutError: # type: ignore[attr-defined] self.logger.warning( "XMPP session timed out after %.2fs", run_timeout ) with contextlib.suppress(Exception): client.disconnect() result[0] = False return # Disconnect happened, success depends on auth state result[0] = not bool(getattr(client, "_auth_failed", False)) except Exception as e: # pragma: no cover self.logger.warning("XMPP send failed.") self.logger.debug("XMPP Exception: %s", e) result[0] = False finally: loop = shared.get("loop") if loop is not None: self._finalize_loop(loop) done.set() t = threading.Thread(target=runner, name="apprise-xmpp", daemon=True) t.start() if not done.wait(timeout=self.timeout): self.logger.warning( "XMPP send timed out after %.2fs.", self.timeout ) result[0] = False loop_obj = shared.get("loop") client_obj = shared.get("client") if loop_obj is not None: loop = loop_obj # type: ignore[assignment] try: if client_obj is not None: client = client_obj # type: ignore[assignment] loop.call_soon_threadsafe(client.disconnect) except Exception: pass with contextlib.suppress(Exception): loop.call_soon_threadsafe(loop.stop) t.join(timeout=0.25) return bool(result[0]) # ----------------------------------------------------------------------- # Keepalive Behaviour # ----------------------------------------------------------------------- def _ensure_keepalive_worker(self) -> bool: """Ensure the background loop and client exist.""" if not self.keepalive: return False with self._state_lock: if self._closing: return False if self._thread is not None and self._thread.is_alive(): return True if not self._enabled: return False self._loop_ready.clear() self._thread = threading.Thread( target=self._keepalive_runner, name="apprise-xmpp-keepalive", daemon=True, ) self._thread.start() if not self._loop_ready.wait(timeout=self.timeout): self.logger.warning( "XMPP keepalive worker failed to start within %.2fs", self.timeout, ) return False return True def _keepalive_runner(self) -> None: loop: Optional[asyncio.AbstractEventLoop] = None # type: ignore[name-defined] published = False try: loop = asyncio.new_event_loop() # type: ignore[union-attr] asyncio.set_event_loop(loop) # type: ignore[union-attr] session_started = asyncio.Event() # type: ignore[union-attr] connect_lock = asyncio.Lock() # type: ignore[union-attr] roster_timeout = ( max(2.0, min(10.0, self.timeout / 3.0)) if self.roster else 0.0 ) client = _build_client( jid=self.config.jid, password=self.config.password, oneshot=False, logger=self.logger, want_muc=self._want_muc, want_roster=self.roster, roster_timeout=roster_timeout, session_started_evt=session_started, ) with contextlib.suppress(Exception): client.loop = loop # type: ignore[assignment] mode_cfg = SECURE_MODES.get(self.config.secure) if not mode_cfg: raise ValueError( f"Unsupported XMPP secure mode: {self.config.secure}" ) client.enable_plaintext = bool(mode_cfg["enable_plaintext"]) client.enable_starttls = bool(mode_cfg["enable_starttls"]) client.enable_direct_tls = bool(mode_cfg["enable_direct_tls"]) if not client.enable_plaintext: client.ssl_context = self._ssl_context( self.config.verify_certificate ) # keepalive=yes implies enabling XEP-0199 keepalive pings with contextlib.suppress(Exception): client.register_plugin("xep_0199", {"keepalive": True}) if self._want_muc: # Multi-User Chat with contextlib.suppress(Exception): client.register_plugin("xep_0045") with self._state_lock: if self._closing: return self._loop = loop self._client = client self._connect_lock = connect_lock self._session_started = session_started published = True self._loop_ready.set() loop.run_forever() except Exception as e: # pragma: no cover self.logger.warning("XMPP keepalive worker failed.") self.logger.debug("XMPP keepalive exception: %s", e) finally: if published: with self._state_lock: if self._closing and self._loop is loop: # Clear internal references if we are exiting the # worker. self._loop = None self._client = None self._connect_lock = None self._session_started = None self._thread = None if loop is not None: self._finalize_loop(loop) async def _connect_if_required(self) -> bool: if self._loop is None or self._client is None: return False if self._connect_lock is None or self._session_started is None: return False # If auth already failed, do not pretend a connection is ready. if bool(getattr(self._client, "_auth_failed", False)): return False async with self._connect_lock: if self._session_started.is_set(): return True connect_timeout = max(3.0, min(15.0, self.timeout / 3.0)) try: fut = self._client.connect( host=self.config.host, port=self.config.port, ) connect_ok = await asyncio.wait_for( # type: ignore[arg-type] fut, timeout=connect_timeout ) # honour boolean connect() result in keepalive. if not connect_ok: self.logger.warning("XMPP connect failed.") with contextlib.suppress(Exception): self._client.disconnect() return False except asyncio.TimeoutError: # type: ignore[attr-defined] self.logger.warning( "XMPP connect timed out after %.2fs", connect_timeout ) return False except Exception as e: self.logger.debug("XMPP connect failed: %s", e) return False try: session_wait = self._session_started.wait() await asyncio.wait_for( session_wait, timeout=connect_timeout, ) except asyncio.TimeoutError: # type: ignore[attr-defined] _close_awaitable(session_wait) self.logger.warning( "XMPP session did not start within %.2fs", connect_timeout, ) return False except Exception: _close_awaitable(session_wait) return False # If auth failed during startup, treat as failure. return not bool(getattr(self._client, "_auth_failed", False)) async def _send_keepalive_async( self, targets: list[(str, str)], subject: str, body: str, ) -> bool: if self._client is None: return False ok = await self._connect_if_required() if ok is False: return False # Auth failed after connect, do not send. if bool(getattr(self._client, "_auth_failed", False)): return False send_targets = targets if targets else [("chat", self.config.jid)] try: for mtype, target in send_targets: if mtype == "groupchat": nick = ( getattr(self._client.boundjid, "user", "") or self.nickname ) muc_coro = self._client.plugin["xep_0045"].join_muc( target, nick ) try: await asyncio.wait_for(muc_coro, timeout=5.0) except Exception: _close_awaitable(muc_coro) self._client.send_message( mto=target, msubject=subject, mbody=body, mtype=mtype, ) return True except Exception as e: self.logger.debug("XMPP send failed: %s", e) if self._session_started is not None: self._session_started.clear() return False def send_message( self, targets: Optional[list[(str, str)]] = None, subject: Optional[str] = None, body: Optional[str] = None, ) -> bool: """Send a message, keeping the session alive if keepalive=True.""" if not self.keepalive: # Fallback to one-shot behaviour using current stored attributes if targets is not None: self.targets = targets if subject is not None: self.subject = subject if body is not None: self.body = body return self.process() if not self._ensure_keepalive_worker(): return False loop = self._loop if loop is None: return False targets = self.targets if targets is None else targets subject = self.subject if subject is None else subject body = self.body if body is None else body coro = self._send_keepalive_async( targets=targets, subject=subject, body=body, ) try: fut = asyncio.run_coroutine_threadsafe( # type: ignore[union-attr] coro, loop, ) return bool(fut.result(timeout=self.timeout)) except FuturesTimeoutError: self.logger.warning( "XMPP keepalive send timed out after %.2fs", self.timeout ) if self._session_started is not None: with contextlib.suppress(Exception): loop.call_soon_threadsafe(self._session_started.clear) return False except Exception as e: with contextlib.suppress(Exception): coro.close() self.logger.debug("XMPP keepalive send exception: %s", e) return False @staticmethod def package_dependency() -> str: """Defines our static dependency for this adapter to work.""" version = ".".join([str(v) for v in SlixmppAdapter._supported_version]) return f"slixmpp >= {version}" @staticmethod def supported_version(version: Optional[str] = None) -> bool: """Returns true if we currently have a version of Slixmpp supported. Provided string describes a version in format of major.minor.patch. """ if SLIXMPP_SUPPORT_AVAILABLE: m = re.match( r"^(?P\d+)(\.(?P\d+)(\.(?P\d+))?)?", version or getattr(slixmpp, "__version__", "") or "", ) if not m: return False return ( int(m.group("major")), int(m.group("minor") or 0), int(m.group("patch") or 0), ) >= SlixmppAdapter._supported_version return False apprise-1.10.0/apprise/plugins/xmpp/base.py000066400000000000000000000417551517341665700206630ustar00rootroot00000000000000# 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. """XMPP Notifications""" from __future__ import annotations import re from typing import Any, Optional from ...common import NotifyType from ...locale import gettext_lazy as _ from ...url import PrivacyMode from ...utils.parse import parse_bool, parse_list, validate_regex from ..base import NotifyBase from .adapter import SLIXMPP_SUPPORT_AVAILABLE, SlixmppAdapter, XMPPConfig from .common import SECURE_MODES, SecureXMPPMode # A pragmatic, "hardened" JID validator intended for Apprise URLs. # # - Supports: local@domain and local@domain/resource # - Rejects whitespace anywhere # - Rejects missing local or domain # - Rejects '@' in the domain component # # This does not try to fully implement RFC 7622. The goal is to catch bad # inputs early and reliably while still supporting common JID patterns. IS_JID = re.compile( r"^\s*(?P#|%23)?(?P[^@\s/]+)((@|%40)" r"(?P[^@\s/]+))?(?:(/|%2F)(?P[^%/\s]+)" r"((/|%2F).*)?)?\s*$" ) class NotifyXMPP(NotifyBase): """Send notifications via XMPP using Slixmpp.""" # Set our global enabled flag enabled = SLIXMPP_SUPPORT_AVAILABLE and SlixmppAdapter._enabled requirements = { # Define our required packaging in order to work "packages_required": SlixmppAdapter.package_dependency(), } # The default descriptive name associated with the Notification service_name = "XMPP" # The services URL service_url = "https://xmpp.org/" # The default insecure protocol protocol = "xmpp" # The default secure protocol secure_protocol = "xmpps" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/xmpp/" templates = ( "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("User"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_user": { "name": _("Target User"), "type": "string", "map_to": "targets", }, "target_channels": { "name": _("Target Channel"), "type": "string", "prefix": "#", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) template_args = dict( NotifyBase.template_args, **{ "xmpp": { "name": _("XMPP Server"), "type": "string", "map_to": "xmpp_host", }, "mode": { "name": _("Secure Mode"), "type": "choice:string", "values": SECURE_MODES, "default": SecureXMPPMode.STARTTLS, "map_to": "secure_mode", }, "roster": { "name": _("Get Roster"), "type": "bool", "default": False, }, "subject": { "name": _("Use Subject"), "type": "bool", "default": False, }, "keepalive": { "name": _("Keep Connection Alive"), "type": "bool", "default": False, }, "to": {"alias_of": "targets"}, "name": { "name": _("MUC Nickname"), "type": "string", }, }, ) def __init__( self, targets: Optional[list[(str, str)]] = None, secure_mode: Optional[str] = None, roster: Optional[bool] = None, subject: Optional[bool] = None, keepalive: Optional[bool] = None, name: Optional[str] = None, xmpp_host: Optional[str] = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) # xmpp_host allows the connection host to differ from the JID domain. # Mirrors the smtp= / smtp_host pattern in the email plugin. self.xmpp_host = ( xmpp_host.strip() if isinstance(xmpp_host, str) and xmpp_host.strip() else "" ) try: self.jid, _ = self.normalize_jid(self.user or "", self.host) except ValueError: msg = f"An invalid XMPP JID ({self.user}) was specified." self.logger.warning(msg) raise TypeError(msg) from None self.targets: list[(str, str)] = [] # Flag for tracking if we want Multi-User Chat function enabled self.want_muc = False for target in parse_list(targets): try: jid, is_muc = self.normalize_jid(target or "", self.host) mtype = "groupchat" if is_muc else "chat" if is_muc: self.want_muc = True except ValueError: self.logger.warning( "Dropped invalid XMPP target (%s).", target ) continue self.targets.append((mtype, jid)) if isinstance(secure_mode, str) and secure_mode.strip(): self.secure_mode = secure_mode.strip().lower() self.secure_mode = next( (k for k in SECURE_MODES if k.startswith(self.secure_mode)), None, ) if self.secure_mode not in SECURE_MODES: msg = ( "The XMPP secure mode specified " f"({secure_mode}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) else: self.secure_mode = ( SecureXMPPMode.NONE if not self.secure else self.template_args["mode"]["default"] ) # Prepare our roster check self.roster = ( self.template_args["roster"]["default"] if roster is None else bool(roster) ) self.subject = ( self.template_args["subject"]["default"] if subject is None else bool(subject) ) self.keepalive = ( self.template_args["keepalive"]["default"] if keepalive is None else bool(keepalive) ) if self.secure and self.secure_mode == SecureXMPPMode.NONE: self.secure_mode = self.template_args["mode"]["default"] self.logger.warning( "Ambiguous XMPP configuration: secure=True and mode=None; " "secure setting prevails; setting mode=%s", self.secure_mode, ) elif not self.secure and self.secure_mode != SecureXMPPMode.NONE: self.logger.warning( "Ambiguous XMPP configuration: secure=False and mode=%s; " "mode setting prevails; setting secure=True", self.secure_mode, ) self.secure = True # MUC nickname: alphanumeric + underscore; falls back to the JID # username, then the app_id as a last resort self.name = validate_regex(name, r"^[a-zA-Z0-9_]+$") if name else None if self.name is None: self.name = self.user or self.app_id # Keepalive adapter (created lazily) self._adapter: Optional[SlixmppAdapter] = None def __del__(self) -> None: """Best-effort close for keepalive sessions.""" try: if self._adapter is not None: self._adapter.close() except Exception: # Never raise from __del__ pass @property def url_identifier(self) -> tuple[str, str, str, str, Optional[int]]: """Return the pieces that uniquely identify this configuration.""" return ( self.secure_protocol if self.secure else self.protocol, self.host, self.xmpp_host, self.user, self.password, self.port, ) def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Return the URL representation of this notification.""" # Initialize our parameters params = { "mode": self.secure_mode, "roster": "yes" if self.roster else "no", "subject": "yes" if self.subject else "no", "keepalive": "yes" if self.keepalive else "no", } # Only include name when it differs from the default # (JID user / app_id) if self.name != (self.user or self.app_id): params["name"] = self.name if self.xmpp_host and self.xmpp_host != self.host: params["xmpp"] = self.xmpp_host # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) auth = "{user}:{password}@".format( user=self.quote(self.jid, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="", ), ) default_port = SECURE_MODES[self.secure_mode]["default_port"] port = self.port if isinstance(self.port, int) else default_port port_str = "" if port == default_port else f":{port}" schema = self.secure_protocol if self.secure else self.protocol # Targets can contain '/' as a resource separator, so ensure it is # always percent-encoded in the path (otherwise Apprise will split it). # Use %23 for the MUC '#' prefix so it is not misread as a fragment. targets = "/".join( ("%23" if mode == "groupchat" else "") + self.quote(jid, safe="") for (mode, jid) in self.targets ) return "{schema}://{auth}{host}{port}/{targets}?{params}".format( schema=schema, auth=auth, host=self.host, port=port_str, targets=targets, params=self.urlencode(params), ) def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Send a notification to one or more XMPP targets.""" default_port = SECURE_MODES[self.secure_mode]["default_port"] self.throttle() config = XMPPConfig( jid=self.jid, password=self.password or "", host=self.xmpp_host or self.host, port=self.port if self.port else default_port, secure=self.secure_mode, verify_certificate=self.verify_certificate, ) self.logger.debug( "XMPP init: jid=%s host=%s port=%d mode=%s " "verify_certificate=%s subject=%s roster=%s keepalive=%s " "targets=%s", self.jid, config.host, config.port, config.secure, config.verify_certificate, "yes" if self.subject else "no", "yes" if self.roster else "no", "yes" if self.keepalive else "no", self.targets, ) subject = title if self.subject else "" if self.keepalive and self._adapter: # Reuse existing adapter return self._adapter.send_message( targets=self.targets, subject=subject, body=body, ) adapter_kwargs = { "config": config, "targets": self.targets, "subject": subject, "body": body, "timeout": self.socket_connect_timeout, "roster": self.roster, "keepalive": self.keepalive, "want_muc": self.want_muc, "default_nickname": self.name, } if not self.keepalive: # One-shot mode: Create, process, and discard return SlixmppAdapter(**adapter_kwargs).process() # Keepalive mode, reuse a single adapter instance self._adapter = SlixmppAdapter(**adapter_kwargs) return self._adapter.send_message() @property def title_maxlen(self) -> Optional[int]: """ Depending on if the subject field is set, we can control how the message is constructed. """ return 0 if not self.subject else super().title_maxlen @staticmethod def normalize_jid(value: str, default_host: str) -> tuple[str, bool]: """Normalize and validate a JID. Behaviour: - If value is 'user' then it becomes 'user@default_host'. - If value is 'user@host' then it becomes 'user@host'. - If value is 'user@host/resource' then it becomes 'user@host/resource'. - If value is 'user/resource' then it becomes 'user@default_host/resource'. - If value already contains '@', it is used as-is, including an optional '/resource' suffix. """ raw = (value or "").strip() results = IS_JID.match(raw) if not results: raise ValueError("Invalid JID") is_muc = bool(results.group("is_room")) host = results.group("domain") or default_host jid = f"{results.group('local')}@{host}" if results.group("resource"): jid = f"{jid}/{results.group('resource')}" return jid, is_muc @staticmethod def parse_url(url: str) -> Optional[dict[str, Any]]: """Parse an XMPP URL into constructor arguments.""" results = NotifyBase.parse_url(url) if not results: return None # Targets from path results["targets"] = [ NotifyXMPP.unquote(t) for t in NotifyXMPP.split_path(results.get("fullpath")) ] qd = results.get("qsd", {}) # Support to= alias if "to" in qd and qd.get("to"): results["targets"] += NotifyXMPP.parse_list( NotifyXMPP.unquote(qd.get("to")) ) 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() if "roster" in results["qsd"] and len(results["qsd"]["roster"]): results["roster"] = parse_bool(results["qsd"]["roster"]) if "subject" in results["qsd"] and len(results["qsd"]["subject"]): results["subject"] = parse_bool(results["qsd"]["subject"]) if "keepalive" in results["qsd"] and len(results["qsd"]["keepalive"]): results["keepalive"] = parse_bool(results["qsd"]["keepalive"]) if "name" in results["qsd"] and len(results["qsd"]["name"]): results["name"] = NotifyXMPP.unquote(results["qsd"]["name"]) if "xmpp" in results["qsd"] and len(results["qsd"]["xmpp"]): results["xmpp_host"] = NotifyXMPP.unquote(results["qsd"]["xmpp"]) return results @staticmethod def runtime_deps(): """Return a tuple of top-level Python package names that this plugin imported as optional runtime dependencies. """ return ("slixmpp",) apprise-1.10.0/apprise/plugins/xmpp/common.py000066400000000000000000000040621517341665700212270ustar00rootroot00000000000000# 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. """XMPP General/Shared Configuration""" class SecureXMPPMode: """ Defines our modes """ NONE = "none" TLS = "tls" STARTTLS = "starttls" SECURE_MODES = { SecureXMPPMode.STARTTLS: { "default_port": 5222, "enable_plaintext": False, "enable_starttls": True, "enable_direct_tls": False, }, SecureXMPPMode.TLS: { "default_port": 5223, "enable_plaintext": False, "enable_starttls": False, "enable_direct_tls": True, }, SecureXMPPMode.NONE: { "default_port": 5222, "enable_plaintext": True, "enable_starttls": False, "enable_direct_tls": False, }, } apprise-1.10.0/apprise/plugins/zulip.py000066400000000000000000000343301517341665700201170ustar00rootroot00000000000000# 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 ZulipChat bot defined; See here: # https://zulipchat.com/help/add-a-bot-or-integration # # At the time of writing this plugin the instructions were: # 1. From your desktop, click on the gear icon in the upper right corner. # 2. Select Settings. # 3. On the left, click Your bots. # 4. Click Add a new bot. # 5. Fill out the fields, and click Create bot. # If you know your organization {ID} (as it's part of the zulipchat.com url # after you signup, then you can also access your bot information by visting: # https://ID.zulipchat.com/#settings/your-bots # For example, I create an organization called apprise. Thus my URL would be # https://apprise.zulipchat.com/#settings/your-bots # When you're done and have a bot, it's important to remember the username # you provided the bot and the API key generated. # # If your {user} was : goober-bot@apprise.zulipchat.com # and your {apikey} was: lqn6mpwpam6VZzbCW0o7olmk3hwbQSK # # Then the following URLs would be accepted by Apprise: # - zulip://goober-bot@apprise.zulipchat.com/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK # - zulip://goober-bot@apprise/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK # - zulip://goober@apprise/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK # - zulip://goober@apprise.zulipchat.com/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK # The API reference used to build this plugin was documented here: # https://zulipchat.com/api/send-message # import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_list, validate_regex from .base import NotifyBase # A Valid Bot Name VALIDATE_BOTNAME = re.compile(r"(?P[A-Z0-9_-]{1,32})", re.I) # Organization required as part of the API request VALIDATE_ORG = re.compile( r"(?P[A-Z0-9_-]{1,32})(\.(?P[^\s]+))?", re.I ) # Extend HTTP Error Messages ZULIP_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", } # Used to break path apart into list of streams TARGET_LIST_DELIM = re.compile(r"[ \t\r\n,#\\/]+") # Used to detect a streams IS_VALID_TARGET_RE = re.compile(r"#?(?P[A-Z0-9_]{1,32})", re.I) class NotifyZulip(NotifyBase): """A wrapper for Zulip Notifications.""" # The default descriptive name associated with the Notification service_name = "Zulip" # The services URL service_url = "https://zulipchat.com/" # The default secure protocol secure_protocol = "zulip" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/zulip/" # Zulip uses the http protocol with JSON requests notify_url = "https://{org}.{hostname}/api/v1/messages" # The maximum allowable characters allowed in the title per message title_maxlen = 60 # The maximum allowable characters allowed in the body per message body_maxlen = 10000 # Define object templates templates = ( "{schema}://{botname}@{organization}/{token}", "{schema}://{botname}@{organization}/{token}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "botname": { "name": _("Bot Name"), "type": "string", "regex": (r"^[A-Z0-9_-]{1,32}$", "i"), "required": True, }, "organization": { "name": _("Organization"), "type": "string", "required": True, "regex": (r"^[A-Z0-9_-]{1,32})$", "i"), }, "token": { "name": _("Token"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9]{32}$", "i"), }, "target_user": { "name": _("Target User"), "type": "string", "map_to": "targets", }, "target_stream": { "name": _("Target Stream"), "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", }, }, ) # The default hostname to append to a defined organization # if one isn't defined in the apprise url default_hostname = "zulipchat.com" # The default stream to notify if no targets are specified default_notification_stream = "general" def __init__(self, botname, organization, token, targets=None, **kwargs): """Initialize Zulip Object.""" super().__init__(**kwargs) # our default hostname self.hostname = self.default_hostname try: match = VALIDATE_BOTNAME.match(botname.strip()) if not match: # let outer exception handle this raise TypeError # The botname botname = match.group("name") suffix = "-bot" # Eliminate suffix if found botname = ( botname[: -len(suffix)] if botname.endswith(suffix) else botname ) self.botname = botname except (TypeError, AttributeError) as err: msg = f"The Zulip botname specified ({botname}) is invalid." self.logger.warning(msg) raise TypeError(msg) from err try: match = VALIDATE_ORG.match(organization.strip()) if not match: # let outer exception handle this raise TypeError # The organization self.organization = match.group("org") if match.group("hostname"): self.hostname = match.group("hostname") except (TypeError, AttributeError) as err: msg = ( "The Zulip organization specified " f"({organization}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) from err self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The Zulip token specified ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.targets = parse_list(targets) if len(self.targets) == 0: # No streams identified, use default self.targets.append(self.default_notification_stream) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Zulip Notification.""" headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", } # error tracking (used for function return) has_error = False # Prepare our notification URL url = self.notify_url.format( org=self.organization, hostname=self.hostname, ) # prepare JSON Object payload = { "subject": title, "content": body, } # Determine Authentication auth = ( f"{self.botname}-bot@{self.organization}.{self.hostname}", self.token, ) # Create a copy of the target list targets = list(self.targets) while len(targets): target = targets.pop(0) result = is_email(target) if result: # Send a private message payload["type"] = "private" else: # Send a stream message payload["type"] = "stream" # Set our target payload["to"] = target if not result else result["full_email"] self.logger.debug( f"Zulip POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Zulip Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=payload, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyZulip.http_response_code_lookup( r.status_code, ZULIP_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Zulip 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 Zulip notification to {target}.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Zulip " f"notification to {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.organization, self.hostname, self.token, ) 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) # simplify our organization in our URL if we can organization = "{}{}".format( self.organization, ( f".{self.hostname}" if self.hostname != self.default_hostname else "" ), ) return "{schema}://{botname}@{org}/{token}/{targets}?{params}".format( schema=self.secure_protocol, botname=NotifyZulip.quote(self.botname, safe=""), org=NotifyZulip.quote(organization, safe=""), token=self.pprint(self.token, privacy, safe=""), targets="/".join( [NotifyZulip.quote(x, safe="") for x in self.targets] ), params=NotifyZulip.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, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The botname results["botname"] = NotifyZulip.unquote(results["user"]) # The organization is stored in the hostname results["organization"] = NotifyZulip.unquote(results["host"]) # Store our targets results["targets"] = NotifyZulip.split_path(results["fullpath"]) if "token" in results["qsd"] and len(results["qsd"]["token"]): # Store our token if specified results["token"] = NotifyZulip.unquote(results["qsd"]["token"]) elif results["targets"]: # First item is the token results["token"] = results["targets"].pop(0) else: # no token results["token"] = None # 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"] += list( filter( bool, TARGET_LIST_DELIM.split( NotifyZulip.unquote(results["qsd"]["to"]) ), ) ) return results apprise-1.10.0/apprise/py.typed000066400000000000000000000000001517341665700164030ustar00rootroot00000000000000apprise-1.10.0/apprise/url.py000066400000000000000000001053611517341665700161000ustar00rootroot00000000000000# 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 import hashlib import re import sys import time from urllib.parse import quote as _quote, unquote as _unquote from xml.sax.saxutils import escape as sax_escape from .asset import AppriseAsset from .locale import gettext_lazy as _ from .logger import logger from .utils.parse import ( parse_bool, parse_list, parse_phone_no, parse_url, urlencode, ) # Used to break a path list into parts PATHSPLIT_LIST_DELIM = re.compile(r"[ \t\r\n,\\/]+") class PrivacyMode: # Defines different privacy modes strings can be printed as # Astrisk sets 4 of them: e.g. **** # This is used for passwords Secret = "*" # Outer takes the first and last character displaying them with # 3 dots between. Hence, 'i-am-a-token' would become 'i...n' Outer = "o" # Displays the last four characters Tail = "t" # Define the HTML Lookup Table HTML_LOOKUP = { 400: "Bad Request - Unsupported Parameters.", 401: "Verification Failed.", 404: "Page not found.", 405: "Method not allowed.", 500: "Internal server error.", 503: "Servers are overloaded.", } class URLBase: """This is the base class for all URL Manipulation.""" # The default descriptive name associated with the URL service_name = None # The default simple (insecure) protocol # all inheriting entries must provide their protocol lookup # protocol:// (in this example they would specify 'protocol') protocol = None # The default secure protocol # all inheriting entries must provide their protocol lookup # protocols:// (in this example they would specify 'protocols') # This value can be the same as the defined protocol. secure_protocol = None # Throttle request_rate_per_sec = 0 # The connect timeout is the number of seconds Requests will wait for your # client to establish a connection to a remote machine (corresponding to # the connect()) call on the socket. socket_connect_timeout = 4.0 # The read timeout is the number of seconds the client will wait for the # server to send a response. socket_read_timeout = 4.0 # provide the information required to allow for unique id generation when # calling url_id(). Over-ride this in calling classes. Calling classes # should set this to false if there can be no url_id generated url_identifier = None # Tracks the last generated url_id() to prevent regeneration; initializes # to False and is set thereafter. This is an internal value for this class # only and should not be set to anything other then False below... __cached_url_identifier = False # Handle # Maintain a set of tags to associate with this specific notification tags = set() # Secure sites should be verified against a Certificate Authority verify_certificate = True # Logging to our global logger logger = logger # Define a default set of template arguments used for dynamically building # details about our individual plugins for developers. # Define object templates templates = () # Provides a mapping of tokens, certain entries are fixed and automatically # configured if found (such as schema, host, user, pass, and port) template_tokens = {} # Here is where we define all of the arguments we accept on the url # such as: schema://whatever/?cto=5.0&rto=15 # 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 = { "verify": { "name": _("Verify SSL"), # SSL Certificate Authority Verification "type": "bool", # Provide a default "default": verify_certificate, # look up default using the following parent class value at # runtime. "_lookup_default": "verify_certificate", }, "rto": { "name": _("Socket Read Timeout"), "type": "float", # Provide a default "default": socket_read_timeout, # look up default using the following parent class value at # runtime. The variable name identified here (in this case # socket_read_timeout) 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": "socket_read_timeout", }, "cto": { "name": _("Socket Connect Timeout"), "type": "float", # Provide a default "default": socket_connect_timeout, # look up default using the following parent class value at # runtime. The variable name identified here (in this case # socket_connect_timeout) 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": "socket_connect_timeout", }, } # kwargs are dynamically built because a prefix causes us to parse the # content slightly differently. The prefix is required and can be either # a (+ or -). Below would handle the +key=value: # { # 'headers': { # 'name': _('HTTP Header'), # 'prefix': '+', # 'type': 'string', # }, # }, # # In a kwarg situation, the 'key' is always presumed to be treated as # a string. When the 'type' is defined, it is being defined to respect # the 'value'. template_kwargs = {} # Internal Values def __init__(self, asset=None, **kwargs): """Initialize some general logging and common server arguments that will keep things consistent when working with the children that inherit this class.""" # Prepare our Asset Object self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) # Certificate Verification (for SSL calls); default to being enabled self.verify_certificate = parse_bool( kwargs.get("verify", URLBase.verify_certificate) ) # Schema self.schema = kwargs.get("schema", "unknown").lower() # Secure Mode self.secure = kwargs.get("secure") if not isinstance(self.secure, bool): # Attempt to detect self.secure = self.schema[-1:] == "s" self.host = URLBase.unquote(kwargs.get("host")) self.port = kwargs.get("port") if self.port: try: self.port = int(self.port) except (TypeError, ValueError): self.logger.warning( f"Invalid port number specified {self.port}" ) self.port = None self.user = kwargs.get("user") if self.user: # Always unquote user if it exists self.user = URLBase.unquote(self.user) self.password = kwargs.get("password") if self.password: # Always unquote the password if it exists self.password = URLBase.unquote(self.password) # Store our full path consistently ensuring it ends with a `/' self.fullpath = URLBase.unquote(kwargs.get("fullpath")) if not isinstance(self.fullpath, str) or not self.fullpath: self.fullpath = "/" # Store our Timeout Variables if "rto" in kwargs: try: self.socket_read_timeout = float(kwargs.get("rto")) except (TypeError, ValueError): self.logger.warning( "Invalid socket read timeout (rto)" " was specified {}".format(kwargs.get("rto")) ) if "cto" in kwargs: try: self.socket_connect_timeout = float(kwargs.get("cto")) except (TypeError, ValueError): self.logger.warning( "Invalid socket connect timeout (cto)" " was specified {}".format(kwargs.get("cto")) ) if "tag" in kwargs: # We want to associate some tags with our notification service. # the code below gets the 'tag' argument if defined, otherwise # it just falls back to whatever was already defined globally self.tags = set(parse_list(kwargs.get("tag"), self.tags)) # Tracks the time any i/o was made to the remote server. This value # is automatically set and controlled through the throttle() call. self._last_io_datetime = None def throttle(self, last_io=None, wait=None): """A common throttle control. if a wait is specified, then it will force a sleep of the specified time if it is larger then the calculated throttle time. """ if last_io is not None: # Assume specified last_io self._last_io_datetime = last_io # Get ourselves a reference time of 'now' reference = datetime.now() if self._last_io_datetime is None: # Set time to 'now' and no need to throttle self._last_io_datetime = reference return if self.request_rate_per_sec <= 0.0 and not wait: # We're done if there is no throttle limit set return # If we reach here, we need to do additional logic. # If the difference between the reference time and 'now' is less than # the defined request_rate_per_sec then we need to throttle for the # remaining balance of this time. elapsed = (reference - self._last_io_datetime).total_seconds() if wait is not None: self.logger.debug(f"Throttling forced for {wait}s...") time.sleep(wait) elif elapsed < self.request_rate_per_sec: self.logger.debug( f"Throttling for {self.request_rate_per_sec - elapsed}s..." ) time.sleep(self.request_rate_per_sec - elapsed) # Update our timestamp before we leave self._last_io_datetime = datetime.now() return def url(self, privacy=False, *args, **kwargs): """Assembles the URL associated with the notification based on the arguments provied.""" # Our default parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=URLBase.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=URLBase.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}{params}".format( schema="https" if self.secure else "http", 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=( URLBase.quote(self.fullpath, safe="/") if self.fullpath else "/" ), params=("?" + URLBase.urlencode(params) if params else ""), ) def url_id(self, lazy=True, hash_engine=hashlib.sha256): """Returns a unique URL identifier that representing the Apprise URL itself. The url_id is always a hash string or None if it can't be generated. The idea is to only build the ID based on the credentials or specific elements relative to the URL itself. The URL ID should never factor in (or else it's a bug) the following: - any targets defined - all GET parameters options unless they explicitly change the complete function of the code. For example: GET parameters like ?image=false&avatar=no should have no bearing in the uniqueness of the Apprise URL Identifier. Consider plugins where some get parameters completely change how the entire upstream comunication works such as slack:// and matrix:// which has a mode. In these circumstances, they should be considered in he unique generation. The intention of this function is to help align Apprise URLs that are common with one another and therefore can share the same persistent storage even when subtle changes are made to them. Hence the following would all return the same URL Identifier: json://abc/def/ghi?image=no json://abc/def/ghi/?test=yes&image=yes """ if lazy and self.__cached_url_identifier is not False: return ( self.__cached_url_identifier if not ( self.__cached_url_identifier and self.asset.storage_idlen ) else self.__cached_url_identifier[: self.asset.storage_idlen] ) # Python v3.9 introduces usedforsecurity argument kwargs = ( {"usedforsecurity": False} if sys.version_info >= (3, 9) else {} ) if self.url_identifier is False: # Disabled self.__cached_url_identifier = None elif self.url_identifier in (None, True): # Prepare our object engine = hash_engine( self.asset.storage_salt + self.schema.encode(self.asset.encoding), **kwargs, ) # We want to treat `None` differently then a blank entry engine.update( b"\0" if self.password is None else self.password.encode(self.asset.encoding) ) engine.update( b"\0" if self.user is None else self.user.encode(self.asset.encoding) ) engine.update( b"\0" if not self.host else self.host.encode(self.asset.encoding) ) engine.update( b"\0" if self.port is None else f"{self.port}".encode(self.asset.encoding) ) engine.update( self.fullpath.rstrip("/").encode(self.asset.encoding) ) engine.update(b"s" if self.secure else b"i") # Save our generated content self.__cached_url_identifier = engine.hexdigest() elif isinstance(self.url_identifier, str): self.__cached_url_identifier = hash_engine( self.asset.storage_salt + self.url_identifier.encode(self.asset.encoding), **kwargs, ).hexdigest() elif isinstance(self.url_identifier, bytes): self.__cached_url_identifier = hash_engine( self.asset.storage_salt + self.url_identifier, **kwargs ).hexdigest() elif isinstance(self.url_identifier, (list, tuple, set)): self.__cached_url_identifier = hash_engine( self.asset.storage_salt + b"".join( [ ( x if isinstance(x, bytes) else str(x).encode(self.asset.encoding) ) for x in self.url_identifier ] ), **kwargs, ).hexdigest() elif isinstance(self.url_identifier, dict): self.__cached_url_identifier = hash_engine( self.asset.storage_salt + b"".join( [ ( x if isinstance(x, bytes) else str(x).encode(self.asset.encoding) ) for x in self.url_identifier.values() ] ), **kwargs, ).hexdigest() else: self.__cached_url_identifier = hash_engine( self.asset.storage_salt + str(self.url_identifier).encode(self.asset.encoding), **kwargs, ).hexdigest() return ( self.__cached_url_identifier if not (self.__cached_url_identifier and self.asset.storage_idlen) else self.__cached_url_identifier[: self.asset.storage_idlen] ) def __contains__(self, tags): """Returns true if the tag specified is associated with this notification. tag can also be a tuple, set, and/or list """ if isinstance(tags, (tuple, set, list)): return bool(set(tags) & self.tags) # return any match return tags in self.tags def __str__(self): """Returns the url path.""" return self.url(privacy=True) @staticmethod def escape_html(html, convert_new_lines=False, whitespace=True): """Takes html text as input and escapes it so that it won't conflict with any xml/html wrapping characters. Args: html (str): The HTML code to escape convert_new_lines (:obj:`bool`, optional): escape new lines (\n) whitespace (:obj:`bool`, optional): escape whitespace Returns: str: The escaped html """ if not isinstance(html, str) or not html: return "" # Escape HTML escaped = sax_escape(html, {"'": "'", '"': """}) if whitespace: # Tidy up whitespace too escaped = escaped.replace("\t", " ").replace(" ", " ") if convert_new_lines: return escaped.replace("\n", "
") return escaped @staticmethod def unquote(content, encoding="utf-8", errors="replace"): """Replace %xx escapes by their single-character equivalent. The optional encoding and errors parameters specify how to decode percent- encoded sequences. Wrapper to Python's `unquote` while remaining compatible with both Python 2 & 3 since the reference to this function changed between versions. Note: errors set to 'replace' means that invalid sequences are replaced by a placeholder character. Args: content (str): The quoted URI string you wish to unquote encoding (:obj:`str`, optional): encoding type errors (:obj:`str`, errors): how to handle invalid character found in encoded string (defined by encoding) Returns: str: The unquoted URI string """ if not content: return "" return _unquote(content, encoding=encoding, errors=errors) @staticmethod def quote(content, safe="/", encoding=None, errors=None): """Replaces single character non-ascii characters and URI specific ones by their %xx code. Wrapper to Python's `quote` while remaining compatible with both Python 2 & 3 since the reference to this function changed between versions. Args: content (str): The URI string you wish to quote safe (str): non-ascii characters and URI specific ones that you do not wish to escape (if detected). Setting this string to an empty one causes everything to be escaped. encoding (:obj:`str`, optional): encoding type errors (:obj:`str`, errors): how to handle invalid character found in encoded string (defined by encoding) Returns: str: The quoted URI string """ if not content: return "" return _quote(content, safe=safe, encoding=encoding, errors=errors) @staticmethod def pprint( content, privacy=True, mode=PrivacyMode.Outer, # privacy print; quoting is ignored when privacy is set to True quote=True, safe="/", encoding=None, errors=None, ): """Privacy Print is used to mainpulate the string before passing it into part of the URL. It is used to mask/hide private details such as tokens, passwords, apikeys, etc from on-lookers. If the privacy=False is set, then the quote variable is the next flag checked. Quoting is never done if the privacy flag is set to true to avoid skewing the expected output. """ if not privacy: if quote: # Return quoted string if specified to do so return URLBase.quote( content, safe=safe, encoding=encoding, errors=errors ) # Return content 'as-is' return content if mode is PrivacyMode.Secret: # Return 4 Asterisks return "****" if not isinstance(content, str) or not content: # Nothing more to do return "" if mode is PrivacyMode.Tail: # Return the trailing 4 characters return f"...{content[-4:]}" # Default mode is Outer Mode return f"{content[0:1]}...{content[-1:]}" @staticmethod def urlencode(query, doseq=False, safe="", encoding=None, errors=None): """Convert a mapping object or a sequence of two-element tuples. Wrapper to Python's `urlencode` while remaining compatible with both Python 2 & 3 since the reference to this function changed between versions. The resulting string is a series of key=value pairs separated by '&' characters, where both key and value are quoted using the quote() function. Note: If the dictionary entry contains an entry that is set to None it is not included in the final result set. If you want to pass in an empty variable, set it to an empty string. Args: query (str): The dictionary to encode doseq (:obj:`bool`, optional): Handle sequences safe (:obj:`str`): non-ascii characters and URI specific ones that you do not wish to escape (if detected). Setting this string to an empty one causes everything to be escaped. encoding (:obj:`str`, optional): encoding type errors (:obj:`str`, errors): how to handle invalid character found in encoded string (defined by encoding) Returns: str: The escaped parameters returned as a string """ return urlencode( query, doseq=doseq, safe=safe, encoding=encoding, errors=errors ) @staticmethod def split_path(path, unquote=True): """Splits a URL up into a list object. Parses a specified URL and breaks it into a list. Args: path (str): The path to split up into a list. unquote (:obj:`bool`, optional): call unquote on each element added to the returned list. Returns: list: A list containing all of the elements in the path """ try: paths = PATHSPLIT_LIST_DELIM.split(path.lstrip("/")) if unquote: paths = [URLBase.unquote(x) for x in filter(bool, paths)] except AttributeError: # path is not useable, we still want to gracefully return an # empty list paths = [] return paths @staticmethod def parse_list(content, allow_whitespace=True, unquote=True): """A wrapper to utils.parse_list() with unquoting support. Parses a specified set of data and breaks it into a list. Args: content (str): The path to split up into a list. If a list is provided, then it's individual entries are processed. allow_whitespace (:obj:`bool`, optional): whitespace is to be treated as a delimiter unquote (:obj:`bool`, optional): call unquote on each element added to the returned list. Returns: list: A unique list containing all of the elements in the path """ content = parse_list(content, allow_whitespace=allow_whitespace) if unquote: content = [URLBase.unquote(x) for x in filter(bool, content)] return content @staticmethod def parse_phone_no(content, unquote=True, prefix=False): """A wrapper to utils.parse_phone_no() with unquoting support. Parses a specified set of data and breaks it into a list. Args: content (str): The path to split up into a list. If a list is provided, then it's individual entries are processed. unquote (:obj:`bool`, optional): call unquote on each element added to the returned list. Returns: list: A unique list containing all of the elements in the path """ if unquote: try: content = URLBase.unquote(content) except TypeError: # Nothing further to do return [] content = parse_phone_no(content, prefix=prefix) return content @property def app_id(self): return self.asset.app_id if self.asset.app_id else "" @property def app_desc(self): return self.asset.app_desc if self.asset.app_desc else "" @property def app_url(self): return self.asset.app_url if self.asset.app_url else "" @property def request_timeout(self): """This is primarily used to fullfill the `timeout` keyword argument that is used by requests.get() and requests.put() calls.""" return (self.socket_connect_timeout, self.socket_read_timeout) @property def request_auth(self): """This is primarily used to fullfill the `auth` keyword argument that is used by requests.get() and requests.put() calls.""" return (self.user, self.password) if self.user else None @property def request_url(self): """Assemble a simple URL that can be used by the requests library.""" # Acquire our schema schema = "https" if self.secure else "http" # Prepare our URL url = f"{schema}://{self.host}" # Apply Port information if present if isinstance(self.port, int): url += f":{self.port}" # Append our full path return url + self.fullpath def url_parameters(self, *args, **kwargs): """Provides a default set of args to work with. This can greatly simplify URL construction in the acommpanied url() function. The following property returns a dictionary (of strings) containing all of the parameters that can be set on a URL and managed through this class. """ # parameters are only provided on demand to keep the URL short params = {} # The socket read timeout if self.socket_read_timeout != URLBase.socket_read_timeout: params["rto"] = str(self.socket_read_timeout) # The request/socket connect timeout if self.socket_connect_timeout != URLBase.socket_connect_timeout: params["cto"] = str(self.socket_connect_timeout) # Certificate verification if self.verify_certificate != URLBase.verify_certificate: params["verify"] = "yes" if self.verify_certificate else "no" return params @staticmethod def post_process_parse_url_results(results): """After parsing the URL, this function applies a bit of extra logic to support extra entries like `pass` becoming `password`, etc. This function assumes that parse_url() was called previously setting up the basics to be checked """ # if our URL ends with an 's', then assume our secure flag is set. results["secure"] = results["schema"][-1] == "s" # QSD Checking (over-rides all) qsd_exists = bool(isinstance(results.get("qsd"), dict)) if qsd_exists and "verify" in results["qsd"]: # Pulled from URL String results["verify"] = parse_bool(results["qsd"].get("verify", True)) elif "verify" in results: # Pulled from YAML Configuratoin results["verify"] = parse_bool(results.get("verify", True)) else: # Support SSL Certificate 'verify' keyword. Default to being # enabled results["verify"] = True # Password overrides if "pass" in results: results["password"] = results["pass"] del results["pass"] if qsd_exists: if "password" in results["qsd"]: results["password"] = results["qsd"]["password"] if "pass" in results["qsd"]: results["password"] = results["qsd"]["pass"] # User overrides if "user" in results["qsd"]: results["user"] = results["qsd"]["user"] # parse_url() always creates a 'password' and 'user' entry in the # results returned. Entries are set to None if they weren't # specified if results["password"] is None and "user" in results["qsd"]: # Handle cases where the user= provided in 2 locations, we want # the original to fall back as a being a password (if one # wasn't otherwise defined) e.g. # mailtos://PASSWORD@hostname?user=admin@mail-domain.com # - in the above, the PASSWORD gets lost in the parse url() # since a user= over-ride is specified. presults = parse_url(results["url"]) if presults: # Store our Password results["password"] = presults["user"] # Store our socket read timeout if specified if "rto" in results["qsd"]: results["rto"] = results["qsd"]["rto"] # Store our socket connect timeout if specified if "cto" in results["qsd"]: results["cto"] = results["qsd"]["cto"] if "port" in results["qsd"]: results["port"] = results["qsd"]["port"] return results @staticmethod def parse_url( url, verify_host=True, plus_to_space=False, strict_port=False, 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 = parse_url( url, default_schema="unknown", verify_host=verify_host, plus_to_space=plus_to_space, strict_port=strict_port, sanitize=sanitize, ) if not results: # We're done; we failed to parse our url return results return URLBase.post_process_parse_url_results(results) @staticmethod def http_response_code_lookup(code, response_mask=None): """Parses the interger response code returned by a remote call from a web request into it's human readable string version. You can over-ride codes or add new ones by providing your own response_mask that contains a dictionary of integer -> string mapped variables """ if isinstance(response_mask, dict): # Apply any/all header over-rides defined HTML_LOOKUP.update(response_mask) # Look up our response try: response = HTML_LOOKUP[code] except KeyError: response = "" return response def __len__(self): """Should be over-ridden and allows the tracking of how many targets are associated with each URLBase object. Default is always 1 """ return 1 def schemas(self): """A simple function that returns a set of all schemas associated with this object based on the object.protocol and object.secure_protocol.""" schemas = set() for key in ("protocol", "secure_protocol"): schema = getattr(self, key, None) if isinstance(schema, str): schemas.add(schema) elif isinstance(schema, (set, list, tuple)): # Support iterables list types for s in schema: if isinstance(s, str): schemas.add(s) return schemas apprise-1.10.0/apprise/utils/000077500000000000000000000000001517341665700160565ustar00rootroot00000000000000apprise-1.10.0/apprise/utils/__init__.py000066400000000000000000000025761517341665700202010ustar00rootroot00000000000000# 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. apprise-1.10.0/apprise/utils/base64.py000066400000000000000000000072731517341665700175250ustar00rootroot00000000000000# 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 copy import json def base64_urlencode(data: bytes) -> str: """URL Safe Base64 Encoding.""" try: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") except TypeError: # data is not supported; avoid raising exception return None def base64_urldecode(data: str) -> bytes: """URL Safe Base64 Encoding.""" try: # Normalize base64url string (remove padding, add it back) padding = "=" * (-len(data) % 4) return base64.urlsafe_b64decode(data + padding) except TypeError: # data is not supported; avoid raising exception return None def decode_b64_dict(di: dict) -> dict: """Decodes base64 dictionary previously encoded. string entries prefixed with `b64:` are targeted """ di = copy.deepcopy(di) for k, v in di.items(): if not isinstance(v, str) or not v.startswith("b64:"): continue try: parsed_v = base64.b64decode(v[4:]) parsed_v = json.loads(parsed_v) except ( ValueError, TypeError, binascii.Error, json.decoder.JSONDecodeError, ): # ValueError: the length of altchars is not 2. # TypeError: invalid input # binascii.Error: not base64 (bad padding) # json.decoder.JSONDecodeError: Bad JSON object parsed_v = v di[k] = parsed_v return di def encode_b64_dict(di: dict, encoding="utf-8") -> tuple[dict, bool]: """Encodes dictionary entries containing binary types (int, float) into base64. Final product is always string based values """ di = copy.deepcopy(di) needs_decoding = False for k, v in di.items(): if isinstance(v, str): continue try: encoded = base64.urlsafe_b64encode(json.dumps(v).encode(encoding)) encoded = f"b64:{encoded.decode(encoding)}" needs_decoding = True except (ValueError, TypeError): # ValueError: # - the length of altchars is not 2. # TypeError: # - json not searializable or # - bytes object not passed into urlsafe_b64encode() encoded = str(v) di[k] = encoded return di, needs_decoding apprise-1.10.0/apprise/utils/cwe312.py000066400000000000000000000173531517341665700174450ustar00rootroot00000000000000# 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 .parse import is_hostname, parse_url def cwe312_word(word, force=False, advanced=True, threshold=5): """This function was written to help mask secure/private information that may or may not be found within Apprise. The idea is to provide a presentable word response that the user who prepared it would understand, yet not reveal any private information for any potential intruder. For more detail see CWE-312 @ https://cwe.mitre.org/data/definitions/312.html The `force` is an optional argument used to keep the string formatting consistent and in one place. If set, the content passed in is presumed to be containing secret information and will be updated accordingly. If advanced is set to `True` then content is additionally checked for upper/lower/ascii/numerical variances. If an obscurity threshold is reached, then content is considered secret """ class Variance: """A Simple List of Possible Character Variances.""" # An Upper Case Character (ABCDEF... etc) ALPHA_UPPER = "+" # An Lower Case Character (abcdef... etc) ALPHA_LOWER = "-" # A Special Character ($%^;... etc) SPECIAL = "s" # A Numerical Character (1234... etc) NUMERIC = "n" if not (isinstance(word, str) and word.strip()): # not a password if it's not something we even support return word # Formatting word = word.strip() if force: # We're forcing the representation to be a secret # We do this for consistency return f"{word[0:1]}...{word[-1:]}" elif len(word) > 1 and not is_hostname( word, ipv4=True, ipv6=True, underscore=False ): # Verify if it is a hostname or not return f"{word[0:1]}...{word[-1:]}" elif len(word) >= 16: # an IP will be 15 characters so we don't want to use a smaller # value then 16 (e.g 101.102.103.104) # we can assume very long words are passwords otherwise return f"{word[0:1]}...{word[-1:]}" if advanced: # # Mark word a secret based on it's obscurity # # Our variances will increase depending on these variables: last_variance = None obscurity = 0 for c in word: # Detect our variance if c.isdigit(): variance = Variance.NUMERIC elif c.isalpha() and c.isupper(): variance = Variance.ALPHA_UPPER elif c.isalpha() and c.islower(): variance = Variance.ALPHA_LOWER else: variance = Variance.SPECIAL if last_variance != variance or variance == Variance.SPECIAL: obscurity += 1 if obscurity >= threshold: return f"{word[0:1]}...{word[-1:]}" last_variance = variance # Otherwise we're good; return our word return word def cwe312_url(url): """This function was written to help mask secure/private information that may or may not be found on an Apprise URL. The idea is to not disrupt the structure of the previous URL too much, yet still protect the users private information from being logged directly to screen. For more detail see CWE-312 @ https://cwe.mitre.org/data/definitions/312.html For example, consider the URL: http://user:password@localhost/ When passed into this function, the return value would be: http://user:****@localhost/ Since apprise allows you to put private information everywhere in it's custom URLs, it uses this function to manipulate the content before returning to any kind of logger. The idea is that the URL can still be interpreted by the person who constructed them, but not to an intruder. """ # Parse our URL results = parse_url(url) if not results: # Nothing was returned (invalid data was fed in); return our # information as it was fed to us (without changing it) return url # Update our URL with values results["password"] = cwe312_word(results["password"], force=True) if not results["schema"].startswith("http"): results["user"] = cwe312_word(results["user"]) results["host"] = cwe312_word(results["host"]) else: results["host"] = cwe312_word(results["host"], advanced=False) results["user"] = cwe312_word(results["user"], advanced=False) # Apply our full path scan in all cases results["fullpath"] = ( "/" + "/".join( [ cwe312_word(x) for x in re.split(r"[\\/]+", results["fullpath"].lstrip("/")) ] ) if results["fullpath"] else "" ) # # Now re-assemble our URL for display purposes # # Determine Authentication auth = "" if results["user"] and results["password"]: auth = "{user}:{password}@".format( user=results["user"], password=results["password"], ) elif results["user"]: auth = "{user}@".format( user=results["user"], ) params = "" if results["qsd"]: params = "?{}".format( "&".join( [ "{}={}".format( k, cwe312_word( v, force=( k in ( "password", "secret", "pass", "token", "key", "id", "apikey", "to", ) ), ), ) for k, v in results["qsd"].items() ] ) ) return "{schema}://{auth}{hostname}{port}{fullpath}{params}".format( schema=results["schema"], auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=results["host"], port="" if not results["port"] else ":{}".format(results["port"]), fullpath=results["fullpath"] if results["fullpath"] else "", params=params, ) apprise-1.10.0/apprise/utils/disk.py000066400000000000000000000130331517341665700173620ustar00rootroot00000000000000# 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 from os.path import expanduser import platform import re from ..logger import logger # Pre-Escape content since we reference it so much ESCAPED_PATH_SEPARATOR = re.escape("\\/") ESCAPED_WIN_PATH_SEPARATOR = re.escape("\\") ESCAPED_NUX_PATH_SEPARATOR = re.escape("/") TIDY_WIN_PATH_RE = re.compile( rf"(^[{ESCAPED_WIN_PATH_SEPARATOR}]{{2}}|[^{ESCAPED_WIN_PATH_SEPARATOR}\s][{ESCAPED_WIN_PATH_SEPARATOR}]|[\s][{ESCAPED_WIN_PATH_SEPARATOR}]{{2}}])([{ESCAPED_WIN_PATH_SEPARATOR}]+)", ) TIDY_WIN_TRIM_RE = re.compile( rf"^(.+[^:][^{ESCAPED_WIN_PATH_SEPARATOR}])[\s{ESCAPED_WIN_PATH_SEPARATOR}]*$", ) TIDY_NUX_PATH_RE = re.compile( rf"([{ESCAPED_NUX_PATH_SEPARATOR}])([{ESCAPED_NUX_PATH_SEPARATOR}]+)", ) # A simple path decoder we can re-use which looks after # ensuring our file info is expanded correctly when provided # a path. __PATH_DECODER = ( os.path.expandvars if platform.system() == "Windows" else os.path.expanduser ) def path_decode(path): """Returns the fully decoded path based on the operating system.""" return os.path.abspath(__PATH_DECODER(path)) def tidy_path(path): """Take a filename and or directory and attempts to tidy it up by removing trailing slashes and correcting any formatting issues. For example: ////absolute//path// becomes: /absolute/path """ # Windows path = TIDY_WIN_PATH_RE.sub("\\1", path.strip()) # Linux path = TIDY_NUX_PATH_RE.sub("\\1", path) # Windows Based (final) Trim path = expanduser(TIDY_WIN_TRIM_RE.sub("\\1", path)) return path def dir_size(path, max_depth=3, missing_okay=True, _depth=0, _errors=None): """Scans a provided path an returns it's size (in bytes) of path provided.""" if _errors is None: _errors = set() if _depth > max_depth: _errors.add(path) return (0, _errors) total = 0 try: with os.scandir(path) as it: for entry in it: try: if entry.is_file(follow_symlinks=False): total += entry.stat(follow_symlinks=False).st_size elif entry.is_dir(follow_symlinks=False): (totals, _) = dir_size( entry.path, max_depth=max_depth, _depth=_depth + 1, _errors=_errors, ) total += totals except FileNotFoundError: # no worries; Nothing to do continue except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point _errors.add(entry.path) logger.warning( "dir_size detetcted inaccessible path: %s", os.fsdecode(entry.path), ) logger.debug(f"dir_size Exception: {e!s}") continue except FileNotFoundError: if not missing_okay: # Conditional error situation _errors.add(path) except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point _errors.add(path) logger.warning( "dir_size detetcted inaccessible path: %s", os.fsdecode(path) ) logger.debug(f"dir_size Exception: {e!s}") return (total, _errors) def bytes_to_str(value): """Covert an integer (in bytes) into it's string representation with acompanied unit value (such as B, KB, MB, GB, TB, etc)""" unit = "B" try: value = float(value) except (ValueError, TypeError): return None if value >= 1024.0: value = value / 1024.0 unit = "KB" if value >= 1024.0: value = value / 1024.0 unit = "MB" if value >= 1024.0: value = value / 1024.0 unit = "GB" if value >= 1024.0: value = value / 1024.0 unit = "TB" return f"{round(value, 2):.2f}{unit}" apprise-1.10.0/apprise/utils/format.py000066400000000000000000000154031517341665700177230ustar00rootroot00000000000000# 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 re from apprise.common import NotifyFormat # Characters we can apply a new line to if found PUNCTUATION_CHARS = ".!?:;" PUNCT_SPLIT_PATTERN = re.compile( f"[{re.escape(PUNCTUATION_CHARS)}][ \t\r\n\x0b\x0c]+" ) # Support HTML entities (&...;) HTML_ENTITY_LOOKBACK = 16 HTML_ENTITY_LOOKAHEAD = 16 # Support Markdown constructs (e.g., links, formatting) # Longer lookback for links [text](url) MARKDOWN_CONSTRUCT_LOOKBACK = 32 def html_adjust( text: str, window_start: int, split_at: int, ) -> int: """ Adjust the split point to avoid splitting inside short HTML entities such as ' '. If the split falls inside '&...;' within a small window around the boundary, move the split back to '&' so the entire entity is kept in the next chunk. """ if split_at <= window_start or split_at > len(text): return split_at search_start = max(window_start, split_at - HTML_ENTITY_LOOKBACK) search_end = split_at amp_index = text.rfind("&", search_start, search_end) if amp_index == -1: return split_at forward_end = min(len(text), split_at + HTML_ENTITY_LOOKAHEAD) semi_index = text.find(";", amp_index, forward_end) if ( semi_index != -1 and amp_index > window_start and amp_index < split_at <= semi_index ): return amp_index return split_at def markdown_adjust( text: str, window_start: int, split_at: int, ) -> int: """ Adjust the split point to avoid splitting inside simple Markdown link / image constructs like [Text](URL) or ![Alt](URL). This is a best-effort heuristic and does not attempt full Markdown parsing. If the boundary falls between '['/'!' and the closing ')' of a nearby link/image, move the split back to that start. """ if split_at <= window_start or split_at > len(text): return split_at search_start = max(window_start, split_at - MARKDOWN_CONSTRUCT_LOOKBACK) # Prefer '[' as the starting marker for links/images. link_start_idx = text.rfind("[", search_start, split_at) if link_start_idx == -1: # As a fallback, consider '!' as a possible start, e.g. '![Alt](...)'. link_start_idx = text.rfind("!", search_start, split_at) if link_start_idx == -1: return split_at # Look ahead for a closing ')' to bound the construct. forward_end = min(len(text), split_at + MARKDOWN_CONSTRUCT_LOOKBACK) link_end_idx = text.find(")", link_start_idx, forward_end) if link_end_idx != -1 and link_start_idx < split_at < link_end_idx: return link_start_idx return split_at def smart_split( text: str, limit: int, body_format: NotifyFormat, ) -> list[str]: """ Split `text` into chunks of at most `limit` characters. Soft split priority: 1. Last newline before `limit` (\\n or \\r) 2. Last space or tab before `limit` 3. Last punctuation+whitespace (.,!?:; followed by space/tab/newline) 4. Hard split at `limit` `body_format` controls additional safety rules: - NotifyFormat.TEXT: generic splitting only - NotifyFormat.HTML: avoid splitting inside '&...;' entities - NotifyFormat.MARKDOWN: same as HTML, plus a best-effort check to avoid splitting inside [Text](URL) / ![Alt](URL) patterns. """ if not text or limit <= 0: return [""] result: list[str] = [] start = 0 length = len(text) while start < length: # pragma: no branch remaining = length - start if remaining <= limit: result.append(text[start:]) break window_end = min(start + limit, length) # # Priority 1: Search for newline # last_nl_idx = max( text.rfind("\n", start, window_end), text.rfind("\r", start, window_end), ) split_nl = last_nl_idx + 1 if last_nl_idx != -1 else -1 # # Priority 2: Search for ending Space and/or Tab # last_space_tab_idx = max( text.rfind(" ", start, window_end), text.rfind("\t", start, window_end), ) split_space_tab = ( last_space_tab_idx + 1 if last_space_tab_idx != -1 else -1 ) # # Priority 3: Last punctuation + whitespace # split_punct = -1 for match in PUNCT_SPLIT_PATTERN.finditer(text, start, window_end): split_punct = match.end() # Determine the best soft split point if split_nl != -1: split_at = split_nl elif split_space_tab != -1: split_at = split_space_tab elif split_punct != -1: split_at = split_punct else: # # Priority 4: Hard split (old way of doing things) # split_at = window_end # # Conditional Content-specific adjustments # orig_split = split_at if body_format is NotifyFormat.HTML: split_at = html_adjust(text, start, split_at) elif body_format is NotifyFormat.MARKDOWN: # Markdown may also contain HTML entities. split_at = html_adjust(text, start, split_at) split_at = markdown_adjust(text, start, split_at) if split_at <= start: split_at = orig_split result.append(text[start:split_at]) start = split_at return result apprise-1.10.0/apprise/utils/json.py000066400000000000000000000042561517341665700174100ustar00rootroot00000000000000# 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 from datetime import datetime import json from ..common import AWARE_DATE_ISO_FORMAT, NAIVE_DATE_ISO_FORMAT from ..locale import LazyTranslation class AppriseJSONEncoder(json.JSONEncoder): """A JSON Encoder for handling Apprise internals.""" 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, bytes): return base64.b64encode(entry).decode("utf-8") elif isinstance(entry, (set, frozenset, tuple)): return list(entry) elif isinstance(entry, LazyTranslation): return str(entry) return super().default(entry) apprise-1.10.0/apprise/utils/logic.py000066400000000000000000000107421517341665700175310ustar00rootroot00000000000000# 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 from .. import common from .parse import parse_list def is_exclusive_match( logic, data, match_all=common.MATCH_ALL_TAG, match_always=common.MATCH_ALWAYS_TAG, ): """The data variable should always be a set of strings that the logic can be compared against. It should be a set. If it isn't already, then it will be converted as such. These identify the tags themselves. Our logic should be a list as well: - top level entries are treated as an 'or' - second level (or more) entries are treated as 'and' examples: logic="tagA, tagB" = tagA or tagB logic=['tagA', 'tagB'] = tagA or tagB logic=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB logic=[('tagB', 'tagC')] = tagB and tagC If `match_always` is not set to None, then its value is added as an 'or' to all specified logic searches. """ if isinstance(logic, str): # Update our logic to support our delimiters logic = set(parse_list(logic)) if not logic: # If there is no logic to apply then we're done early; we only match # if there is also no data to match against return not data if not isinstance(logic, (list, tuple, set)): # garbage input return False if match_always: # Add our match_always to our logic searching if secified logic = chain(logic, [match_always]) # Track what we match against; but by default we do not match # against anything matched = False # Every entry here will be or'ed with the next for entry in logic: if not isinstance(entry, (str, list, tuple, set)): # Garbage entry in our logic found return False # treat these entries as though all elements found # must exist in the notification service entries = set(parse_list(entry)) if not entries: # We got a bogus set of tags to parse # If there is no logic to apply then we're done early; we only # match if there is also no data to match against return not data if len(entries.intersection(data.union({match_all}))) == len(entries): # our set contains all of the entries found # in our notification data set matched = True break # else: keep looking # Return True if we matched against our logic (or simply none was # specified). return matched def dict_full_update(dict1, dict2): """Takes 2 dictionaries (dict1 and dict2) that contain sub-dictionaries and gracefully merges them into dict1. This is similar to: dict1.update(dict2) except that internal dictionaries are also recursively applied. """ def _merge(dict1, dict2): for k in dict2: if ( k in dict1 and isinstance(dict1[k], dict) and isinstance(dict2[k], dict) ): _merge(dict1[k], dict2[k]) else: dict1[k] = dict2[k] _merge(dict1, dict2) return apprise-1.10.0/apprise/utils/module.py000066400000000000000000000040061517341665700177150ustar00rootroot00000000000000# 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 importlib.util import sys from ..logger import logger def import_module(path, name): """Load our module based on path.""" spec = importlib.util.spec_from_file_location(name, path) try: module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) except Exception as e: # module isn't loadable with contextlib.suppress(KeyError): del sys.modules[name] module = None logger.debug( "Module exception raised from %s (name=%s) %s", path, name, str(e) ) return module apprise-1.10.0/apprise/utils/parse.py000066400000000000000000001246751517341665700175610ustar00rootroot00000000000000# 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 functools import reduce import re from urllib.parse import quote, unquote, urlencode as _urlencode, urlparse from .disk import tidy_path # URL Indexing Table for returns via parse_url() # The below accepts and scans for: # - schema:// # - schema://path # - schema://path?kwargs # VALID_URL_RE = re.compile( r"^[\s]*((?P[^:\s]+):[/\\]+)?((?P[^?]+)" r"(\?(?P.+))?)?[\s]*$", ) VALID_QUERY_RE = re.compile(r"^(?P.*[/\\])(?P[^/\\]+)?$") # delimiters used to separate values when content is passed in by string. # This is useful when turning a string into a list STRING_DELIMITERS = r"[\[\]\;,\s]+" # String Delimiters without the whitespace STRING_DELIMITERS_NO_WS = r"[\[\]\;,]+" # The handling of custom arguments passed in the URL; we treat any # argument (which would otherwise appear in the qsd area of our parse_url() # function differently if they start with a +, - or : value NOTIFY_CUSTOM_ADD_TOKENS = re.compile(r"^( |\+)(?P.*)\s*") NOTIFY_CUSTOM_DEL_TOKENS = re.compile(r"^-(?P.*)\s*") NOTIFY_CUSTOM_COLON_TOKENS = re.compile(r"^:(?P.*)\s*") # Used for attempting to acquire the schema if the URL can't be parsed. GET_SCHEMA_RE = re.compile(r"\s*(?P[a-z0-9]{1,32})://.*$", re.I) # Used for validating that a provided entry is indeed a schema # this is slightly different then the GET_SCHEMA_RE above which # insists the schema is only valid with a :// entry. this one # extrapolates the individual entries URL_DETAILS_RE = re.compile( r"\s*(?P[a-z0-9]{1,32})(://(?P.*))?$", re.I ) # Regular expression based and expanded from: # http://www.regular-expressions.info/email.html # Extended to support colon (:) delimiter for parsing names from the URL # such as: # - 'Optional Name':user@example.com # - 'Optional Name' # # The expression also parses the general email as well such as: # - user@example.com # - label+user@example.com GET_EMAIL_RE = re.compile( r'(([\s"\']+)?(?P[^:<\'"]+)?[:<\s\'"]+)?' r"(?P((?P