From e68b15316254cc9fea8cc2e8a6a5d585f97d017f Mon Sep 17 00:00:00 2001 From: Paarth Shah Date: Mon, 5 Jun 2023 20:26:25 -0700 Subject: [PATCH] Initial commit --- .devcontainer.json | 42 ++ .gitattributes | 1 + .github/ISSUE_TEMPLATE/bug.yml | 55 +++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 47 +++ .github/dependabot.yml | 15 + .github/workflows/lint.yml | 29 ++ .github/workflows/release.yml | 35 ++ .github/workflows/validate.yml | 37 ++ .gitignore | 18 + .ruff.toml | 48 +++ CONTRIBUTING.md | 61 +++ README.md | 42 ++ config/configuration.yaml | 8 + custom_components/matrix/__init__.py | 421 +++++++++++++++++++++ custom_components/matrix/const.py | 7 + custom_components/matrix/manifest.json | 11 + custom_components/matrix/notify.py | 49 +++ custom_components/matrix/services.yaml | 24 ++ hacs.json | 8 + requirements.txt | 6 + scripts/develop | 20 + scripts/lint | 7 + scripts/setup | 7 + 24 files changed, 999 insertions(+) create mode 100644 .devcontainer.json create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 .ruff.toml create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 config/configuration.yaml create mode 100644 custom_components/matrix/__init__.py create mode 100644 custom_components/matrix/const.py create mode 100644 custom_components/matrix/manifest.json create mode 100644 custom_components/matrix/notify.py create mode 100644 custom_components/matrix/services.yaml create mode 100644 hacs.json create mode 100644 requirements.txt create mode 100755 scripts/develop create mode 100755 scripts/lint create mode 100755 scripts/setup diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..9fef7af --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "PaarthShah/matrix-nio-hacs", + "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.10-bullseye", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..ada3397 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,55 @@ +--- +name: "Bug report" +description: "Report a bug with the integration" +labels: "Bug" +body: +- type: markdown + attributes: + value: Before you open a new issue, search through the existing issues to see if others have had the same problem. +- type: textarea + attributes: + label: "System Health details" + description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io//more-info/system-health#github-issues)" + validations: + required: true +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have enabled debug logging for my installation. + required: true + - label: I have filled out the issue template to the best of my ability. + required: true + - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). + required: true + - label: This issue is not a duplicate issue of currently [previous issues](https://github.com/PaarthShah/matrix-nio-hacs/issues?q=is%3Aissue+label%3A%22Bug%22+).. + required: true +- type: textarea + attributes: + label: "Describe the issue" + description: "A clear and concise description of what the issue is." + validations: + required: true +- type: textarea + attributes: + label: Reproduction steps + description: "Without steps to reproduce, it will be hard to fix, it is very important that you fill out this part, issues without it will be closed" + value: | + 1. + 2. + 3. + ... + validations: + required: true +- type: textarea + attributes: + label: "Debug logs" + description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." + render: text + validations: + required: true + +- type: textarea + attributes: + label: "Diagnostics dump" + description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..08e38c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +--- +name: "Feature request" +description: "Suggest an idea for this project" +labels: "Feature+Request" +body: +- type: markdown + attributes: + value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have filled out the template to the best of my ability. + required: true + - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). + required: true + - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/PaarthShah/matrix-nio-hacs/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). + required: true + +- type: textarea + attributes: + label: "Is your feature request related to a problem? Please describe." + description: "A clear and concise description of what the problem is." + placeholder: "I'm always frustrated when [...]" + validations: + required: true + +- type: textarea + attributes: + label: "Describe the solution you'd like" + description: "A clear and concise description of what you want to happen." + validations: + required: true + +- type: textarea + attributes: + label: "Describe alternatives you've considered" + description: "A clear and concise description of any alternative solutions or features you've considered." + validations: + required: true + +- type: textarea + attributes: + label: "Additional context" + description: "Add any other context or screenshots about the feature request here." + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..04f2d40 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + ignore: + # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json + - dependency-name: "homeassistant" \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..28f9fce --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: "Lint" + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + ruff: + name: "Ruff" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v3.5.2" + + - name: "Set up Python" + uses: actions/setup-python@v4.6.1 + with: + python-version: "3.10" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Run" + run: python3 -m ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b13d280 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: "Release" + +on: + release: + types: + - "published" + +permissions: {} + +jobs: + release: + name: "Release" + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v3.5.2" + + - name: "Adjust version number" + shell: "bash" + run: | + yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ + "${{ github.workspace }}/custom_components/matrix/manifest.json" + + - name: "ZIP the integration directory" + shell: "bash" + run: | + cd "${{ github.workspace }}/custom_components/matrix" + zip matrix-nio-hacs.zip -r ./ + + - name: "Upload the ZIP file to the release" + uses: softprops/action-gh-release@v0.1.15 + with: + files: ${{ github.workspace }}/custom_components/matrix/matrix-nio-hacs.zip diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..6d257aa --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,37 @@ +name: "Validate" + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest + name: "Hassfest Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v3.5.2" + + - name: "Run hassfest validation" + uses: "home-assistant/actions/hassfest@master" + + hacs: # https://github.com/hacs/action + name: "HACS Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v3.5.2" + + - name: "Run HACS validation" + uses: "hacs/action@main" + with: + category: "integration" + # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands + ignore: "brands" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a266a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* + + +# misc +.coverage +.vscode +.idea +coverage.xml + + +# Home Assistant configuration +config/* +!config/configuration.yaml \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..75bec2b --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,48 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +[flake8-pytest-style] +fixture-parentheses = false + +#[pyupgrade] +#keep-runtime-typing = true + +[mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..88f2fa7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `main`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using `scripts/lint`). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) to make sure the code follows the style. + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`configuration.yaml`](./config/configuration.yaml) +file. + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9549b2a --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Integration Blueprint + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) + +[![hacs][hacsbadge]][hacs] +![Project Maintenance][maintenance-shield] + +[![Community Forum][forum-shield]][forum] + +_Integration to integrate with Matrix Homeservers. Replaces the core HomeAssistant `matrix` integration._ + +[PR to merge to core](https://github.com/home-assistant/core/pull/72797) + +## Installation + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +1. If you do not have a `custom_components` directory (folder) there, you need to create it. +1. In the `custom_components` directory (folder) create a new folder called `matrix`. +1. Download _all_ the files from the `custom_components/matrix/` directory (folder) in this repository. +1. Place the files you downloaded in the new directory (folder) you created. +1. Restart Home Assistant +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Matrix Nio" + +## Contributions are welcome! + +If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) + +*** + +[matrix-nio-hacs]: https://github.com/PaarthShah/matrix-nio-hacs +[commits-shield]: https://img.shields.io/github/commit-activity/y/PaarthShah/matrix-nio-hacs.svg?style=for-the-badge +[commits]: https://github.com/PaarthShah/matrix-nio-hacs/commits/main +[hacs]: https://github.com/hacs/integration +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge +[forum]: https://community.home-assistant.io/ +[license-shield]: https://img.shields.io/github/license/PaarthShah/matrix-nio-hacs.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Paarth%20Shah%20%40PaarthShah-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/PaarthShah/matrix-nio-hacs.svg?style=for-the-badge +[releases]: https://github.com/PaarthShah/matrix-nio-hacs/releases diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..86a624f --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,8 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: info + logs: + custom_components.matrix-nio-hacs: debug diff --git a/custom_components/matrix/__init__.py b/custom_components/matrix/__init__.py new file mode 100644 index 0000000..e756886 --- /dev/null +++ b/custom_components/matrix/__init__.py @@ -0,0 +1,421 @@ +"""The Matrix bot component.""" +from __future__ import annotations + +import asyncio +import logging +import mimetypes +import os +import re +from typing import NewType, TypedDict + +from PIL import Image +import aiofiles.os +from nio import AsyncClient, Event, MatrixRoom +from nio.events.room_events import RoomMessageText +from nio.responses import ( + ErrorResponse, + JoinError, + JoinResponse, + LoginError, + Response, + UploadError, + UploadResponse, +) +import voluptuous as vol + +from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.json import save_json +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.json import JsonObjectType, load_json_object + +from .const import DOMAIN, FORMAT_HTML, FORMAT_TEXT, SERVICE_SEND_MESSAGE + +_LOGGER = logging.getLogger(__name__) + +SESSION_FILE = ".matrix.conf" + +CONF_HOMESERVER = "homeserver" +CONF_ROOMS = "rooms" +CONF_COMMANDS = "commands" +CONF_WORD = "word" +CONF_EXPRESSION = "expression" + +EVENT_MATRIX_COMMAND = "matrix_command" + +DEFAULT_CONTENT_TYPE = "application/octet-stream" + +MESSAGE_FORMATS = [FORMAT_HTML, FORMAT_TEXT] +DEFAULT_MESSAGE_FORMAT = FORMAT_TEXT + +ATTR_FORMAT = "format" # optional message format +ATTR_IMAGES = "images" # optional images + +WordCommand = NewType("WordCommand", str) +ExpressionCommand = NewType("ExpressionCommand", re.Pattern) +RoomID = NewType("RoomID", str) + + +class ConfigCommand(TypedDict, total=False): + """Corresponds to a single COMMAND_SCHEMA.""" + + name: str # CONF_NAME + rooms: list[RoomID] | None # CONF_ROOMS + word: WordCommand | None # CONF_WORD + expression: ExpressionCommand | None # CONF_EXPRESSION + + +COMMAND_SCHEMA = vol.All( + vol.Schema( + { + vol.Exclusive(CONF_WORD, "trigger"): cv.string, + vol.Exclusive(CONF_EXPRESSION, "trigger"): cv.is_regex, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ROOMS): vol.All(cv.ensure_list, [cv.string]), + } + ), + cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION), +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOMESERVER): cv.url, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Required(CONF_USERNAME): cv.matches_regex("@[^:]*:.*"), + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_ROOMS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA], + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( + { + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_DATA, default={}): { + vol.Optional(ATTR_FORMAT, default=DEFAULT_MESSAGE_FORMAT): vol.In( + MESSAGE_FORMATS + ), + vol.Optional(ATTR_IMAGES): vol.All(cv.ensure_list, [cv.string]), + }, + vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), + } +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Matrix bot component.""" + config = config[DOMAIN] + + matrix_bot = MatrixBot( + hass, + os.path.join(hass.config.path(), SESSION_FILE), + config[CONF_HOMESERVER], + config[CONF_VERIFY_SSL], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_ROOMS], + config[CONF_COMMANDS], + ) + hass.data[DOMAIN] = matrix_bot + + hass.services.async_register( + DOMAIN, + SERVICE_SEND_MESSAGE, + matrix_bot.handle_send_message, + schema=SERVICE_SCHEMA_SEND_MESSAGE, + ) + + return True + + +class MatrixBot: + """The Matrix Bot.""" + + _client: AsyncClient + + def __init__( + self, + hass: HomeAssistant, + config_file: str, + homeserver: str, + verify_ssl: bool, + username: str, + password: str, + listening_rooms: list[RoomID], + commands: list[ConfigCommand], + ) -> None: + """Set up the client.""" + self.hass = hass + + self._session_filepath = config_file + self._auth_tokens: JsonObjectType = {} + + self._homeserver = homeserver + self._verify_tls = verify_ssl + self._mx_id = username + self._password = password + + self._client = AsyncClient( + homeserver=self._homeserver, user=self._mx_id, ssl=self._verify_tls + ) + + self._listening_rooms = listening_rooms + + self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} + self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} + self._load_commands(commands) + + async def stop_client(event: HassEvent) -> None: + """Run once when Home Assistant stops.""" + if self._client is not None: + await self._client.close() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) + + async def handle_startup(event: HassEvent) -> None: + """Run once when Home Assistant finished startup.""" + self._auth_tokens = await self._get_auth_tokens() + await self._login() + await self._join_rooms() + # Sync once so that we don't respond to past events. + await self._client.sync(timeout=30_000) + + self._client.add_event_callback(self._handle_room_message, RoomMessageText) + + await self._client.sync_forever( + timeout=30_000, + loop_sleep_time=1_000, + ) # milliseconds. + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, handle_startup) + + def _load_commands(self, commands: list[ConfigCommand]) -> None: + for command in commands: + # Set the command for all listening_rooms, unless otherwise specified. + command.setdefault(CONF_ROOMS, self._listening_rooms) # type: ignore[misc] + + # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. + if (word_command := command.get(CONF_WORD)) is not None: + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._word_commands.setdefault(room_id, {}) + self._word_commands[room_id][word_command] = command # type: ignore[index] + else: + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._expression_commands.setdefault(room_id, []) + self._expression_commands[room_id].append(command) + + async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None: + """Handle a message sent to a Matrix room.""" + # Corresponds to message type 'm.text' and NOT other RoomMessage subtypes, like 'm.notice' and 'm.emote'. + if not isinstance(message, RoomMessageText): + return + # Don't respond to our own messages. + if message.sender == self._mx_id: + return + _LOGGER.debug("Handling message: %s", message.body) + + room_id = RoomID(room.room_id) + + if message.body.startswith("!"): + # Could trigger a single-word command. + pieces = message.body.split() + word = WordCommand(pieces[0].lstrip("!")) + + if command := self._word_commands.get(room_id, {}).get(word): + message_data = { + "command": command[CONF_NAME], + "sender": message.sender, + "room": room_id, + "args": pieces[1:], + } + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) + + # After single-word commands, check all regex commands in the room. + for command in self._expression_commands.get(room_id, []): + match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] + if not match: + continue + message_data = { + "command": command[CONF_NAME], + "sender": message.sender, + "room": room_id, + "args": match.groupdict(), + } + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) + + async def _join_room(self, room_id_or_alias: str) -> None: + """Join a room or do nothing if already joined.""" + join_response = await self._client.join(room_id_or_alias) + + if isinstance(join_response, JoinResponse): + _LOGGER.debug("Joined or already in room '%s'", room_id_or_alias) + elif isinstance(join_response, JoinError): + _LOGGER.error( + "Could not join room '%s': %s", + room_id_or_alias, + join_response, + ) + + async def _join_rooms(self) -> None: + """Join the Matrix rooms that we listen for commands in.""" + rooms = { + asyncio.create_task(self._join_room(room_id)) + for room_id in self._listening_rooms + } + await asyncio.wait(rooms) + + async def _get_auth_tokens(self) -> JsonObjectType: + """Read sorted authentication tokens from disk.""" + try: + return load_json_object(self._session_filepath) + except HomeAssistantError as ex: + _LOGGER.warning( + "Loading authentication tokens from file '%s' failed: %s", + self._session_filepath, + str(ex), + ) + return {} + + async def _store_auth_token(self, token: str) -> None: + """Store authentication token to session and persistent storage.""" + self._auth_tokens[self._mx_id] = token + + await self.hass.async_add_executor_job( + save_json, self._session_filepath, self._auth_tokens, True # private=True + ) + + async def _login(self) -> None: + """Log in to the Matrix homeserver. + + Attempts to use the stored authentication token. + If that fails, then tries using the password. + If that also fails, raises LocalProtocolError. + """ + + # If we have an authentication token + if (token := self._auth_tokens.get(self._mx_id)) is not None: + response = await self._client.login(token=token) + _LOGGER.debug("Logging in using stored token") + + if isinstance(response, LoginError): + _LOGGER.warning( + "Login by token failed: %s, %s", + response.status_code, + response.message, + ) + + # If the token login did not succeed + if not self._client.logged_in: + response = await self._client.login(password=self._password) + _LOGGER.debug("Logging in using password") + + if isinstance(response, LoginError): + _LOGGER.warning( + "Login by password failed: %s, %s", + response.status_code, + response.message, + ) + + if not self._client.logged_in: + raise ConfigEntryAuthFailed( + "Login failed, both token and username/password are invalid" + ) + + await self._store_auth_token(self._client.access_token) + + async def _send_image(self, image_path: str, target_rooms: list[RoomID]) -> None: + """Upload an image, then send it to all target_rooms.""" + if not self.hass.config.is_allowed_path(image_path): + _LOGGER.error("Path not allowed: %s", image_path) + return + + # Get required image metadata. + image = Image.open(image_path) + (width, height) = image.size + mime_type = mimetypes.guess_type(image_path)[0] + file_stat = await aiofiles.os.stat(image_path) + + _LOGGER.debug("Uploading file from path, %s", image_path) + async with aiofiles.open(image_path, "r+b") as image_file: + response = await self._client.upload( + image_file, + content_type=mime_type, + filename=os.path.basename(image_path), + filesize=file_stat.st_size, + ) + if isinstance(response, UploadError): + _LOGGER.error("Unable to upload image to the homeserver: %s", response) + return + + assert isinstance(response, UploadResponse) + _LOGGER.debug("Successfully uploaded image to the homeserver") + + content = { + "body": os.path.basename(image_path), + "info": { + "size": file_stat.st_size, + "mimetype": mime_type, + "w": width, + "h": height, + }, + "msgtype": "m.image", + "url": response.content_uri, + } + + for room in target_rooms: + await self._client.room_send( + room_id=room, message_type="m.room.message", content=content + ) + _LOGGER.debug("Image '%s' sent to room '%s'", image_path, room) + + async def _send_message( + self, message: str, target_rooms: list[RoomID], data: dict | None + ) -> None: + """Send a message to the Matrix server.""" + content = {"msgtype": "m.text", "body": message} + if data is not None and data.get(ATTR_FORMAT) == FORMAT_HTML: + content |= {"format": "org.matrix.custom.html", "formatted_body": message} + for target_room in target_rooms: + response: Response = await self._client.room_send( + room_id=target_room, + message_type="m.room.message", + content=content, + ) + if isinstance(response, ErrorResponse): + _LOGGER.error( + "Unable to deliver message to room '%s': %s", + target_room, + response, + ) + else: + _LOGGER.debug("Message delivered to room '%s'", target_room) + + if data is not None and len(target_rooms) > 0: + for image_path in data.get(ATTR_IMAGES, []): + await self._send_image(image_path, target_rooms) + + async def handle_send_message(self, service: ServiceCall) -> None: + """Handle the send_message service.""" + return await self._send_message( + service.data[ATTR_MESSAGE], + service.data[ATTR_TARGET], + service.data.get(ATTR_DATA), + ) diff --git a/custom_components/matrix/const.py b/custom_components/matrix/const.py new file mode 100644 index 0000000..b7e0c22 --- /dev/null +++ b/custom_components/matrix/const.py @@ -0,0 +1,7 @@ +"""Constants for the Matrix integration.""" +DOMAIN = "matrix" + +SERVICE_SEND_MESSAGE = "send_message" + +FORMAT_HTML = "html" +FORMAT_TEXT = "text" diff --git a/custom_components/matrix/manifest.json b/custom_components/matrix/manifest.json new file mode 100644 index 0000000..603f6e6 --- /dev/null +++ b/custom_components/matrix/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "matrix", + "name": "Matrix Nio", + "codeowners": ["@PaarthShah"], + "documentation": "https://www.home-assistant.io/integrations/matrix", + "iot_class": "cloud_push", + "issue_tracker": "https://github.com/PaarthShah/matrix-nio-hacs/issues", + "loggers": ["matrix_client"], + "requirements": ["matrix-nio==0.20.2", "Pillow==9.5.0"], + "version": "v1.0.0" +} diff --git a/custom_components/matrix/notify.py b/custom_components/matrix/notify.py new file mode 100644 index 0000000..c71f91e --- /dev/null +++ b/custom_components/matrix/notify.py @@ -0,0 +1,49 @@ +"""Support for Matrix notifications.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import RoomID +from .const import DOMAIN, SERVICE_SEND_MESSAGE + +CONF_DEFAULT_ROOM = "default_room" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_DEFAULT_ROOM): cv.string}) + + +def get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> MatrixNotificationService: + """Get the Matrix notification service.""" + return MatrixNotificationService(config[CONF_DEFAULT_ROOM]) + + +class MatrixNotificationService(BaseNotificationService): + """Send notifications to a Matrix room.""" + + def __init__(self, default_room: RoomID) -> None: + """Set up the Matrix notification service.""" + self._default_room = default_room + + def send_message(self, message: str = "", **kwargs: Any) -> None: + """Send the message to the Matrix server.""" + target_rooms: list[RoomID] = kwargs.get(ATTR_TARGET) or [self._default_room] + service_data = {ATTR_TARGET: target_rooms, ATTR_MESSAGE: message} + if (data := kwargs.get(ATTR_DATA)) is not None: + service_data[ATTR_DATA] = data + self.hass.services.call(DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data) diff --git a/custom_components/matrix/services.yaml b/custom_components/matrix/services.yaml new file mode 100644 index 0000000..9b5171d --- /dev/null +++ b/custom_components/matrix/services.yaml @@ -0,0 +1,24 @@ +send_message: + name: Send message + description: Send message to target room(s) + fields: + message: + name: Message + description: The message to be sent. + required: true + example: This is a message I am sending to matrix + selector: + text: + target: + name: Target + description: A list of room(s) to send the message to. + required: true + example: "#hasstest:matrix.org" + selector: + text: + data: + name: Data + description: Extended information of notification. Supports list of images. Supports message format. Optional. + example: "{'images': ['/tmp/test.jpg'], 'format': 'text'}" + selector: + object: diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..4f78393 --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "Matrix Nio", + "filename": "matrix-nio-hacs.zip", + "hide_default_branch": true, + "homeassistant": "2023.5.4", + "render_readme": true, + "zip_release": true +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f2cce9b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +colorlog==6.7.0 +homeassistant==2023.5.4 +pip>=21.0,<23.2 +ruff==0.0.270 +matrix-nio==0.20.2 +Pillow==9.5.0 diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 0000000..47bf43b --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/matrix-nio-hacs +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..9b5b1df --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..141d19f --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt