├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── base-config.yaml ├── hasswebhook ├── __init__.py ├── bot.py ├── config.py ├── db.py ├── roomposter.py └── setupinstructions.py └── maubot.yaml /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | tags: 11 | - 'v*' 12 | pull_request: 13 | branches: [ main ] 14 | 15 | 16 | # Allows you to run this workflow manually from the Actions tab 17 | workflow_dispatch: 18 | 19 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 20 | jobs: 21 | # This workflow contains a single job called "build" 22 | build: 23 | # The type of runner that the job will run on 24 | runs-on: ubuntu-latest 25 | 26 | # Steps represent a sequence of tasks that will be executed as part of the job 27 | steps: 28 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 29 | - uses: actions/checkout@v2 30 | - name: Create .mbp package 31 | run: | 32 | mkdir artifacts 33 | zip -9r ./artifacts/$(grep 'id:' maubot.yaml | sed 's/id: //')'-v'$(grep 'version:' maubot.yaml | sed 's/version: //').mbp hasswebhook/* *.yaml 34 | 35 | - name: Release and attach artifacts 36 | uses: fnkr/github-action-ghr@v1 37 | if: startsWith(github.ref, 'refs/tags/') 38 | env: 39 | GHR_PATH: ./artifacts 40 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv 3 | __pycache__ 4 | 5 | # Maubot plugin 6 | *.mbp 7 | 8 | .DS_Store 9 | /artifacts/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Valentin Rieß 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/v411e/hasswebhookbot) 2 | 3 | # Home Assistant notification bot for [matrix](https://matrix.org/) via webhooks 4 | A [maubot](https://github.com/maubot) bot to get [Home Assistant](https://github.com/home-assistant) notifications in your favorite matrix room. 5 | Simple message | Edited message with reaction | Image 6 | :-------------------------------------:|:-------------------------:|:----: 7 | ![Imgur](https://i.imgur.com/y22FQKe.jpg)| ![Imgur](https://i.imgur.com/rPUdca3.jpeg) | ![Imgur](https://imgur.com/CdFZPf9.png) 8 | 9 | I am describing in this [blog post](https://riess.dev/washing-machine-notifications/) how I use this bot to get notified whenever my washing machine has finised. 10 | 11 | ## Configuration 12 | First add this plugin to your maubot manager: 13 | 1. Load the *.mbp file of the current [release](https://github.com/v411e/hasswebhookbot/releases) 14 | 2. Create client and instance 15 | 3. Configure instance `base_url` 16 | 17 | After setting up the plugin just invite the bot into an *encrypted* room. Each room has an indvidual "webhook url". To get yours just write `!ha`. The bot replies with the `WEBHOOK_URL` of your room and also generates some YAML code for the configuration of your home assistant instance (like below). 18 | 19 | `configuration.yaml` on HA (don't forget to reload): 20 | ```yaml 21 | notify: 22 | - name: HASS_MAUBOT 23 | platform: rest 24 | resource: "" 25 | method: POST_JSON 26 | data: 27 | type: "{{data.type}}" 28 | identifier: "{{data.identifier}}" 29 | callback_url: "{{data.callback_url}}" 30 | lifetime: "{{data.lifetime}}" 31 | content: "{{data.content}}" 32 | contentType: "{{data.contentType}}" 33 | name: "{{data.name}}" 34 | thumbnailSize: 512 35 | ``` 36 | 37 | ## Usage 38 | The bot is almost stateless (database only used for lifetime) and can be used within multiple rooms. 39 | Most attributes are optional. The only attribute that always has to be provided through home assistant is `data: message`. You can set this to `None` if you are not using it (e.g. sending a redaction or image) 40 | ```yaml 41 | service: notify. 42 | data: 43 | message: 44 | data: 45 | type: # The type of action 46 | identifier: # Use your own identifier (#1) or reference an event_id (#2) 47 | callback_url: https:///api/webhook/ # Optional: Get a callback with entity_id of sent message 48 | lifetime: 1440 # Optional: Activate message self-deletion after given time in minutes 49 | ``` 50 | 51 | ## Examples 52 | ### Send a message 53 | ```yaml 54 | service: notify.hass_maubot 55 | data: 56 | message: Die Post ist da! 📬 57 | data: 58 | type: message 59 | identifier: letterbox.status 60 | callback_url: https://ha.example.com/api/webhook/some_hook_id 61 | lifetime: 1440 62 | ``` 63 | ### Send a message (but minimal) 64 | ```yaml 65 | service: notify.hass_maubot 66 | data: 67 | message: "hi" 68 | data: 69 | type: "message" 70 | ``` 71 | ### Delete a message 72 | ```yaml 73 | service: notify.hass_maubot 74 | data: 75 | message: None 76 | data: 77 | type: redaction 78 | identifier: letterbox.status 79 | ``` 80 | or 81 | ```yaml 82 | service: notify.hass_maubot 83 | data: 84 | message: None 85 | data: 86 | type: redaction 87 | identifier: event_id.$DRTYGw... # event_id can be obtained through callback 88 | ``` 89 | ### React to message 90 | ```yaml 91 | service: notify.hass_maubot 92 | data: 93 | message: 📬 94 | data: 95 | type: reaction 96 | identifier: letterbox.status 97 | ``` 98 | or 99 | ```yaml 100 | service: notify.hass_maubot 101 | data: 102 | message: 📬 103 | data: 104 | type: reaction 105 | identifier: event_id.$DRTYGw... # event_id can be obtained through callback 106 | ``` 107 | ### Edit message 108 | ```yaml 109 | service: notify.hass_maubot 110 | data: 111 | message: Die Post ist da! 📬 112 | data: 113 | type: edit 114 | identifier: letterbox.status 115 | ``` 116 | or 117 | ```yaml 118 | service: notify.hass_maubot 119 | data: 120 | message: Die Post ist da! 📬 121 | data: 122 | type: edit 123 | identifier: event_id.$DRTYGw... # event_id can be obtained through callback 124 | ``` 125 | 126 | ### Send image 127 | ```yaml 128 | service: notify.hass_maubot 129 | data: 130 | message: None 131 | data: 132 | type: "image" 133 | content: "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" 134 | contentType: "image/png" 135 | name: "halogo.png" 136 | ``` 137 | The image sending functionality is contributed and used by https://github.com/AlexanderBabel/mail-parser. 138 | 139 | ## Maubot plugin config 140 | **Hint:** Depending on your preference, you can choose between two different modes for the edit feature: 141 | 1. Content of `` is discarded in the Matrix notification (`keep_del_tag: true`)
Notification example: 142 | ``` 143 | * - New message 144 | ``` 145 | 2. Content of `` is displayed as normal text (`keep_del_tag: false`)
Notification example: 146 | ``` 147 | * Previous message - New message 148 | ``` 149 | 150 | You can change this setting on the maubot configuration page. 151 | -------------------------------------------------------------------------------- /base-config.yaml: -------------------------------------------------------------------------------- 1 | command_prefix: ha 2 | base_url: https://maubot.example.com/ 3 | keep_del_tag: false 4 | message_key: message 5 | -------------------------------------------------------------------------------- /hasswebhook/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import HassWebhook -------------------------------------------------------------------------------- /hasswebhook/bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from datetime import datetime, timedelta 4 | from typing import Type 5 | 6 | import pytz 7 | from aiohttp.web import Request, Response 8 | from maubot import Plugin, MessageEvent 9 | from maubot.handlers import command, web 10 | from mautrix.types import TextMessageEventContent, Format, MessageType 11 | from mautrix.util import markdown 12 | 13 | from .config import Config 14 | from .db import LifetimeDatabase, LifetimeEnd 15 | from .roomposter import RoomPoster, RoomPosterType, Image 16 | from .setupinstructions import HassWebhookSetupInstructions 17 | 18 | 19 | class HassWebhook(Plugin): 20 | config: Config 21 | db: LifetimeDatabase 22 | loop_task: asyncio.Future 23 | 24 | async def start(self) -> None: 25 | self.config.load_and_update() 26 | self.db = LifetimeDatabase(self.database) 27 | self.loop_task = asyncio.ensure_future(self.lifetime_loop(), loop=self.loop) 28 | 29 | async def stop(self) -> None: 30 | self.loop_task.cancel() 31 | 32 | async def lifetime_loop(self) -> None: 33 | try: 34 | self.log.debug("Lifetime watcher loop started") 35 | while True: 36 | now = datetime.now(tz=pytz.UTC) 37 | next_minute = (now + timedelta(minutes=1)).replace(second=0, microsecond=0) 38 | await asyncio.sleep((next_minute - now).total_seconds()) 39 | await self.schedule_nearby_lifetime_ends(next_minute) 40 | except asyncio.CancelledError: 41 | self.log.debug("Lifetime watcher loop stopped") 42 | except Exception: 43 | self.log.exception("Exception in lifetime watcher loop") 44 | 45 | async def schedule_nearby_lifetime_ends(self, now: datetime) -> None: 46 | until = now + timedelta(minutes=1) 47 | for lifetime_end in self.db.get_older_than(until): 48 | asyncio.create_task(self.post_lifetime_end(lifetime_end)) 49 | 50 | async def post_lifetime_end(self, lifetime_end: LifetimeEnd) -> None: 51 | self.db.remove(lifetime_end) 52 | room_poster: RoomPoster = RoomPoster( 53 | hasswebhook=self, 54 | identifier=f"event_id.{lifetime_end.event_id}", 55 | rp_type=RoomPosterType.REDACTION, 56 | room_id=lifetime_end.room_id 57 | ) 58 | 59 | self.log.debug(f"Lifetime ends for event with ID {lifetime_end.event_id}.") 60 | await room_poster.post_to_room() 61 | 62 | def get_base_url(self) -> str: 63 | return self.config["base_url"] 64 | 65 | def get_command_prefix(self) -> str: 66 | return self.config["command_prefix"] 67 | 68 | def get_keep_del_tag(self) -> str: 69 | return self.config["keep_del_tag"] 70 | 71 | def get_message_key(self) -> str: 72 | return self.config["message_key"] 73 | 74 | @command.new(name=get_command_prefix) 75 | async def setup_instructions(self, evt: MessageEvent) -> None: 76 | setup_instructions = HassWebhookSetupInstructions( 77 | base_url=self.get_base_url(), 78 | bot_id=self.id, 79 | room_id=evt.room_id 80 | ) 81 | message_html = markdown.render( 82 | setup_instructions.md(), 83 | allow_html=True 84 | ) 85 | content = TextMessageEventContent( 86 | msgtype=MessageType.TEXT, 87 | format=Format.HTML, 88 | body=setup_instructions.plain(), 89 | formatted_body=message_html 90 | ) 91 | 92 | await evt.respond(content) 93 | 94 | @web.post("/push/{room_id}") 95 | async def post_data(self, req: Request) -> Response: 96 | room_id: str = req.match_info["room_id"] 97 | self.log.info(f"Request for room {room_id} data: {await req.text()}") 98 | 99 | req_dict = await req.json() 100 | self.log.debug(req_dict) 101 | 102 | message: str = req_dict.get(self.get_message_key()) 103 | rp_type: RoomPosterType = RoomPosterType.get_type_from_str(req_dict.get("type", "message")) 104 | identifier: str = req_dict.get("identifier", "") 105 | callback_url: str = req_dict.get("callback_url", "") 106 | 107 | lifetime: int = req_dict.get("lifetime", "") 108 | if lifetime == "" or int(lifetime) < 0: 109 | lifetime = -1 110 | else: 111 | lifetime = int(lifetime) 112 | self.log.debug(f"Lifetime: {lifetime}") 113 | 114 | # Image parameters 115 | content: str = req_dict.get("content") 116 | content_type: str = req_dict.get("contentType") 117 | name: str = req_dict.get("name") 118 | thumbnail_size: int = req_dict.get("thumbnailSize", 128) 119 | image = None 120 | self.log.info(content) 121 | if not content and rp_type == RoomPosterType.IMAGE: 122 | return Response(status=400, content_type="application/json", body=json.dumps( 123 | {"success": False, 124 | "error": "Type is set to image. Please pass at least the 'content' property (base64 image)"})) 125 | 126 | if content and rp_type != RoomPosterType.IMAGE: 127 | rp_type = RoomPosterType.IMAGE 128 | 129 | if rp_type == RoomPosterType.IMAGE: 130 | image = Image(content=content, content_type=content_type, name=name, thumbnail_size=thumbnail_size) 131 | self.log.info(f"Image content found: {content}") 132 | 133 | room_poster: RoomPoster = RoomPoster( 134 | hasswebhook=self, 135 | message=message, 136 | identifier=identifier, 137 | rp_type=rp_type, 138 | room_id=room_id, 139 | callback_url=callback_url, 140 | lifetime=lifetime, 141 | image=image, 142 | ) 143 | 144 | self.log.debug(f"Received data with ID {room_id}: {req_dict}") 145 | 146 | event_id = await room_poster.post_to_room() 147 | if rp_type == RoomPosterType.MESSAGE or rp_type == RoomPosterType.IMAGE: 148 | return Response(status=200, body=json.dumps({"event_id": event_id}), content_type="application/json") 149 | elif event_id: 150 | return Response(status=200) 151 | else: 152 | return Response(status=404) 153 | 154 | @web.get("/health") 155 | async def health(self, req: Request) -> Response: 156 | return Response(status=200) 157 | 158 | @classmethod 159 | def get_config_class(cls) -> Type[Config]: 160 | return Config 161 | -------------------------------------------------------------------------------- /hasswebhook/config.py: -------------------------------------------------------------------------------- 1 | from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper 2 | 3 | 4 | class Config(BaseProxyConfig): 5 | def do_update(self, helper: ConfigUpdateHelper) -> None: 6 | helper.copy("command_prefix") 7 | helper.copy("base_url") 8 | helper.copy("keep_del_tag") 9 | helper.copy("message_key") 10 | -------------------------------------------------------------------------------- /hasswebhook/db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Iterator 4 | 5 | import pytz 6 | from attr import dataclass 7 | from mautrix.types import EventID, RoomID 8 | from sqlalchemy import (Column, String, Integer, DateTime, Table, MetaData, 9 | select, and_) 10 | from sqlalchemy.engine.base import Engine 11 | 12 | 13 | @dataclass 14 | class LifetimeEnd: 15 | id: int = None 16 | end_date: datetime = None 17 | room_id: RoomID = None 18 | event_id: EventID = None 19 | 20 | 21 | class LifetimeDatabase: 22 | lifetime_ends: Table 23 | db: Engine 24 | 25 | def __init__(self, db: Engine) -> None: 26 | self.db = db 27 | 28 | meta = MetaData() 29 | meta.bind = db 30 | 31 | self.lifetime_ends = Table("lifetime_ends", meta, 32 | Column("id", Integer, primary_key=True, autoincrement=True), 33 | Column("end_date", DateTime, nullable=False), 34 | Column("room_id", String(255), nullable=False), 35 | Column("event_id", String(255), nullable=False)) 36 | 37 | meta.create_all() 38 | 39 | def insert(self, lifetime_end: LifetimeEnd) -> None: 40 | logging.getLogger("maubot").info(f"Inserted event {lifetime_end.event_id} into database.") 41 | self.db.execute(self.lifetime_ends.insert() 42 | .values(end_date=lifetime_end.end_date, room_id=lifetime_end.room_id, 43 | event_id=lifetime_end.event_id)) 44 | 45 | def get_older_than(self, end_date: datetime) -> Iterator[LifetimeEnd]: 46 | where = [self.lifetime_ends.c.end_date < end_date] 47 | rows = self.db.execute(select([self.lifetime_ends]).where(and_(*where))) 48 | for row in rows: 49 | yield LifetimeEnd(id=row[0], end_date=row[1].replace(tzinfo=pytz.UTC), room_id=row[2], 50 | event_id=row[3]) 51 | 52 | def remove(self, lifetime_end: LifetimeEnd) -> None: 53 | self.db.execute(self.lifetime_ends.delete().where( 54 | and_(self.lifetime_ends.c.event_id == lifetime_end.event_id))) 55 | -------------------------------------------------------------------------------- /hasswebhook/roomposter.py: -------------------------------------------------------------------------------- 1 | import re 2 | from base64 import b64decode 3 | from datetime import datetime, timedelta 4 | from enum import Enum 5 | from io import BytesIO 6 | from typing import Optional 7 | 8 | import pytz 9 | from PIL import Image as pil_image 10 | from markdown import markdown 11 | from maubot import Plugin 12 | from maubot.matrix import MaubotMessageEvent 13 | from mautrix.crypto.attachments import encrypt_attachment 14 | from mautrix.errors.request import MForbidden 15 | from mautrix.types import TextMessageEventContent, Format, MessageType, RoomID, PaginationDirection, \ 16 | MessageEventContent, EventID, MediaMessageEventContent, ImageInfo, EventType, ThumbnailInfo 17 | 18 | from .db import LifetimeEnd 19 | 20 | 21 | class Image: 22 | content: str 23 | content_type: str 24 | name: str 25 | thumbnail_size: int 26 | 27 | def __init__(self, content: str, content_type: str, name: str, thumbnail_size: int): 28 | self.content = content 29 | self.content_type = content_type 30 | self.name = name 31 | self.thumbnail_size = thumbnail_size 32 | 33 | 34 | class RoomPosterType(Enum): 35 | MESSAGE = 1 36 | EDIT = 2 37 | REDACTION = 3 38 | REACTION = 4 39 | IMAGE = 5 40 | 41 | @classmethod 42 | def get_type_from_str(cls, mtype: str): 43 | type_switcher = { 44 | "": RoomPosterType.MESSAGE, 45 | "message": RoomPosterType.MESSAGE, 46 | "redaction": RoomPosterType.REDACTION, 47 | "edit": RoomPosterType.EDIT, 48 | "reaction": RoomPosterType.REACTION, 49 | "image": RoomPosterType.IMAGE 50 | } 51 | return type_switcher.get(mtype) 52 | 53 | 54 | class RoomPoster: 55 | rp_type: RoomPosterType 56 | room_id: RoomID 57 | hasswebhook: Plugin 58 | identifier: str 59 | callback_url: str 60 | message: str 61 | lifetime: int 62 | 63 | def __init__(self, hasswebhook: Plugin, identifier: str, rp_type: RoomPosterType, room_id: str, 64 | image: Optional[Image] = None, message="", callback_url="", lifetime=-1): 65 | self.rp_type = rp_type 66 | self.room_id = RoomID(room_id) 67 | self.hasswebhook = hasswebhook 68 | self.identifier = identifier 69 | self.callback_url = callback_url 70 | self.message = message 71 | self.lifetime = lifetime 72 | self.image = image 73 | 74 | # Send a POST as a callback containing the event_id of the sent message 75 | async def callback(self, event_id: str) -> None: 76 | if self.callback_url: 77 | await self.hasswebhook.http.post(self.callback_url, json={'event_id': event_id}) 78 | 79 | # Switch for each RoomPosterType 80 | async def post_to_room(self): 81 | if self.rp_type == RoomPosterType.MESSAGE: 82 | return await self.post_message() 83 | if self.rp_type == RoomPosterType.REDACTION: 84 | return await self.post_redaction() 85 | if self.rp_type == RoomPosterType.EDIT: 86 | return await self.post_edit() 87 | if self.rp_type == RoomPosterType.REACTION: 88 | return await self.post_reaction() 89 | if self.rp_type == RoomPosterType.IMAGE: 90 | return await self.post_image() 91 | return False 92 | 93 | async def post_image(self) -> str: 94 | media_event = MediaMessageEventContent(body=self.image.name, msgtype=MessageType.IMAGE) 95 | 96 | upload_mime = "application/octet-stream" 97 | bytes_image = b64decode(self.image.content) 98 | encrypted_image, file = encrypt_attachment(bytes_image) 99 | file.url = await self.hasswebhook.client.upload_media(encrypted_image, mime_type=upload_mime) 100 | media_event.file = file 101 | 102 | img = pil_image.open(BytesIO(bytes_image)) 103 | image_info = ImageInfo(mimetype=self.image.content_type, height=img.height, width=img.width) 104 | media_event.info = image_info 105 | 106 | byt_arr_tn = BytesIO() 107 | img.thumbnail((self.image.thumbnail_size, self.image.thumbnail_size), pil_image.ANTIALIAS) 108 | img.save(byt_arr_tn, format='PNG') 109 | 110 | tn_img = pil_image.open(byt_arr_tn) 111 | enc_tn, tn_file = encrypt_attachment(byt_arr_tn.getvalue()) 112 | 113 | image_info.thumbnail_info = ThumbnailInfo(mimetype="image/png", height=tn_img.height, width=tn_img.width) 114 | tn_file.url = await self.hasswebhook.client.upload_media(enc_tn, mime_type=upload_mime) 115 | image_info.thumbnail_file = tn_file 116 | 117 | img.close() 118 | tn_img.close() 119 | 120 | event_id = await self.hasswebhook.client.send_message_event(self.room_id, event_type=EventType.ROOM_MESSAGE, 121 | content=media_event) 122 | await self.callback(event_id) 123 | return event_id 124 | 125 | # Send message to room 126 | async def post_message(self): 127 | body = "{message} by {identifier}".format( 128 | message=self.message, identifier=self.identifier) if self.identifier else self.message 129 | content = TextMessageEventContent( 130 | msgtype=MessageType.TEXT, 131 | format=Format.HTML, 132 | body=body, 133 | formatted_body=markdown(self.message) 134 | ) 135 | try: 136 | event_id_req = await self.hasswebhook.client.send_message(self.room_id, content) 137 | await self.callback(event_id_req) 138 | # Lifetime (self-deletion) 139 | if self.lifetime != -1: 140 | end_time = datetime.now(tz=pytz.UTC) + timedelta(minutes=self.lifetime) 141 | self.hasswebhook.db.insert( 142 | LifetimeEnd(end_date=end_time, room_id=self.room_id, event_id=event_id_req)) 143 | return event_id_req 144 | except MForbidden: 145 | self.hasswebhook.log.error("Wrong Room ID") 146 | return False 147 | 148 | # Redact message 149 | async def post_redaction(self) -> bool: 150 | event_id = self.identifier[9:] if ("event_id." in self.identifier) else ( 151 | await self.search_history_for_event()).event_id 152 | try: 153 | event_id_req = await self.hasswebhook.client.redact( 154 | room_id=self.room_id, 155 | event_id=event_id, 156 | reason="deactivated" 157 | ) 158 | await self.callback(event_id_req) 159 | except MForbidden: 160 | self.hasswebhook.log.error("Wrong Room ID") 161 | return False 162 | return True 163 | 164 | # Edit message 165 | async def post_edit(self) -> bool: 166 | body = re.sub(r"(.*)", r"\1", self.message) if self.hasswebhook.get_keep_del_tag() else re.sub( 167 | r".*", "", self.message) 168 | content = TextMessageEventContent( 169 | msgtype=MessageType.TEXT, 170 | format=Format.HTML, 171 | body=body, 172 | formatted_body=markdown(self.message) 173 | ) 174 | event: MaubotMessageEvent = await self.search_history_for_event() 175 | await event.edit(content=content) 176 | return True 177 | 178 | # React on message 179 | async def post_reaction(self) -> bool: 180 | event: MaubotMessageEvent = await self.search_history_for_event() 181 | await event.react(key=self.message) 182 | return True 183 | 184 | # Search in room history for a message containing the identifier and return the event of that message 185 | async def search_history_for_event(self) -> Optional[MaubotMessageEvent]: 186 | if "event_id." in self.identifier: 187 | event_id = EventID(self.identifier[9:]) 188 | message_event = await self.hasswebhook.client.get_event(room_id=self.room_id, event_id=event_id) 189 | self.hasswebhook.log.info( 190 | f"[search_history_for_event] event_id: {event_id}: message_event: {message_event}") 191 | if not message_event: 192 | self.hasswebhook.log.error("Could not find a matching event for event_id.") 193 | return message_event 194 | 195 | self.hasswebhook.log.debug(f"Searching for message_event... {self.identifier}") 196 | sync_result = await self.hasswebhook.client.sync() 197 | prev_batch = sync_result.get("rooms").get("join").get( 198 | self.room_id).get("timeline").get("prev_batch") 199 | encrypted_eventlist = [] 200 | get_messages_result_FW = await self.hasswebhook.client.get_messages( 201 | room_id=self.room_id, 202 | direction=PaginationDirection.FORWARD, 203 | from_token=prev_batch, 204 | limit=100 205 | ) 206 | encrypted_eventlist.extend(get_messages_result_FW.events) 207 | encrypted_eventlist = list(reversed(encrypted_eventlist)) 208 | 209 | start = get_messages_result_FW.start 210 | for x in range(10): 211 | get_messages_result_BW = await self.hasswebhook.client.get_messages( 212 | room_id=self.room_id, 213 | direction=PaginationDirection.BACKWARD, 214 | from_token=start, 215 | limit=100 216 | ) 217 | encrypted_eventlist.extend(get_messages_result_BW.events) 218 | start = get_messages_result_BW.end 219 | 220 | eventlist = [] 221 | for encrypted_event in encrypted_eventlist: 222 | try: 223 | event = await self.hasswebhook.client.crypto.decrypt_megolm_event(encrypted_event) 224 | if event.sender == self.hasswebhook.client.mxid: 225 | eventlist.append(event) 226 | except: 227 | continue 228 | 229 | message_event: MaubotMessageEvent = None 230 | for event in eventlist: 231 | evt_content: MessageEventContent = event.content 232 | if self.identifier in evt_content.body: 233 | message_event = MaubotMessageEvent( 234 | base=event, client=self.hasswebhook.client) 235 | break 236 | if not message_event: 237 | self.hasswebhook.log.error("Could not find a matching event.") 238 | return 239 | 240 | self.hasswebhook.log.debug(f"Found message_event: {message_event.event_id}") 241 | return message_event 242 | -------------------------------------------------------------------------------- /hasswebhook/setupinstructions.py: -------------------------------------------------------------------------------- 1 | # Generate a beautiful setup instruction with individual elements 2 | class HassWebhookSetupInstructions: 3 | webhook_url: str 4 | webhook_url_cli: str 5 | message_plain: str 6 | curl_data: str = "{\\\"message\\\": \\\"Foo bar\\\", \\\"type\\\": \\\"message\\\", \\\"identifier\\\": \\\"foo.bar\\\"}" 7 | message_md: str 8 | 9 | def __init__(self, base_url: str, bot_id: str, room_id: str): 10 | self.webhook_url = base_url + "_matrix/maubot/plugin/" + bot_id + "/push/" + room_id 11 | self.webhook_url_cli = base_url + \ 12 | "_matrix/maubot/plugin/" + bot_id + "/push/\\" + room_id 13 | self.message_plain = ( 14 | "Your Webhook-URL is: {webhook_url}".format(webhook_url=self.webhook_url)) 15 | self.message_md = ( 16 | """Your webhook-URL is: 17 | {webhook_url}\n 18 | 19 | Write this in your `configuration.yaml` on HA (don't forget to reload): 20 | ```yaml 21 | notify: 22 | - name: HASS_MAUBOT 23 | platform: rest 24 | resource: \"{webhook_url}\" 25 | method: POST_JSON 26 | data: 27 | type: \"{data_type}\" 28 | identifier: \"{data_identifier}\" 29 | callback_url: \"{data_callback_url}\" 30 | lifetime: \"{data_lifetime}\" 31 | 32 | ``` 33 | \n\n 34 | Use this yaml to send a notification from homeassistant: 35 | ```yaml 36 | service: notify.hass_maubot 37 | data: 38 | message: Die Post ist da! 📬 39 | data: 40 | type: message / reaction / redaction / edit 41 | identifier: letterbox.status / eventID.xyz 42 | callback_url: https:///api/webhook/ 43 | lifetime: 1440 44 | ``` 45 | 46 | Use this to redact the last message with a given identifier: 47 | ```yaml 48 | service: notify.hass_maubot 49 | data: 50 | message: None 51 | data: 52 | type: redaction 53 | identifier: letterbox.status 54 | ``` 55 | 56 | Use this to test the webhook via cli: 57 | ```zsh 58 | curl -d "{curl_data}" -X POST "{webhook_url_cli}" 59 | ``` 60 | """.format( 61 | webhook_url=self.webhook_url, 62 | curl_data=self.curl_data, 63 | webhook_url_cli=self.webhook_url_cli, 64 | data_type="{{data.type}}", 65 | data_identifier="{{data.identifier}}", 66 | data_callback_url="{{data.callback_url}}", 67 | data_lifetime="{{data.lifetime}}")) 68 | 69 | def __str__(self) -> str: 70 | return self.message_md 71 | 72 | # Plain only contains the webhook-url of that specific room 73 | 74 | def plain(self) -> str: 75 | return self.message_plain 76 | 77 | # Markdown-formatted message also contains setup instructions 78 | 79 | def md(self) -> str: 80 | return self.message_md 81 | -------------------------------------------------------------------------------- /maubot.yaml: -------------------------------------------------------------------------------- 1 | maubot: 0.1.0 2 | id: com.valentinriess.hasswebhook 3 | version: 0.0.15 4 | license: MIT 5 | modules: 6 | - hasswebhook 7 | main_class: HassWebhook 8 | config: true 9 | extra_files: 10 | - base-config.yaml 11 | database: true 12 | webapp: true 13 | soft_dependencies: 14 | - Pillow 15 | dependencies: 16 | - Markdown 17 | - pytz --------------------------------------------------------------------------------