├── .dockerignore ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── bubo.png ├── bubo ├── __init__.py ├── api │ └── pindora.py ├── bot_commands.py ├── callbacks.py ├── chat_functions.py ├── config.py ├── discourse.py ├── email_strings.py ├── emails.py ├── errors.py ├── help_strings.py ├── message_responses.py ├── migrations │ ├── 001.py │ ├── 002.py │ ├── 003.py │ ├── 004.py │ ├── 005.py │ ├── 006.py │ ├── 007.py │ ├── 008.py │ ├── 009.py │ ├── 010.py │ └── __init__.py ├── rooms.py ├── storage.py ├── synapse_admin.py ├── users.py └── utils.py ├── docker └── build_and_install_libolm.sh ├── main.py ├── requirements.in ├── requirements.txt └── sample.config.yaml /.dockerignore: -------------------------------------------------------------------------------- 1 | store/ 2 | bot.db 3 | config.yaml 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # PyCharm 2 | .idea/ 3 | 4 | # Python virtualenv environment folders 5 | env/ 6 | env3/ 7 | .env/ 8 | 9 | # Bot local files 10 | *.db 11 | 12 | # Config file 13 | config.yaml 14 | 15 | # Python 16 | __pycache__/ 17 | 18 | # Config file 19 | config.yaml 20 | 21 | # Log files 22 | *.log 23 | 24 | store/ 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## unreleased 4 | 5 | ### Added 6 | 7 | * Add `spaces` command. Mirrors `rooms` command for subcommands and functionality, with the 8 | exception that the created room will be of type Space. 9 | 10 | * Added rooms `link` and `link-and-admin` subcommands. Both variants make Bubo attempt 11 | to join the room and store the room in the database to start tracking it. If Bubo 12 | is Synapse admin and/or admin permissions is requested, it will also try to join and/or 13 | make itself admin using the Synapse admin API. 14 | 15 | * Add `rooms` subcommand `alias`. Allows maintaining aliases for rooms, including 16 | adding, removing and setting the main alias. 17 | 18 | * Add `join` command, which allows (Synapse admin) force joining users to rooms or as 19 | a fallback inviting users to rooms. 20 | 21 | * Add `groupinvite` command, which allows inviting a user to a group 22 | of rooms. Room groups are preconfigured in the config file. 23 | 24 | * Add `users` subcommand `rooms`. Lists users rooms on the server Bubo is installed on. 25 | Requires Bubo being Synapse admin and also requires Bubo admin permissions. 26 | 27 | * Added `pindora` command to manage Pindora keys. 28 | 29 | ### Changed 30 | 31 | * Allow removing room encryption by recreating with `rooms recreate-unencrypted` command. 32 | 33 | * The `invite` command will now check the user exists before sending an invitation. 34 | 35 | * Use `pip-tools` to lock dependencies. 36 | 37 | ### Fixed 38 | 39 | * Force `charset_normalizer` dependency logs to `warning` level to avoid spammy info 40 | logs about probing the chaos when the Matrix server is unavailable. 41 | 42 | * Added an extra member count check to when determining whether to consider a room 43 | a one to one direct message, which ignores needing a command prefix. This is to 44 | hopefully mitigate race conditions where Bubo is starting up and hasn't yet 45 | determined the state of the room. 46 | 47 | * Fixed incorrect check for invalid coordinator room / user ID's when loading config ([#17](https://github.com/elokapina/bubo/pull/17)). 48 | 49 | * Fixed rare bug that caused Bubo to suddenly start to react to all messages in non-DM 50 | rooms even when command prefix was not used ([#17](https://github.com/elokapina/bubo/issues/18)). 51 | 52 | * Fixed config check failing in the case that the default is `False` and config is not required. 53 | 54 | ### Removed 55 | 56 | * Removed any attempts to maintain room encryption for old rooms on startup. This code 57 | seems to have been broken and seems like a footgun waiting to happen. It should 58 | be replaced with a manual "encrypt room" command if needed. 59 | 60 | * Removed the `communities` command. Existing configured communities will not be tracked. 61 | 62 | ## v0.3.0 - 2022-01-23 63 | 64 | ### Added 65 | 66 | * Default power levels can now be configured in config as `rooms.power_levels` (see 67 | sample config file). If defined, they will be used in room creation power level 68 | overrides. By default, they will also be enforced on old rooms, unless 69 | `rooms.enforce_power_in_old_rooms` is set to `false`. 70 | 71 | * Add config option `callbacks.unable_to_decrypt_responses` to allow disabling 72 | the room reply to messages that Bubo cannot decrypt. 73 | 74 | * Add command `power` to set power levels in a room where the bot has the 75 | required power. 76 | 77 | * Add command `users` to interact with an identity provider. Currently, only Keycloak 78 | is supported. Supported features is listing, creating and inviting new users. 79 | Created users will be sent a password reset email, and their email will be 80 | marked as verified. Invitation allows the invited user to choose their own username. 81 | Support also exists for creating self-service signup links. 82 | 83 | Note, the invite and self-service signup link creation commands 84 | require an instance of [keycloak-signup](https://github.com/elokapina/keycloak-signup). 85 | 86 | * Logging to a Matrix room is now possible. User access token must be set for the logging 87 | to be used. 88 | 89 | * Allow configuring admins and coordinators based on room membership. Both config lists 90 | now accept a room ID whose members will be added as admins or coordinators when checking 91 | access rights for commands or when setting power levels in rooms. 92 | 93 | * Added rooms `list-no-admin` subcommand. Lists all the rooms that Bubo should be maintaining, 94 | but which is lacks admin rights to do so for. 95 | 96 | * Added rooms `recreate` subcommand. Recreates a room, specifically designed for the case where 97 | admin permissions have been lost and a new room is needed. 98 | 99 | * Added rooms `unlink` and `unlink-and-leave` subcommands. The first variant unlinks a room 100 | tracked by Bubo, the second also leaves the room. 101 | 102 | * When receiving an event the bot cannot decrypt, the event will now be stored for 103 | later. When keys are received later matching any stored encrypted events, a new attempt 104 | will be made to decrypt them. 105 | 106 | ### Changed 107 | 108 | * Message edits are now understood as new commands from clients that send them 109 | prefixed with ` * ` (for example Element). 110 | 111 | * Invite command no longer requires room to be maintained by Bubo. It's enough now that 112 | Bubo is in the room and able to invite to it. It also now works with room ID in 113 | addition to alias. 114 | 115 | * Produce a more useful log error when Bubo fails to decrypt an event. 116 | 117 | ### Fixed 118 | 119 | * Don't fail to start up if `matrix.user_token` is not set (but password is). 120 | 121 | * Don't crash in `set_user_power` if bot not in room. 122 | 123 | ### Deprecated 124 | 125 | * Communities support is deprecated. The `communities` command will be removed in Bubo v0.4.0. 126 | 127 | ### Removed 128 | 129 | * Removed the `power_to_write` override in the `rooms` database table. Rooms 130 | can no longer for now have custom power levels enforced by Bubo on a per room basis. 131 | 132 | ## v0.2.0 - 2020-10-30 133 | 134 | ### Added 135 | 136 | * New command `breakout`. Allows splitting discussion into breakout rooms. 137 | 138 | ## v0.1.0 - 2020-10-24 139 | 140 | Initial version with the following commands: 141 | * rooms (list and create) 142 | * communities (list and create) 143 | * invite (self or others) 144 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # To build the image, run `docker build` command from the root of the 2 | # repository: 3 | # 4 | # docker build . 5 | # 6 | # There is an optional PYTHON_VERSION build argument which sets the 7 | # version of python to build against. For example: 8 | # 9 | # docker build --build-arg PYTHON_VERSION=3.8 . 10 | # 11 | # An optional LIBOLM_VERSION build argument which sets the 12 | # version of libolm to build against. For example: 13 | # 14 | # docker build --build-arg LIBOLM_VERSION=3.1.4 . 15 | # 16 | 17 | 18 | ## 19 | ## Creating a builder container 20 | ## 21 | 22 | # We use an initial docker container to build all of the runtime dependencies, 23 | # then transfer those dependencies to the container we're going to ship, 24 | # before throwing this one away 25 | ARG PYTHON_VERSION=3.8 26 | FROM docker.io/python:${PYTHON_VERSION}-alpine3.11 as builder 27 | 28 | ## 29 | ## Build libolm for matrix-nio e2e support 30 | ## 31 | 32 | # Install libolm build dependencies 33 | ARG LIBOLM_VERSION=3.2.1 34 | RUN apk add --no-cache \ 35 | make \ 36 | cmake \ 37 | gcc \ 38 | g++ \ 39 | git \ 40 | libffi-dev \ 41 | yaml-dev \ 42 | python3-dev 43 | 44 | # Build libolm 45 | # 46 | # Also build the libolm python bindings and place them at /python-libs 47 | # We will later copy contents from both of these folders to the runtime 48 | # container 49 | COPY docker/build_and_install_libolm.sh /scripts/ 50 | RUN /scripts/build_and_install_libolm.sh ${LIBOLM_VERSION} /python-libs 51 | 52 | RUN mkdir -p /app 53 | 54 | COPY requirements.txt /app 55 | 56 | WORKDIR /app 57 | 58 | RUN pip install --prefix="/python-libs" --no-warn-script-location -r requirements.txt 59 | 60 | ## 61 | ## Creating the runtime container 62 | ## 63 | 64 | # Create the container we'll actually ship. We need to copy libolm and any 65 | # python dependencies that we built above to this container 66 | FROM docker.io/python:${PYTHON_VERSION}-alpine3.11 67 | 68 | # Copy python dependencies from the "builder" container 69 | COPY --from=builder /python-libs /usr/local 70 | 71 | # Copy libolm from the "builder" container 72 | COPY --from=builder /usr/local/lib/libolm* /usr/local/lib/ 73 | 74 | # Install any native runtime dependencies 75 | RUN apk add --no-cache \ 76 | libstdc++ 77 | 78 | WORKDIR /app 79 | 80 | # Copy app files 81 | COPY *.py *.md /app/ 82 | COPY bubo/ /app/bubo/ 83 | 84 | CMD python main.py /config/config.yaml 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bubo 2 | 3 | [![#bubo:elokapina.fi](https://img.shields.io/matrix/bubo:elokapina.fi.svg?label=%23bubo%3Aelokapina.fi&server_fqdn=matrix.elokapina.fi)](https://matrix.to/#/#bubo:elokapina.fi) [![docker pulls](https://badgen.net/docker/pulls/elokapinaorg/bubo)](https://hub.docker.com/r/elokapinaorg/bubo) [![License:Apache2](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 4 | 5 | Matrix bot to help with community management. Can create and maintain rooms and help with 6 | room memberships. 7 | 8 | Created with love and rage by [Elokapina](https://elokapina.fi) (Extinction Rebellion Finland). 9 | 10 | [![bubo](bubo.png)](https://clash-of-the-titans.fandom.com/wiki/Bubo) 11 | 12 | Based on [nio-template](https://github.com/anoadragon453/nio-template), a template project for Matrix bots. 13 | 14 | ## Installation 15 | 16 | ### Docker (recommended) 17 | 18 | Docker images [are available](https://hub.docker.com/r/elokapinaorg/bubo). 19 | 20 | An example configuration file is provided as `sample.config.yaml`. 21 | 22 | Make a copy of that, edit as required and mount it to `/config/config.yaml` on the Docker container. 23 | 24 | You'll also need to give the container a folder for storing state. Create a folder, ensure 25 | it's writable by the user the container process is running as and mount it to `/data`. 26 | 27 | Example: 28 | 29 | ```bash 30 | cp sample.config.yaml config.yaml 31 | # Edit config.yaml, see the file for details 32 | mkdir data 33 | docker run -v ${PWD}/config.docker.yaml:/config/config.yaml:ro \ 34 | -v ${PWD}/data:/data --name bubo elokapinaorg/bubo 35 | ``` 36 | 37 | ### Source 38 | 39 | * Install any system dependencies by copying what the Dockerfile does 40 | * Create a Python 3.8+ virtualenv 41 | * Do `pip install -U pip setuptools pip-tools` 42 | * Do `pip-sync` 43 | * Copy the example `sample.config.yaml` file into `config.yaml` and edit 44 | * Run with `python main.py config.yaml` 45 | 46 | ## Usage 47 | 48 | ### Talking to Bubo 49 | 50 | Either prefix commands with `!bubo` (or another configured prefix) in a room Bubo is in or 51 | start a direct chat with it. When talking with Bubo directly, you don't need 52 | the `!bubo` prefix for commands. 53 | 54 | ### Getting Bubo into a room 55 | 56 | Bubo will automatically join rooms it is invited to. 57 | 58 | ### Commands 59 | 60 | #### `breakout` 61 | 62 | Creates a breakout room. The user who requested the breakout room creation will 63 | automatically be invited to the room and made admin. The room will be created 64 | as non-public and non-encrypted. 65 | 66 | Other users can react to the breakout room creation response with any emoji reaction to 67 | get an invite to the room. 68 | 69 | Syntax: 70 | 71 | breakout TOPIC 72 | 73 | For example: 74 | 75 | breakout How awesome is Bubo? 76 | 77 | Any remaining text after `breakout` will be used as the room name. 78 | 79 | Note that while Bubo will stay in the breakout room itself, it will not maintain 80 | it in any way like the rooms created using the `rooms` command. 81 | 82 | #### `groupinvite` 83 | 84 | Invite a user to a predefined group of rooms. 85 | 86 | Syntax: 87 | 88 | groupinvite @user1:domain.tld groupname [subgroups] 89 | 90 | Where "groupname" should be replaced with the group to join to. Additionally, 91 | subgroups can be given as well. 92 | 93 | Groups must be configured to the Bubo configuration file by an administrator. 94 | 95 | This command requires coordinator level permissions. 96 | 97 | #### `help` 98 | 99 | Shows a help! Each command can also be given a subcommand `help`, to make 100 | Bubo kindly give some usage instructions. 101 | 102 | #### `invite` 103 | 104 | Invite to rooms. 105 | 106 | When given with only a room alias or ID parameter, invites you to that room. 107 | 108 | To invite other users, give one or more user ID's (separated by a space) after 109 | the room alias or ID. 110 | 111 | Examples: 112 | 113 | * Invite yourself to a room: 114 | 115 | `invite #room:example.com` 116 | 117 | * Invite one or more users to a room: 118 | 119 | `invite #room:example.com @user1:example.com @user2:example.org` 120 | 121 | Requires bot coordinator privileges. The bot must be in the room 122 | and with power to invite users. 123 | 124 | #### `join` 125 | 126 | Join one or more users to rooms. 127 | 128 | Syntax: 129 | 130 | join !roomidoralias:domain.tld @user1:domain.tld @user2:domain.tld 131 | 132 | If Bubo has Synapse admin powers, it will try to join admin API join any local users 133 | (which still requires Bubo to be in the room and be able to invite). 134 | Otherwise, a normal onvitation is used. 135 | 136 | This command requires coordinator level permissions. 137 | 138 | #### `power` 139 | 140 | Set power level in a room. Usage: 141 | 142 | `power []` 143 | 144 | * `user` is the user ID, example `@user:example.tld` 145 | * `room` is a room alias or ID, example `#room:example.tld`. Bot must have power to give power there. 146 | * `level` is optional and defaults to `moderator`. 147 | 148 | Moderator rights can be given by coordinator level users. To give admin in a room, user must be admin of the bot. 149 | 150 | #### `rooms` and `spaces` 151 | 152 | Maintains rooms and spaces. 153 | 154 | When given without parameters, Bubo will tell you about the rooms or spaces it maintains. 155 | 156 | Subcommands below. 157 | 158 | ##### `alias` - Manage room and space aliases 159 | 160 | Manage room or space aliases. 161 | 162 | Allows adding and removing aliases, and setting the main (canonical) alias of a room or space. 163 | 164 | Format: 165 | 166 | rooms/spaces alias !roomidoralias:domain.tld subcommand #alias:domain.tld 167 | 168 | Where "subcommand" can be one of: "add", "remove", "main". 169 | 170 | Examples: 171 | 172 | rooms/spaces alias !roomidoralias:domain.tld add #alias:domain.tld 173 | rooms/spaces alias !roomidoralias:domain.tld remove #alias:domain.tld 174 | rooms/spaces alias !roomidoralias:domain.tld main #alias:domain.tld 175 | 176 | Notes: 177 | 178 | * "remove" cannot remove the main alias. First add another alias and set it main alias. 179 | * "main" can only handle aliases for the same domain Bubo runs on currently. This may change in the future. 180 | 181 | This command requires coordinator level permissions. 182 | 183 | ##### `create` - Create room or space 184 | 185 | Syntax: 186 | 187 | rooms/spaces create NAME ALIAS TITLE ENCRYPTED(yes/no) PUBLIC(yes/no) 188 | 189 | Example: 190 | 191 | rooms/spaces create "My awesome room" epic-room "The best room ever!" yes no 192 | 193 | Note, ALIAS should only contain lower case ascii characters and dashes. 194 | ENCRYPTED and PUBLIC are either 'yes' or 'no'. 195 | 196 | ##### `link` and `link-and-admin` - Register rooms or spaces with Bubo 197 | 198 | Both variants make Bubo attempt to join the room or space and store the room or space in 199 | the database to start tracking it. If Bubo is Synapse admin and/or admin permissions is requested, 200 | it will also try to join and/or make itself admin using the Synapse admin API. 201 | 202 | The only parameter is a room/space ID or alias. 203 | 204 | ##### `list` - List rooms/spaces 205 | 206 | Same as without a subcommand, Bubo will tell you all about the rooms or spaces it maintains. 207 | 208 | ##### `list-no-admin` - List rooms/spaces without Bubo admin privileges 209 | 210 | List any rooms or spaces Bubo maintains where Bubo lacks admin privileges. 211 | 212 | ##### `recreate` - Recreate a room or space 213 | 214 | Recreate a room or space. This is a bit like the room upgrade functionality in Element, but it's designed to 215 | also work when admin power levels have been lost in the room, and thus an upgrade cannot be done. 216 | In short the command will: 217 | 218 | * Create a new room mirroring the current room 219 | * Rename the old room with a prefix (defaults to "OLD", see config to change it) 220 | * Invite all room members (including those in invite status) to the new room 221 | * Try to force join local server members using the Synapse admin API if Bubo has 222 | been defined as Synapse admin in config (and of course in Synapse too) 223 | * Post a message to both rooms, linking them together 224 | * Optionally join a configured secondary admin to the room 225 | 226 | After giving the command in the room, Bubo will ask for confirmation. 227 | 228 | **NOTE** This command cannot be reversed so should be used with care. The old room will however 229 | stay as it is, so in problem cases it should be enough to just rename the old room back. 230 | 231 | This command requires Bubo admin privileges. It does not require Bubo to have any special power 232 | in the room to be recreated. 233 | 234 | ##### `unlink` - Unregister a room or space 235 | 236 | Remove the room or space from Bubo's room database. The only parameter is a room/space ID or alias. 237 | 238 | ##### `unlink-and-leave` - Unregister a room or space, and leave it 239 | 240 | Remove the room or space from Bubo's room database, then leave the room or space. 241 | The only parameter is a room/space ID or alias. 242 | 243 | #### `users` 244 | 245 | Manage users of an identity provider. 246 | 247 | Currently [Keycloak](https://www.keycloak.org/) is the only identity provider supported. 248 | See `sample.config.yaml` for how to configure a Keycloak client. 249 | 250 | Subcommands: 251 | 252 | ##### `list` (or no subcommand) 253 | 254 | List currently registered users. Requires admin level permissions. 255 | 256 | ##### `create` 257 | 258 | Creates users for the given emails and sends them a password reset email. The users 259 | email will be marked as verified. Give one or more emails as parameters. Requires admin level permissions. 260 | 261 | ##### `invite` 262 | 263 | Send an invitation to the email(s) given for self-registration. Requires 264 | an instance of [keycloak-signup](https://github.com/elokapina/keycloak-signup). 265 | The invitation will contain a one-time link valid for 7 days. Requires coordinator level permissions. 266 | 267 | ##### `rooms` 268 | 269 | List the rooms of a user. 270 | 271 | Usage: 272 | 273 | users rooms @user:domain.tld 274 | 275 | Requires bot admin permissions. Bubo must also be a Synapse admin. 276 | 277 | ##### `signuplink` 278 | 279 | Create a self-service signup link with a chosen amount of maximum signups 280 | and days of validity. Requires an instance of 281 | [keycloak-signup](https://github.com/elokapina/keycloak-signup). 282 | Requires coordinator level permissions. 283 | 284 | ### Room power levels 285 | 286 | #### User power 287 | 288 | Bubo can be picky on who can have power in a room. All rooms that it maintains (ie the rooms 289 | stored in it's database) will be checked on start-up and Bubo can be made to promote or demote 290 | users to their correct level, using the following rules: 291 | 292 | * Users marked as `admin` in the config will get power level 50, if in the room 293 | * Users marked as `coordinator` in the config will get power level 50, if in the room 294 | * Everybody else will get power level 0 295 | 296 | Bubo can be told to not demote or promote users in the config. By default it will 297 | promote but not demote users in the room. Users not in the room who have too much power 298 | will always be demoted. 299 | 300 | Currently it's not possible to override this on a per room basis but is likely to come. 301 | 302 | #### Room power defaults 303 | 304 | The sample config contains `room.power_levels` for the default power levels that 305 | Bubo will use for new rooms. By default, it will also enforce these power levels on 306 | old rooms, unless told not to. 307 | 308 | ### Room and space maintenance 309 | 310 | When Bubo starts, it will go through the rooms and spaces it maintains (see above 311 | commands). It will currently ensure the following details are correct: 312 | 313 | * Room or space exists (tip! you can mass-create rooms/spaces by inserting them to 314 | the database without an ID and restarting) 315 | * Ensure rooms/spaces marked as encrypted are encrypted 316 | * Ensure room/spaces power levels (see above "Room power levels") 317 | 318 | ## Development 319 | 320 | If you need help or want to otherwise chat, jump to `#bubo:elokapina.fi`! 321 | 322 | ### Dependencies 323 | 324 | * Create a Python 3.8+ virtualenv 325 | * Do `pip install -U pip setuptools pip-tools` 326 | * Do `pip-sync` 327 | 328 | To update dependencies, do NOT edit `requirements.txt` directly. Any changes go into 329 | `requirements.in` and then you run `pip-compile`. If you want to upgrade existing 330 | non-pinned (in `requirements.in`) dependencies, run `pip-compile --upgrade`, keeping 331 | the ones that you want to update in `requirements.txt` when commiting. See more info 332 | about `pip-tools` at https://github.com/jazzband/pip-tools 333 | 334 | 335 | ### Releasing 336 | 337 | * Update `CHANGELOG.md` 338 | * Commit changelog 339 | * Make a tag 340 | * Push the tag 341 | * Make a GitHub release, copy the changelog for the release there 342 | * Build a docker image 343 | * `docker build . -t elokapinaorg/bubo:v` 344 | * Push docker image 345 | * Update topic in `#bubo:elokapina.fi` 346 | * Consider announcing on `#thisweekinmatrix:matrix.org` \o/ 347 | 348 | ## TODO 349 | 350 | Add more features! Bubo wants to help you manage your community! 351 | 352 | ## License 353 | 354 | Apache2 355 | -------------------------------------------------------------------------------- /bubo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elokapina/bubo/abf314818af509415fbf6c1fab1d05a4e77ab5ad/bubo.png -------------------------------------------------------------------------------- /bubo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elokapina/bubo/abf314818af509415fbf6c1fab1d05a4e77ab5ad/bubo/__init__.py -------------------------------------------------------------------------------- /bubo/api/pindora.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from datetime import datetime, timedelta 4 | from pytz import timezone 5 | 6 | 7 | def get_headers(pindora_token): 8 | headers = { 9 | 'Pindora-Api-Key': f'{pindora_token}', 10 | 'Content-Type': 'application/vdn.pindora.v1+json' 11 | } 12 | return headers 13 | 14 | 15 | def create_new_key(pindora_id, pindora_token, name, pindora_timezone=None, hours=3): 16 | url = "https://admin.pindora.fi/api/integration/pins" 17 | if pindora_timezone is not None: 18 | now = datetime.now(timezone(pindora_timezone)) 19 | else: 20 | now = datetime.now() 21 | 22 | to = now + timedelta(hours=hours) 23 | 24 | payload = json.dumps({ 25 | "name": name, 26 | "validity_rules": [{ 27 | "pindora": { 28 | "id": f"{pindora_id}", 29 | }, 30 | "date_from": now.astimezone().isoformat('T', 'seconds'), 31 | "date_to": to.astimezone().isoformat('T', 'seconds') 32 | }], 33 | "magic_enabled": True, 34 | }) 35 | 36 | response = requests.request( 37 | "POST", url, headers=get_headers(pindora_token), data=payload) 38 | response.raise_for_status() 39 | response_json = response.json() 40 | 41 | magic_url = None 42 | if "code" in response_json: 43 | code = response_json["code"] 44 | else: 45 | raise Exception("Code not found in Pindora API resopnse") 46 | if "allowed_for_pindoras" in response_json: 47 | allowed_for_pindoras = response_json["allowed_for_pindoras"] 48 | if len(allowed_for_pindoras) > 0: 49 | if "magic_url" in allowed_for_pindoras[0]: 50 | magic_url = allowed_for_pindoras[0]["magic_url"] 51 | 52 | return code, magic_url 53 | -------------------------------------------------------------------------------- /bubo/bot_commands.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | import re 4 | import time 5 | from typing import List, Union, Dict, Tuple 6 | 7 | from email_validator import validate_email, EmailNotValidError 8 | # noinspection PyPackageRequirements 9 | from nio import ( 10 | RoomPutStateError, RoomGetStateEventError, RoomPutStateResponse, ProtocolError, JoinedRoomsError, 11 | JoinError, RoomGetStateEventResponse, RoomInviteError, 12 | ) 13 | # noinspection PyPackageRequirements 14 | from nio.schemas import check_user_id 15 | 16 | from bubo import help_strings 17 | from bubo.chat_functions import send_text_to_room, invite_to_room 18 | from bubo.discourse import Discourse 19 | from bubo.rooms import ( 20 | ensure_room_exists, create_breakout_room, set_user_power, get_room_power_levels, recreate_room, 21 | add_alias, remove_alias, set_canonical_alias, 22 | ) 23 | from bubo.synapse_admin import make_room_admin, join_users, get_user_rooms 24 | from bubo.users import list_users, get_user_by_attr, create_user, send_password_reset, invite_user, create_signup_link 25 | from bubo.utils import get_users_for_access, with_ratelimit, ensure_room_id 26 | from bubo.api.pindora import create_new_key 27 | 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | TEXT_PERMISSION_DENIED = "I'm afraid I cannot let you do that." 32 | 33 | 34 | class Command(object): 35 | def __init__(self, client, store, config, command, room, event): 36 | """A command made by a user 37 | 38 | Args: 39 | client (nio.AsyncClient): The client to communicate to matrix with 40 | 41 | store (Storage): Bot storage 42 | 43 | config (Config): Bot configuration parameters 44 | 45 | command (str): The command and arguments 46 | 47 | room (nio.rooms.MatrixRoom): The room the command was sent in 48 | 49 | event (nio.events.room_events.RoomMessageText): The event describing the command 50 | """ 51 | self.client = client 52 | self.store = store 53 | self.config = config 54 | self.command = command 55 | self.room = room 56 | self.event = event 57 | self.args = self.command.split()[1:] 58 | 59 | async def _ensure_admin(self) -> bool: 60 | admin_users = await get_users_for_access(self.client, self.config, "admins") 61 | if self.event.sender not in admin_users: 62 | await send_text_to_room( 63 | self.client, 64 | self.room.room_id, 65 | f"{TEXT_PERMISSION_DENIED} Admin level access needed.", 66 | ) 67 | return False 68 | return True 69 | 70 | async def _ensure_coordinator(self) -> bool: 71 | allowed_users = await get_users_for_access(self.client, self.config, "coordinators") 72 | if self.event.sender not in allowed_users: 73 | await send_text_to_room( 74 | self.client, 75 | self.room.room_id, 76 | f"{TEXT_PERMISSION_DENIED} Coordinator level access needed.", 77 | ) 78 | return False 79 | return True 80 | 81 | async def _ensure_pindora_user(self) -> bool: 82 | pindora_users = await get_users_for_access(self.client, self.config, "pindora_users") 83 | if self.event.sender not in pindora_users: 84 | await send_text_to_room( 85 | self.client, 86 | self.room.room_id, 87 | f"{TEXT_PERMISSION_DENIED} Pindora level access needed.", 88 | ) 89 | return False 90 | return True 91 | 92 | async def process(self): 93 | """Process the command""" 94 | if self.command.startswith("breakout"): 95 | await self._breakout() 96 | elif self.command.startswith("discourse"): 97 | await self._discourse() 98 | elif self.command.startswith("groupinvite"): 99 | await self._groupinvite() 100 | elif self.command.startswith("groupjoin"): 101 | await self._groupinvite() 102 | elif self.command.startswith("help"): 103 | await self._show_help() 104 | elif self.command.startswith("invite"): 105 | await self._invite() 106 | elif self.command.startswith("join"): 107 | await self._join() 108 | elif self.command.startswith("power"): 109 | await self._power() 110 | elif self.command.startswith("rooms"): 111 | await self._rooms() 112 | elif self.command.startswith("spaces"): 113 | await self._rooms(space=True) 114 | elif self.command.startswith("users"): 115 | await self._users() 116 | elif self.command.startswith("pindora"): 117 | await self._pindora() 118 | else: 119 | await self._unknown_command() 120 | 121 | async def _alias(self): 122 | """ 123 | Maintain room aliases. 124 | """ 125 | if len(self.args) < 4: 126 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_ROOMS_ALIAS) 127 | return 128 | 129 | room_alias_or_id = self.args[1] 130 | subcommand = self.args[2] 131 | alias = self.args[3] 132 | 133 | if subcommand == "add": 134 | try: 135 | await add_alias(room_alias_or_id=room_alias_or_id, alias=alias, client=self.client) 136 | except Exception as ex: 137 | # TODO this doesn't seem to fire. the error code is 409 but no expection is raised 138 | await send_text_to_room( 139 | self.client, 140 | self.room.room_id, 141 | f"Failed to add alias to room {room_alias_or_id}: {ex}" 142 | ) 143 | else: 144 | await send_text_to_room( 145 | self.client, 146 | self.room.room_id, 147 | f"Alias {alias} added to {room_alias_or_id}" 148 | ) 149 | return 150 | elif subcommand == "remove": 151 | try: 152 | await remove_alias(room_alias_or_id=room_alias_or_id, alias=alias, client=self.client) 153 | except Exception as ex: 154 | await send_text_to_room( 155 | self.client, 156 | self.room.room_id, 157 | f"Failed to remove alias from room {room_alias_or_id}: {ex}" 158 | ) 159 | else: 160 | await send_text_to_room( 161 | self.client, 162 | self.room.room_id, 163 | f"Alias {alias} removed from {room_alias_or_id}" 164 | ) 165 | return 166 | elif subcommand == "main": 167 | try: 168 | await set_canonical_alias( 169 | room_alias_or_id=room_alias_or_id, alias=alias, client=self.client, store=self.store, 170 | config=self.config, 171 | ) 172 | except Exception as ex: 173 | await send_text_to_room( 174 | self.client, 175 | self.room.room_id, 176 | f"Failed to set main alias for room {room_alias_or_id}: {ex}" 177 | ) 178 | else: 179 | await send_text_to_room( 180 | self.client, 181 | self.room.room_id, 182 | f"Alias {alias} set as main alias for {room_alias_or_id}" 183 | ) 184 | return 185 | 186 | async def _breakout(self): 187 | """Create a breakout room""" 188 | if not self.args or self.args[0] == "help": 189 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_BREAKOUT) 190 | elif self.args: 191 | name = ' '.join(self.args) 192 | logger.debug(f"Breakout room name: '{name}'") 193 | room_id = await create_breakout_room( 194 | name=name, 195 | client=self.client, 196 | created_by=self.event.sender, 197 | ) 198 | text = f"Breakout room '{name}' created!\n" 199 | text += "\n\nReact to this message with any emoji reaction to get invited to the room." 200 | event_id = await send_text_to_room(self.client, self.room.room_id, text) 201 | if event_id: 202 | self.store.store_breakout_room(event_id, room_id) 203 | else: 204 | text = "*Error: failed to store breakout room data. The room was created, " \ 205 | "but invites via reactions will not work.*" 206 | await send_text_to_room(self.client, self.room.room_id, text) 207 | 208 | async def _discourse(self): 209 | """Discourse integration""" 210 | if not await self._ensure_admin(): 211 | return 212 | 213 | if not self.args or self.args[0] != "sync": 214 | await send_text_to_room(self.client, self.room.room_id, "WIP, try 'sync'") 215 | return 216 | 217 | discourse = Discourse() 218 | await discourse.sync_groups_as_spaces(self.client, self.store) 219 | 220 | async def _groupinvite(self): 221 | """ 222 | Invite user to a predefined group of rooms. 223 | """ 224 | if not await self._ensure_coordinator(): 225 | return 226 | 227 | if len(self.args) < 2: 228 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_GROUPINVITE) 229 | return 230 | 231 | user = self.args[0] 232 | groups = self.args[1:] 233 | 234 | async def _get_group_rooms(group: Union[List, Dict], remaining_groups: Tuple, room_list: List) -> List: 235 | new_room_list = room_list[:] 236 | if isinstance(group, dict): 237 | new_room_list.extend(group.get("__all__", [])) 238 | if remaining_groups: 239 | next_group = group.get(remaining_groups[0]) 240 | if not next_group: 241 | await send_text_to_room( 242 | self.client, self.room.room_id, 243 | f"Invalid group '{remaining_groups[0]}' or group has no rooms." 244 | ) 245 | return [] 246 | remaining_groups = remaining_groups[1:] if len(remaining_groups) > 1 else () 247 | return await _get_group_rooms(next_group, remaining_groups, new_room_list) 248 | return new_room_list 249 | elif isinstance(group, list): 250 | new_room_list.extend(group) 251 | return new_room_list 252 | 253 | first_group = self.config.rooms.get("groups", {}).get(groups[0]) 254 | if not first_group: 255 | await send_text_to_room( 256 | self.client, self.room.room_id, 257 | f"Invalid group '{groups[0]}' or group has no rooms." 258 | ) 259 | return 260 | subgroups = groups[1:] if len(groups) > 1 else () 261 | 262 | initial_rooms = self.config.rooms.get("groups", {}).get("__all__", []) 263 | 264 | rooms = await _get_group_rooms(first_group, subgroups, initial_rooms) 265 | if not rooms: 266 | await send_text_to_room( 267 | self.client, self.room.room_id, 268 | f"No rooms found for groups {' '.join(groups)}." 269 | ) 270 | return 271 | 272 | for room in rooms: 273 | room_id = await ensure_room_id(client=self.client, room_id_or_alias=room) 274 | await invite_to_room(self.client, room_id, user, self.room.room_id, ignore_in_room=True) 275 | 276 | async def _invite(self): 277 | """Handle an invitation command""" 278 | if not await self._ensure_coordinator(): 279 | return 280 | if not self.args or self.args[0] == "help": 281 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_INVITE) 282 | return 283 | 284 | try: 285 | room_id = await ensure_room_id(self.client, self.args[0]) 286 | except (AttributeError, ProtocolError): 287 | await send_text_to_room( 288 | self.client, 289 | self.room.room_id, 290 | f"Could not resolve room ID. Please ensure room exists.", 291 | ) 292 | except IndexError: 293 | await send_text_to_room( 294 | self.client, 295 | self.room.room_id, 296 | f"Cannot understand arguments.\n\n{help_strings.HELP_INVITE}", 297 | ) 298 | else: 299 | if len(self.args) == 1: 300 | await invite_to_room(self.client, room_id, self.event.sender, self.room.room_id, self.args[0]) 301 | return 302 | else: 303 | for counter, user_id in enumerate(self.args, 1): 304 | if counter == 1: 305 | # Skip the room ID 306 | continue 307 | try: 308 | check_user_id(user_id) 309 | except ValueError: 310 | await send_text_to_room( 311 | self.client, 312 | self.room.room_id, 313 | f"Invalid user mxid: {user_id}", 314 | ) 315 | else: 316 | await invite_to_room(self.client, room_id, user_id, self.room.room_id, self.args[0]) 317 | return 318 | 319 | async def _join(self): 320 | """ 321 | Join a user to a room. 322 | 323 | If Bubo is not a Synapse admin, fall back to regular invite. 324 | Either way, Bubo needs to be in the room. 325 | """ 326 | if not await self._ensure_coordinator(): 327 | return 328 | 329 | if len(self.args) < 2: 330 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_JOIN) 331 | return 332 | 333 | room_id_or_alias = self.args[0] 334 | users = self.args[1:] 335 | 336 | await self._join_users_to_room(room_id_or_alias, users) 337 | 338 | async def _join_users_to_room(self, room_id_or_alias: str, users: List[str]) -> None: 339 | joined = 0 340 | invited = 0 341 | 342 | if self.config.is_synapse_admin: 343 | joined = await join_users(self.config, users, room_id_or_alias) 344 | 345 | room_id = await ensure_room_id(client=self.client, room_id_or_alias=room_id_or_alias) 346 | 347 | if not self.config.is_synapse_admin or joined != len(users): 348 | # Fallback invites 349 | for user in users: 350 | response = await self.client.room_invite(room_id, user) 351 | if isinstance(response, RoomInviteError): 352 | logger.warning(f"Failed to invite user {user} to room {room_id}: " 353 | f"{response.message} / {response.status_code}") 354 | else: 355 | invited += 1 356 | await send_text_to_room( 357 | self.client, self.room.room_id, 358 | f"Joined {joined} users and invited {invited} users to room {room_id}", 359 | ) 360 | 361 | async def _power(self): 362 | """Set power in a room. 363 | 364 | Coordinators can set moderator power. 365 | Admins can set also admin power. 366 | 367 | # TODO this does not persist power in rooms maintained with the bot if 368 | `permissions.demote_users` is set to True - need to make this 369 | command also save the power in that case, unfortunately we don't yet have 370 | a database table to track configured user power in rooms and might not 371 | be adding such a feature anytime soon. 372 | """ 373 | if not await self._ensure_coordinator(): 374 | return 375 | 376 | if not self.args or self.args[0] == "help": 377 | text = help_strings.HELP_POWER 378 | else: 379 | try: 380 | user_id = self.args[0] 381 | room_id = self.args[1] 382 | if room_id.startswith("#"): 383 | response = await self.client.room_resolve_alias(f"{room_id}") 384 | room_id = response.room_id 385 | except AttributeError: 386 | text = f"Could not resolve room ID. Please ensure room exists." 387 | except IndexError: 388 | text = f"Cannot understand arguments.\n\n{help_strings.HELP_POWER}" 389 | else: 390 | try: 391 | level = self.args[2] 392 | except IndexError: 393 | level = "moderator" 394 | if level not in ("moderator", "admin"): 395 | text = f"Level must be 'moderator' or 'admin'." 396 | else: 397 | if level == "admin" and not await self._ensure_admin(): 398 | text = f"Only bot admins can set admin level power, sorry." 399 | else: 400 | power = { 401 | "admin": 100, 402 | "moderator": 50, 403 | }.get(level) 404 | response = await set_user_power(room_id, user_id, self.client, power) 405 | if isinstance(response, (RoomPutStateError, RoomGetStateEventError)): 406 | text = f"Sorry, command failed.\n\n{response.message}" 407 | elif isinstance(response, RoomPutStateResponse): 408 | text = f"Power level was successfully set as requested." 409 | elif isinstance(response, int): 410 | if response == 403: 411 | text = f"Failed to set power level - no permissions to do so or not in room." 412 | else: 413 | text = f"Failed to set power level - error code {response}." 414 | else: 415 | logger.warning(f"Got unexpected set_user_power response: {response}") 416 | text = f"Unknown power level response, please consult the logs." 417 | 418 | await send_text_to_room(self.client, self.room.room_id, text) 419 | 420 | async def _show_help(self): 421 | """Show the help text""" 422 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_HELP) 423 | 424 | async def _rooms(self, space: bool = False): 425 | """List and operate on rooms and spaces""" 426 | if not await self._ensure_coordinator(): 427 | return 428 | text = None 429 | type_text = 'Space' if space else 'Room' 430 | if self.args: 431 | if self.args[0] == "alias": 432 | await self._alias() 433 | elif self.args[0] == "create": 434 | # Create a room or space 435 | # Figure out the actual parameters 436 | args = self.args[1:] 437 | params = csv.reader([' '.join(args)], delimiter=" ") 438 | params = [param for param in params][0] 439 | if len(params) != 5 or params[3] not in ('yes', 'no') or params[3] not in ('yes', 'no') \ 440 | or params[0] == "help": 441 | text = f"Wrong number or bad arguments. " \ 442 | f"Usage:\n\n{help_strings.HELP_SPACES if space else help_strings.HELP_ROOMS}" 443 | else: 444 | result, room_id = await ensure_room_exists( 445 | (None, params[0], params[1], None, params[2], None, True if params[3] == "yes" else False, 446 | True if params[4] == "yes" else False, "space" if space else "room"), 447 | self.client, 448 | self.store, 449 | self.config, 450 | ) 451 | if result == "created": 452 | text = f"{type_text} {params[0]} (#{params[1]}:{self.config.server_name}) " \ 453 | f"created successfully. Room ID: {room_id}" 454 | elif result == "exists": 455 | text = f"Sorry! {type_text} {params[0]} (#{params[1]}:{self.config.server_name}) " \ 456 | f"already exists." 457 | else: 458 | text = f"Error creating {type_text}" 459 | elif self.args[0] == "help": 460 | text = help_strings.HELP_SPACES if space else help_strings.HELP_ROOMS 461 | elif self.args[0] == "link": 462 | await self._link_room(make_admin=False) 463 | elif self.args[0] == "link-and-admin": 464 | await self._link_room(make_admin=True) 465 | elif self.args[0] == "list": 466 | text = await self._list_rooms(spaces=space) 467 | elif self.args[0] == "list-no-admin": 468 | text = await self._list_no_admin_rooms(spaces=space) 469 | elif self.args[0] == 'recreate': 470 | return await self._recreate_room( 471 | subcommand=self.args[1] if len(self.args) > 1 else None, keep_encryption=True, 472 | ) 473 | elif self.args[0] == 'recreate-unencrypted': 474 | return await self._recreate_room( 475 | subcommand=self.args[1] if len(self.args) > 1 else None, keep_encryption=False, 476 | ) 477 | elif self.args[0] == 'unlink': 478 | await self._unlink_room(leave=False) 479 | elif self.args[0] == 'unlink-and-leave': 480 | await self._unlink_room(leave=True) 481 | else: 482 | text = "Unknown subcommand!" 483 | else: 484 | text = await self._list_rooms() 485 | if text: 486 | await send_text_to_room(self.client, self.room.room_id, text) 487 | 488 | async def _link_room(self, make_admin=False): 489 | """ 490 | Link the room by adding to the Bubo room database. 491 | 492 | Will try to join the room if not a member. 493 | 494 | If "make_admin" is true and Bubo is synapse admin, it will make itself 495 | admin using the admin API, which will also act as fallback to join the room 496 | when lacking an invitation. 497 | """ 498 | if not await self._ensure_coordinator(): 499 | return 500 | 501 | if len(self.args) < 2: 502 | if make_admin: 503 | return await send_text_to_room( 504 | self.client, self.room.room_id, help_strings.HELP_ROOMS_LINK_AND_ADMIN, 505 | ) 506 | else: 507 | return await send_text_to_room( 508 | self.client, self.room.room_id, help_strings.HELP_ROOMS_LINK, 509 | ) 510 | try: 511 | room_id = await ensure_room_id(self.client, self.args[1]) 512 | except (KeyError, ProtocolError): 513 | return await send_text_to_room( 514 | self.client, self.room.room_id, f"Error resolving room ID", 515 | ) 516 | 517 | room = self.store.get_room(room_id) 518 | if room: 519 | return await send_text_to_room( 520 | self.client, self.room.room_id, f"Room {room_id} is already tracked by Bubo.", 521 | ) 522 | 523 | # Ensure member 524 | response = await self.client.joined_rooms() 525 | if isinstance(response, JoinedRoomsError): 526 | return await send_text_to_room( 527 | self.client, self.room.room_id, f"Error fetching current joined rooms. Try again?", 528 | ) 529 | if room_id not in response.rooms: 530 | # Try join 531 | response = await self.client.join(room_id) 532 | if isinstance(response, JoinError): 533 | # Try the admin API if admin 534 | if self.config.is_synapse_admin: 535 | response = await make_room_admin(config=self.config, room_id=room_id, user_id=self.config.user_id) 536 | if not response: 537 | return await send_text_to_room( 538 | self.client, self.room.room_id, 539 | f"Failed to get access to the room. You'll need someone to invite Bubo manually.", 540 | ) 541 | else: 542 | return await send_text_to_room( 543 | self.client, self.room.room_id, 544 | f"Failed to join the room. You'll need someone to invite Bubo manually.", 545 | ) 546 | else: 547 | if make_admin and self.config.is_synapse_admin: 548 | # Are we admin? 549 | _state, users = await get_room_power_levels(self.client, room_id) 550 | if users and users.get(self.config.user_id, 0) < 100: 551 | response = await make_room_admin(config=self.config, room_id=room_id, user_id=self.config.user_id) 552 | if not response: 553 | await send_text_to_room( 554 | self.client, self.room.room_id, 555 | f"Failed to make Bubo admin in the room. Continuing with linking anyway.", 556 | ) 557 | 558 | # Get some data 559 | # We can't trust the room is in the matrix-nio store at this stage yet 560 | name = "" 561 | response = await self.client.room_get_state_event(room_id=room_id, event_type="m.room.name") 562 | if isinstance(response, RoomGetStateEventResponse): 563 | name = response.content.get("name") 564 | alias = "" 565 | response = await self.client.room_get_state_event(room_id=room_id, event_type="m.room.canonical_alias") 566 | if isinstance(response, RoomGetStateEventResponse): 567 | alias = response.content.get("alias") 568 | if alias: 569 | alias = alias.lstrip("#").split(":")[0] 570 | title = "" 571 | response = await self.client.room_get_state_event(room_id=room_id, event_type="m.room.topic") 572 | if isinstance(response, RoomGetStateEventResponse): 573 | title = response.content.get("title") 574 | encrypted = False 575 | response = await self.client.room_get_state_event(room_id=room_id, event_type="m.room.encryption") 576 | if isinstance(response, RoomGetStateEventResponse): 577 | encrypted = True 578 | public = False 579 | response = await self.client.room_get_state_event(room_id=room_id, event_type="m.room.join_rules") 580 | if isinstance(response, RoomGetStateEventResponse): 581 | public = response.content.get("join_rule") == "public" 582 | room_type = "room" 583 | response = await self.client.room_get_state_event(room_id=room_id, event_type="m.room.create") 584 | if isinstance(response, RoomGetStateEventResponse): 585 | room_type = "space" if response.content.get("type") == "m.space" else "room" 586 | 587 | if not name or not alias: 588 | # Currently required :( 589 | if not name: 590 | await send_text_to_room( 591 | self.client, self.room.room_id, 592 | f"Failed to link room, it's missing a name. Add a name and try again.", 593 | ) 594 | if not alias: 595 | await send_text_to_room( 596 | self.client, self.room.room_id, 597 | f"Failed to link room, it's missing an alias. Add an alias and try again.", 598 | ) 599 | return 600 | 601 | self.store.store_room( 602 | name=name, 603 | alias=alias, 604 | room_id=room_id, 605 | title=title, 606 | encrypted=encrypted, 607 | public=public, 608 | room_type=room_type, 609 | ) 610 | 611 | return await send_text_to_room( 612 | self.client, self.room.room_id, f"Room {room_id} has been added to the Bubo database.", 613 | ) 614 | 615 | async def _list_no_admin_rooms(self, spaces: bool = False): 616 | text = f"I lack admin power in the following {'spaces' if spaces else 'rooms'} I maintain:\n\n" 617 | rooms = self.store.get_rooms(spaces=spaces) 618 | rooms_list = [] 619 | for room in rooms: 620 | _state, users = await get_room_power_levels(self.client, room["room_id"]) 621 | if users and users.get(self.config.user_id, 0) < 100: 622 | joined_members = await with_ratelimit( 623 | self.client, "joined_members", room_id=room["room_id"], 624 | ) 625 | user_count = getattr(joined_members, "members", None) 626 | suffix = "" 627 | admin_users = [user for user, power in users.items() if power == 100] 628 | if len(admin_users): 629 | suffix = f". **The {'space' if spaces else 'room'} has {len(admin_users)} other admins.**" 630 | rooms_list.append(f"* {room['name']} / #{room['alias']}:{self.config.server_name} / " 631 | f"{room['room_id']} / users: {len(user_count) if user_count else 'unknown'}" 632 | f"{suffix}\n") 633 | text += "".join(rooms_list) 634 | return text 635 | 636 | async def _list_rooms(self, spaces: bool = False): 637 | text = f"I currently maintain the following {'spaces' if spaces else 'rooms'}:\n\n" 638 | rooms = self.store.get_rooms(spaces=spaces) 639 | rooms_list = [] 640 | for room in rooms: 641 | rooms_list.append(f"* {room['name']} / #{room['alias']}:{self.config.server_name} / {room['room_id']}\n") 642 | text += "".join(rooms_list) 643 | return text 644 | 645 | async def _pindora(self): 646 | if not self.config.pindora_enabled: 647 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_PINDORA_DISABLED) 648 | return 649 | 650 | if not await self._ensure_pindora_user(): 651 | return 652 | 653 | if self.args: 654 | subcommand = self.args[0] 655 | if subcommand == "create": 656 | try: 657 | name = self.args[1] 658 | except KeyError: 659 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_KEYS) 660 | return 661 | 662 | hours = None 663 | try: 664 | hours = self.args[2] 665 | except KeyError: 666 | pass 667 | 668 | try: 669 | key, magic_url = create_new_key( 670 | self.config.pindora_id, self.config.pindora_token, name, hours=int(hours), 671 | pindora_timezone=self.config.pindora_timezone, 672 | ) 673 | logger.info("New Pindora key created successfully, requested by %s", self.event.sender) 674 | await send_text_to_room(self.client, self.room.room_id, f"Code: {key}, Magic url: {magic_url}") 675 | except Exception as ex: 676 | logger.error("pindora - error creating a key: %s", ex) 677 | await send_text_to_room( 678 | self.client, self.room.room_id, f"Generating code failed, please contact administrators", 679 | ) 680 | else: 681 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_KEYS) 682 | else: 683 | await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_KEYS) 684 | 685 | async def _recreate_room(self, subcommand: str, keep_encryption: bool = True): 686 | """ 687 | Command to recreate a room. Useful if the room has no admins. 688 | """ 689 | if not await self._ensure_admin(): 690 | return 691 | 692 | if not subcommand: 693 | room = self.store.get_recreate_room(self.room.room_id) 694 | if room: 695 | if room["applied"] == 1: 696 | return await send_text_to_room( 697 | self.client, self.room.room_id, 698 | "Can only recreate a room once, this room has already been recreated.", 699 | ) 700 | self.store.delete_recreate_room(self.room.room_id) 701 | self.store.store_recreate_room(self.event.sender, self.room.room_id) 702 | return await send_text_to_room( 703 | self.client, self.room.room_id, help_strings.HELP_ROOMS_RECREATE_CONFIRM % self.config.command_prefix, 704 | ) 705 | 706 | if subcommand != "confirm": 707 | return await send_text_to_room( 708 | self.client, self.room.room_id, f"Unknown subcommand. Usage:\n\n{help_strings.HELP_ROOMS_RECREATE}", 709 | ) 710 | 711 | room = self.store.get_recreate_room(self.room.room_id) 712 | if not room: 713 | return await send_text_to_room( 714 | self.client, self.room.room_id, 715 | "Cannot confirm room recreate before requesting room recreate.", 716 | ) 717 | if room["requester"] != self.event.sender: 718 | return await send_text_to_room( 719 | self.client, self.room.room_id, 720 | "Room recreate confirm must be given by the room recreate requester.", 721 | ) 722 | if int(time.time()) - room["timestamp"] > 300: 723 | return await send_text_to_room( 724 | self.client, self.room.room_id, 725 | "Room recreate confirmation must be given within 300 seconds. Please request recreation again.", 726 | ) 727 | 728 | # OK confirmation over, let's do stuff 729 | new_room_id = await recreate_room( 730 | self.room, self.client, self.config, self.store, self.event.event_id, keep_encryption=keep_encryption, 731 | ) 732 | if not new_room_id: 733 | return await send_text_to_room( 734 | self.client, self.room.room_id, 735 | f"Failed to create new room. Please see logs or contact support.", 736 | ) 737 | 738 | async def _unknown_command(self): 739 | await send_text_to_room( 740 | self.client, 741 | self.room.room_id, 742 | f"Unknown command '{self.command}'. Try the 'help' command for more information.", 743 | ) 744 | 745 | async def _unlink_room(self, leave: bool): 746 | """ 747 | Unlink the room by removing from Bubo room database. 748 | 749 | Optionally leave the room as well. 750 | """ 751 | if not await self._ensure_coordinator(): 752 | return 753 | 754 | if len(self.args) < 2: 755 | return await send_text_to_room( 756 | self.client, self.room.room_id, help_strings.HELP_ROOMS_UNLINK, 757 | ) 758 | try: 759 | room_id = await ensure_room_id(self.client, self.args[1]) 760 | except (KeyError, ProtocolError): 761 | return await send_text_to_room( 762 | self.client, self.room.room_id, f"Error resolving room ID", 763 | ) 764 | 765 | room = self.store.get_room(room_id) 766 | if not room: 767 | return await send_text_to_room( 768 | self.client, self.room.room_id, f"Cannot unlink room {room_id} which doesn't seem tracked by Bubo", 769 | ) 770 | 771 | self.store.unlink_room(room_id) 772 | 773 | if leave: 774 | await self.client.room_leave(room_id) 775 | 776 | return await send_text_to_room( 777 | self.client, self.room.room_id, f"Room {room_id} has been removed from Bubo database." 778 | f"{' Bubo has also left the room' if leave else ''}", 779 | ) 780 | 781 | async def _users(self): 782 | """ 783 | Command to manage users. 784 | """ 785 | if not self.config.keycloak.get("enabled"): 786 | return await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_USERS_KEYCLOAK_DISABLED) 787 | text = None 788 | if self.args: 789 | if self.args[0] == "list": 790 | if not await self._ensure_admin(): 791 | return 792 | users = list_users(self.config) 793 | text = f"The following usernames were found: {', '.join([user['username'] for user in users])}" 794 | elif self.args[0] == "help": 795 | text = help_strings.HELP_USERS 796 | elif self.args[0] == "create": 797 | if not await self._ensure_admin(): 798 | return 799 | if len(self.args) == 1 or self.args[1] == "help": 800 | text = help_strings.HELP_USERS_CREATE 801 | else: 802 | emails = self.args[1:] 803 | emails = {email.strip() for email in emails} 804 | texts = [] 805 | for email in emails: 806 | try: 807 | validated = validate_email(email) 808 | email = validated.email 809 | logger.debug("users create - Email %s is valid", email) 810 | except EmailNotValidError as ex: 811 | texts.append(f"The email {email} looks invalid: {ex}") 812 | continue 813 | try: 814 | existing_user = get_user_by_attr(self.config, "email", email) 815 | except Exception as ex: 816 | texts.append(f"Error looking up existing users by email {email}: {ex}") 817 | continue 818 | if existing_user: 819 | texts.append(f"Found an existing user by email {email} - ignoring") 820 | continue 821 | logger.debug("users create - No existing user for %s found", email) 822 | username = None 823 | username_candidate = email.split('@')[0] 824 | username_candidate = username_candidate.lower() 825 | username_candidate = re.sub(r'[^a-z\d._\-]', '', username_candidate) 826 | candidate = username_candidate 827 | counter = 0 828 | while not username: 829 | logger.debug("users create - candidate: %s", candidate) 830 | # noinspection PyBroadException 831 | try: 832 | existing_user = get_user_by_attr(self.config, "username", candidate) 833 | except Exception: 834 | existing_user = True 835 | if existing_user: 836 | logger.debug("users create - Found existing user with candidate %s", existing_user) 837 | counter += 1 838 | candidate = f"{username_candidate}{counter}" 839 | continue 840 | username = candidate 841 | logger.debug("Username is %s", username) 842 | user_id = create_user(self.config, username, email) 843 | logger.debug("Created user: %s", user_id) 844 | if not user_id: 845 | texts.append(f"Failed to create user for email {email}") 846 | logger.warning("users create - Failed to create user for email %s", email) 847 | continue 848 | send_password_reset(self.config, user_id) 849 | logger.info("users create - Successfully create user with email %s", email) 850 | texts.append(f"Successfully create {email}!") 851 | text = '\n'.join(texts) 852 | elif self.args[0] == "invite": 853 | if not await self._ensure_coordinator(): 854 | return 855 | if not self.config.keycloak_signup.get("enabled"): 856 | return await send_text_to_room( 857 | self.client, self.room.room_id, help_strings.HELP_USERS_KEYCLOAK_SIGNUP_DISABLED, 858 | ) 859 | if len(self.args) == 1 or self.args[1] == "help": 860 | return await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_USERS_INVITE) 861 | emails = self.args[1:] 862 | emails = {email.strip() for email in emails} 863 | texts = [] 864 | for email in emails: 865 | try: 866 | validated = validate_email(email) 867 | email = validated.email 868 | logger.debug("users invite - Email %s is valid", email) 869 | except EmailNotValidError as ex: 870 | texts.append(f"The email {email} looks invalid: {ex}") 871 | continue 872 | 873 | try: 874 | invite_user(self.config, email, self.event.sender) 875 | except Exception as ex: 876 | logger.error("users invite - error sending invite to user: %s", ex) 877 | texts.append(f"Error inviting {email}, please see logs.") 878 | continue 879 | logger.debug("users invite - Invited user: %s", email) 880 | texts.append(f"Successfully invited {email}!") 881 | text = '\n'.join(texts) 882 | elif self.args[0] == "rooms": 883 | if not await self._ensure_admin(): 884 | return 885 | if not self.config.is_synapse_admin: 886 | return await send_text_to_room( 887 | self.client, self.room.room_id, 888 | "Bubo must be Synapse admin to use this command. Please contact your system administrator", 889 | ) 890 | if len(self.args) < 2 or self.args[1] == "help": 891 | return await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_USERS_ROOMS) 892 | 893 | user_id = self.args[1] 894 | try: 895 | check_user_id(user_id) 896 | except ValueError: 897 | await send_text_to_room( 898 | self.client, 899 | self.room.room_id, 900 | f"Invalid user mxid: {user_id}", 901 | ) 902 | rooms = await get_user_rooms(self.config, user_id) 903 | if not rooms: 904 | return await send_text_to_room( 905 | self.client, self.room.room_id, 906 | f"Cannot find {user_id} in any rooms on this server.", 907 | ) 908 | else: 909 | room_list = [] 910 | for room in rooms: 911 | room_str = f"{room.get('name')} ({room.get('canonical_alias')})" \ 912 | if room.get("canonical_alias") else \ 913 | f"{room.get('name')} ({room.get('room_id')})" 914 | room_list.append(room_str) 915 | return await send_text_to_room( 916 | self.client, self.room.room_id, 917 | f"User {user_id} found in the following rooms:\n\n{'
'.join(room_list)}" 918 | ) 919 | elif self.args[0] == "signuplink": 920 | if not await self._ensure_coordinator(): 921 | return 922 | if not self.config.keycloak_signup.get("enabled"): 923 | return await send_text_to_room( 924 | self.client, self.room.room_id, help_strings.HELP_USERS_KEYCLOAK_SIGNUP_DISABLED, 925 | ) 926 | if len(self.args) < 3 or self.args[1] == "help": 927 | return await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_USERS_SIGNUPLINK) 928 | try: 929 | max_signups = int(self.args[1]) 930 | days_valid = int(self.args[2]) 931 | if max_signups < 1 or days_valid < 1: 932 | raise ValueError 933 | except ValueError: 934 | return await send_text_to_room(self.client, self.room.room_id, help_strings.HELP_USERS_SIGNUPLINK) 935 | # noinspection PyBroadException 936 | try: 937 | signup_link = create_signup_link(self.config, self.event.sender, max_signups, days_valid) 938 | except Exception as ex: 939 | logger.error("Failed to create signup link: %s", ex) 940 | text = "Error creating signup link. Please contact an administrator." 941 | else: 942 | logger.info(f"Successfully created signup link requested by {self.event.sender}") 943 | text = f"Signup link created for {max_signups} signups with a validity of {days_valid} days. " \ 944 | f"The link is {signup_link}" 945 | else: 946 | if not await self._ensure_admin(): 947 | return 948 | users = list_users(self.config) 949 | text = f"The following usernames were found: {', '.join([user['username'] for user in users])}" 950 | if not text: 951 | text = help_strings.HELP_USERS 952 | await send_text_to_room(self.client, self.room.room_id, text) 953 | -------------------------------------------------------------------------------- /bubo/callbacks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Union 3 | 4 | # noinspection PyPackageRequirements 5 | from nio import JoinError, MatrixRoom, MegolmEvent, RoomKeyEvent, Event, RoomMessageText, UnknownEvent 6 | 7 | from bubo.bot_commands import Command 8 | from bubo.chat_functions import send_text_to_room, invite_to_room 9 | from bubo.message_responses import Message 10 | 11 | import logging 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Callbacks(object): 16 | 17 | def __init__(self, client, store, config): 18 | """ 19 | Args: 20 | client (nio.AsyncClient): nio client used to interact with matrix 21 | 22 | store (Storage): Bot storage 23 | 24 | config (Config): Bot configuration parameters 25 | """ 26 | self.client = client 27 | self.store = store 28 | self.config = config 29 | self.command_prefix = config.command_prefix 30 | 31 | async def decrypted_callback(self, room_id: str, event: Union[RoomMessageText, UnknownEvent]): 32 | if isinstance(event, RoomMessageText): 33 | await self.message(self.client.rooms[room_id], event) 34 | elif isinstance(event, UnknownEvent): 35 | await self.reaction(self.client.rooms[room_id], event) 36 | else: 37 | logger.warning(f"Unknown event %s passed to decrypted_callback" % event) 38 | 39 | async def message(self, room, event): 40 | """Callback for when a message event is received 41 | 42 | Args: 43 | room (nio.rooms.MatrixRoom): The room the event came from 44 | 45 | event (nio.events.room_events.RoomMessageText): The event defining the message 46 | 47 | """ 48 | # Extract the message text 49 | msg = event.body 50 | 51 | # Ignore messages from ourselves 52 | if event.sender == self.client.user: 53 | return 54 | 55 | # If this looks like an edit, strip the edit prefix 56 | if msg.startswith(" * "): 57 | msg = msg[3:] 58 | 59 | logger.debug( 60 | f"Bot message received for room {room.display_name} | " 61 | f"{room.user_name(event.sender)}: {msg}" 62 | ) 63 | 64 | # Process as message if in 2+ person room and no command prefix 65 | # We used to use `room.is_group` but it seemed to lose state sometimes, see 66 | # https://github.com/elokapina/bubo/issues/18 67 | has_command_prefix = msg.startswith(self.command_prefix) 68 | if not has_command_prefix and room.member_count > 2: 69 | # General message listener 70 | message = Message(self.client, self.store, self.config, msg, room, event) 71 | await message.process() 72 | return 73 | 74 | # Otherwise if this is in a 1-1 with the bot or features a command prefix, 75 | # treat it as a command 76 | if has_command_prefix: 77 | # Remove the command prefix 78 | msg = msg[len(self.command_prefix):] 79 | 80 | command = Command(self.client, self.store, self.config, msg, room, event) 81 | await command.process() 82 | 83 | async def reaction(self, room, event): 84 | """Callback for when a reaction is received.""" 85 | logger.debug(f"Got unknown event to {room.room_id} from {event.sender}.") 86 | if event.type != "m.reaction": 87 | return 88 | relates_to = event.source.get("content", {}).get("m.relates_to") 89 | logger.debug(f"Relates to: {relates_to}") 90 | if not relates_to: 91 | return 92 | event_id = relates_to.get("event_id") 93 | rel_type = relates_to.get("rel_type") 94 | if not event_id or rel_type != "m.annotation": 95 | return 96 | # TODO split to "reactions.py" or similar 97 | # Breakout creation reaction? 98 | room_id = self.store.get_breakout_room_id(event_id) 99 | logger.debug(f"Breakout room query found room_id: {room_id}") 100 | if room_id: 101 | logger.info(f"Found breakout room for reaction in {room.room_id} by {event.sender} - " 102 | f"inviting to {room_id}") 103 | # Do an invitation 104 | await invite_to_room( 105 | self.client, room_id, event.sender, 106 | ) 107 | 108 | async def room_key(self, event: RoomKeyEvent): 109 | """Callback for ToDevice events like room key events.""" 110 | events = self.store.get_encrypted_events(event.session_id) 111 | logger.debug("Got room key event for session %s, matched sessions: %s" % (event.session_id, len(events))) 112 | if not events: 113 | return 114 | 115 | for encrypted_event in events: 116 | try: 117 | event_dict = json.loads(encrypted_event["event"]) 118 | params = event_dict["source"] 119 | params["room_id"] = event_dict["room_id"] 120 | params["transaction_id"] = event_dict["transaction_id"] 121 | megolm_event = MegolmEvent.from_dict(params) 122 | except Exception as ex: 123 | logger.warning("Failed to restore MegolmEvent for %s: %s" % (encrypted_event["event_id"], ex)) 124 | continue 125 | try: 126 | # noinspection PyTypeChecker 127 | decrypted = self.client.decrypt_event(megolm_event) 128 | except Exception as ex: 129 | logger.warning(f"Error decrypting event %s: %s" % (megolm_event.event_id, ex)) 130 | continue 131 | if isinstance(decrypted, Event): 132 | logger.info(f"Successfully decrypted stored event %s" % decrypted.event_id) 133 | parsed_event = Event.parse_event(decrypted.source) 134 | logger.info(f"Parsed event: %s" % parsed_event) 135 | self.store.remove_encrypted_event(decrypted.event_id) 136 | # noinspection PyTypeChecker 137 | await self.decrypted_callback(encrypted_event["room_id"], parsed_event) 138 | else: 139 | logger.warning(f"Failed to decrypt event %s" % (decrypted.event_id,)) 140 | 141 | async def invite(self, room, event): 142 | """Callback for when an invitation is received. Join the room specified in the invite""" 143 | logger.debug(f"Got invite to {room.room_id} from {event.sender}.") 144 | 145 | # Attempt to join 3 times before giving up 146 | for attempt in range(3): 147 | result = await self.client.join(room.room_id) 148 | if type(result) == JoinError: 149 | logger.error( 150 | f"Error joining room {room.room_id} (attempt %d): %s", 151 | attempt, result.message, 152 | ) 153 | else: 154 | break 155 | else: 156 | logger.error("Unable to join room: %s", room.room_id) 157 | 158 | # Successfully joined room 159 | logger.info(f"Joined {room.room_id}") 160 | 161 | async def decryption_failure(self, room: MatrixRoom, event: MegolmEvent): 162 | """Callback for when an event fails to decrypt.""" 163 | logger.warning( 164 | f"Failed to decrypt event {event.event_id} in room {room.name} ({room.canonical_alias} / {room.room_id}) " 165 | f"from sender {event.sender} - possibly missing session, storing for later." 166 | ) 167 | if self.config.callbacks.get("unable_to_decrypt_responses", True): 168 | user_msg = ( 169 | "Unable to decrypt this message. " 170 | "Check whether you've chosen to only encrypt to trusted devices." 171 | ) 172 | 173 | await send_text_to_room( 174 | self.client, room.room_id, user_msg, reply_to_event_id=event.event_id, 175 | ) 176 | 177 | self.store.store_encrypted_event(event) 178 | -------------------------------------------------------------------------------- /bubo/chat_functions.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | import uuid 5 | from typing import Optional 6 | 7 | import aiohttp 8 | # noinspection PyPackageRequirements 9 | from nio import ( 10 | SendRetryError, RoomInviteError, AsyncClient, ErrorResponse, RoomSendResponse, ProfileGetResponse, 11 | ) 12 | from markdown import markdown 13 | 14 | from bubo.config import Config 15 | from bubo.utils import get_request_headers 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | async def invite_to_room( 21 | client: AsyncClient, room_id: str, user_id: str, command_room_id: str = None, room_alias: str = None, 22 | ignore_in_room: bool = False, 23 | ): 24 | """ 25 | Invite a user to a room. 26 | 27 | First checks that the user exists. 28 | """ 29 | user = await client.get_profile(user_id) 30 | if not isinstance(user, ProfileGetResponse): 31 | await send_text_to_room( 32 | client, 33 | command_room_id, 34 | f"Could not find {user_id} to invite", 35 | ) 36 | return 37 | response = await client.room_invite(room_id, user_id) 38 | if isinstance(response, RoomInviteError): 39 | if response.status_code == "M_LIMIT_EXCEEDED": 40 | time.sleep(3) 41 | await invite_to_room(client, room_id, user_id, command_room_id, room_alias) 42 | return 43 | if command_room_id: 44 | if ignore_in_room and response.message.find("is already in the room") > -1: 45 | await send_text_to_room( 46 | client, 47 | command_room_id, 48 | f"{user_id} already in room {room_alias or room_id}", 49 | ) 50 | return 51 | await send_text_to_room( 52 | client, 53 | command_room_id, 54 | f"Failed to invite user {user_id} to room: {response.message} (code: {response.status_code})", 55 | ) 56 | logger.warning(f"Failed to invite user {user_id} to room: {response.message} " 57 | f"(code: {response.status_code})") 58 | elif command_room_id: 59 | await send_text_to_room( 60 | client, 61 | command_room_id, 62 | f"Invite for {room_alias or room_id} to {user_id} done!", 63 | ) 64 | logger.info(f"Invite for {room_alias or room_id} to {user_id} done!") 65 | 66 | 67 | async def send_text_to_room( 68 | client, 69 | room_id, 70 | message, 71 | notice=True, 72 | markdown_convert=True, 73 | reply_to_event_id: Optional[str] = None, 74 | ) -> str: 75 | """Send text to a matrix room. 76 | 77 | Args: 78 | client (nio.AsyncClient): The client to communicate to matrix with 79 | 80 | room_id (str): The ID of the room to send the message to 81 | 82 | message (str): The message content 83 | 84 | notice (bool): Whether the message should be sent with an "m.notice" message type 85 | (will not ping users). 86 | 87 | markdown_convert (bool): Whether to convert the message content to markdown. 88 | Defaults to true. 89 | 90 | reply_to_event_id: Whether this message is a reply to another event. The event 91 | ID this is message is a reply to. 92 | """ 93 | # Determine whether to ping room members or not 94 | msgtype = "m.notice" if notice else "m.text" 95 | 96 | content = { 97 | "msgtype": msgtype, 98 | "format": "org.matrix.custom.html", 99 | "body": message, 100 | } 101 | 102 | if markdown_convert: 103 | content["formatted_body"] = markdown(message) 104 | 105 | if reply_to_event_id: 106 | content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}} 107 | 108 | try: 109 | response = await client.room_send( 110 | room_id, 111 | "m.room.message", 112 | content, 113 | ignore_unverified_devices=True, 114 | ) 115 | if isinstance(response, ErrorResponse): 116 | if response.status_code == "M_LIMIT_EXCEEDED": 117 | time.sleep(3) 118 | return await send_text_to_room(client, room_id, message, notice, markdown_convert) 119 | else: 120 | logger.warning(f"Failed to send message to {room_id} due to {response.status_code}") 121 | elif isinstance(response, RoomSendResponse): 122 | return response.event_id 123 | else: 124 | logger.warning(f"Failed to get event_id from send_text_to_room, response: {response}") 125 | except SendRetryError: 126 | logger.exception(f"Unable to send message to {room_id}") 127 | 128 | 129 | async def send_text_to_room_c2s( 130 | config: Config, 131 | room_id: str, 132 | message: str, 133 | notice=True, 134 | markdown_convert=True, 135 | reply_to_event_id: Optional[str] = None, 136 | ) -> str: 137 | """ 138 | Send text to a matrix room using the C2S API directly. 139 | """ 140 | # Determine whether to ping room members or not 141 | msgtype = "m.notice" if notice else "m.text" 142 | 143 | content = { 144 | "msgtype": msgtype, 145 | "format": "org.matrix.custom.html", 146 | "body": message, 147 | } 148 | 149 | if markdown_convert: 150 | content["formatted_body"] = markdown(message) 151 | 152 | if reply_to_event_id: 153 | content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}} 154 | 155 | try: 156 | async with aiohttp.ClientSession() as session: 157 | async with session.put( 158 | f"{config.homeserver_url}/_matrix/client/r0/rooms/{room_id}/send/m.room.message/{str(uuid.uuid4())}", 159 | json=content, 160 | headers=get_request_headers(config), 161 | ) as response: 162 | if response.status == 429: 163 | await asyncio.sleep(3) 164 | return await send_text_to_room_c2s( 165 | config, room_id, message, notice, markdown_convert, reply_to_event_id, 166 | ) 167 | response.raise_for_status() 168 | return (await response.json())["event_id"] 169 | except Exception as ex: 170 | logger.exception(f"Unable to send C2S message to {room_id}: {ex}") 171 | -------------------------------------------------------------------------------- /bubo/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import os 4 | import yaml 5 | import sys 6 | from typing import List, Any 7 | 8 | # noinspection PyPackageRequirements 9 | from aiolog import matrix 10 | # noinspection PyPackageRequirements 11 | from nio.schemas import RoomRegex, UserIdRegex 12 | 13 | from bubo.errors import ConfigError 14 | 15 | # Prevent debug messages from peewee lib 16 | logger = logging.getLogger() 17 | logging.getLogger("peewee").setLevel(logging.INFO) 18 | # Prevent info messages from charset_normalizer which seems to output 19 | # lots of weird log lines about probing the chaos if Synapse goes away for a sec 20 | logging.getLogger("charset_normalizer").setLevel(logging.WARNING) 21 | 22 | 23 | class Config(object): 24 | def __init__(self, filepath): 25 | """ 26 | Args: 27 | filepath (str): Path to config file 28 | """ 29 | if not os.path.isfile(filepath): 30 | raise ConfigError(f"Config file '{filepath}' does not exist") 31 | 32 | # Load in the config file at the given filepath 33 | with open(filepath) as file_stream: 34 | self.config = yaml.safe_load(file_stream.read()) 35 | 36 | # Logging setup 37 | formatter = logging.Formatter('%(asctime)s | %(name)s [%(levelname)s] %(message)s') 38 | 39 | log_level = self._get_cfg(["logging", "level"], default="INFO") 40 | logger.setLevel(log_level) 41 | 42 | file_logging_enabled = self._get_cfg(["logging", "file_logging", "enabled"], default=False) 43 | file_logging_filepath = self._get_cfg(["logging", "file_logging", "filepath"], default="bot.log") 44 | if file_logging_enabled: 45 | handler = logging.FileHandler(file_logging_filepath) 46 | handler.setFormatter(formatter) 47 | logger.addHandler(handler) 48 | 49 | console_logging_enabled = self._get_cfg(["logging", "console_logging", "enabled"], default=True) 50 | if console_logging_enabled: 51 | handler = logging.StreamHandler(sys.stdout) 52 | handler.setFormatter(formatter) 53 | logger.addHandler(handler) 54 | 55 | # Storage setup 56 | self.database_filepath = self._get_cfg(["storage", "database_filepath"], required=True) 57 | self.store_filepath = self._get_cfg(["storage", "store_filepath"], required=True) 58 | 59 | # Create the store folder if it doesn't exist 60 | if not os.path.isdir(self.store_filepath): 61 | if not os.path.exists(self.store_filepath): 62 | os.mkdir(self.store_filepath) 63 | else: 64 | raise ConfigError(f"storage.store_filepath '{self.store_filepath}' is not a directory") 65 | 66 | # Matrix bot account setup 67 | self.user_id = self._get_cfg(["matrix", "user_id"], required=True) 68 | if not re.match("@.*:.*", self.user_id): 69 | raise ConfigError("matrix.user_id must be in the form @name:domain") 70 | 71 | self.user_token = self._get_cfg(["matrix", "user_token"], required=False, default="") 72 | self.user_password = self._get_cfg(["matrix", "user_password"], required=False, default="") 73 | if not self.user_token and not self.user_password: 74 | raise ConfigError("Must supply either user token or password") 75 | 76 | self.device_id = self._get_cfg(["matrix", "device_id"], required=True) 77 | self.device_name = self._get_cfg(["matrix", "device_name"], default="nio-template") 78 | self.homeserver_url = self._get_cfg(["matrix", "homeserver_url"], required=True) 79 | self.server_name = self._get_cfg(["matrix", "server_name"], required=True) 80 | self.is_synapse_admin = self._get_cfg(["matrix", "is_synapse_admin"], required=False, default=False) 81 | 82 | self.command_prefix = self._get_cfg(["command_prefix"], default="!c") + " " 83 | 84 | matrix_logging_enabled = self._get_cfg(["logging", "matrix_logging", "enabled"], default=False) 85 | if matrix_logging_enabled: 86 | if not self.user_token: 87 | logger.warning("Not setting up Matrix logging - requires user access token to be set") 88 | else: 89 | matrix_logging_room = self._get_cfg(["logging", "matrix_logging", "room"], required=True) 90 | handler = matrix.Handler( 91 | homeserver_url=self.homeserver_url, 92 | access_token=self.user_token, 93 | room_id=matrix_logging_room, 94 | ) 95 | handler.setFormatter(formatter) 96 | logger.addHandler(handler) 97 | 98 | # Permissions 99 | self.admins = self._get_cfg(["permissions", "admins"], default=[]) 100 | for admin in self.admins: 101 | if not re.match(re.compile(RoomRegex), admin) and not re.match(re.compile(UserIdRegex), admin): 102 | raise ConfigError(f"Admin {admin} does not look like a user or room") 103 | self.coordinators = self._get_cfg(["permissions", "coordinators"], default=[]) 104 | for coordinator in self.coordinators: 105 | if not re.match(re.compile(RoomRegex), coordinator) and not re.match(re.compile(UserIdRegex), coordinator): 106 | raise ConfigError(f"Coordinator {coordinator} does not look like a user or room") 107 | self.permissions_demote_users = self._get_cfg(["permissions", "demote_users"], default=False, required=False) 108 | self.permissions_promote_users = self._get_cfg(["permissions", "promote_users"], default=True, required=False) 109 | 110 | # Rooms 111 | self.rooms = self._get_cfg(["rooms"], default={}, required=False) 112 | 113 | # Callbacks 114 | self.callbacks = self._get_cfg(["callbacks"], default={}, required=False) 115 | 116 | # Keycloak 117 | self.keycloak = self._get_cfg(["users", "keycloak"], default={}, required=False) 118 | self.keycloak_signup = self._get_cfg(["users", "keycloak", "keycloak_signup"], default={}, required=False) 119 | 120 | # Email 121 | self.email = self._get_cfg(["email"], default={}, required=False) 122 | if self.email and self.email.get("starttls") and self.email.get("ssl"): 123 | raise ConfigError("Cannot enable both starttls and ssl for email") 124 | 125 | # Discourse 126 | self.discourse = self._get_cfg(["discourse"], default={}, required=False) 127 | 128 | # Pindora 129 | self.pindora_enabled = self._get_cfg(["pindora", "enabled"], default=False, required=False) 130 | self.pindora_token = self._get_cfg(["pindora", "token"], required=False) 131 | self.pindora_id = self._get_cfg(["pindora", "id"], required=False) 132 | self.pindora_timezone = self._get_cfg(["pindora", "timezone"], required=False) 133 | self.pindora_users = self._get_cfg(["pindora", "pindora_users"], default=[], required=False) 134 | 135 | def _get_cfg( 136 | self, 137 | path: List[str], 138 | default: Any = None, 139 | required: bool = True, 140 | ) -> Any: 141 | """Get a config option from a path and option name, specifying whether it is 142 | required. 143 | 144 | Raises: 145 | ConfigError: If required is specified and the object is not found 146 | (and there is no default value provided), this error will be raised 147 | """ 148 | # Shift through the config until we reach our option 149 | config = self.config 150 | for name in path: 151 | config = config.get(name) 152 | 153 | # If at any point we don't get our expected option... 154 | if config is None: 155 | # Raise an error if it was required 156 | if required and default is None: 157 | raise ConfigError(f"Config option {'.'.join(path)} is required") 158 | 159 | # or return the default value 160 | return default 161 | 162 | # We found the option. Return it 163 | return config 164 | 165 | 166 | def load_config() -> Config: 167 | # Read config file 168 | # A different config file path can be specified as the first command line argument 169 | if len(sys.argv) > 1: 170 | config_filepath = sys.argv[1] 171 | else: 172 | config_filepath = "config.yaml" 173 | return Config(config_filepath) 174 | -------------------------------------------------------------------------------- /bubo/discourse.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import dataclasses 3 | import logging 4 | from typing import Dict 5 | 6 | import aiohttp 7 | # noinspection PyPackageRequirements 8 | from nio import AsyncClient 9 | # noinspection PyPackageRequirements 10 | from slugify import slugify 11 | 12 | from bubo.config import Config, load_config 13 | from bubo.rooms import ( 14 | ensure_room_exists, add_membership_in_space, add_parent_space, set_join_rules, 15 | ) 16 | from bubo.storage import Storage 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @dataclasses.dataclass 22 | class DiscourseClient: 23 | api_key: str 24 | api_username: str 25 | url: str 26 | 27 | async def do_request(self, method, path, data: Dict = None): 28 | logger.debug("Making %s request to %s%s", method, self.url, path) 29 | async with aiohttp.ClientSession() as session: 30 | async with getattr(session, method.lower())( 31 | f"{self.url}{path}", 32 | json=data, 33 | headers=self.request_headers, 34 | ) as response: 35 | if response.status == 429: 36 | await asyncio.sleep(3) 37 | return await self.do_request(method, path, data) 38 | response.raise_for_status() 39 | return await response.json() 40 | 41 | @property 42 | def request_headers(self) -> Dict: 43 | return { 44 | "Api-Key": self.api_key, 45 | "Api-Username": self.api_username, 46 | "Content-Type": "application/json", 47 | } 48 | 49 | 50 | @dataclasses.dataclass 51 | class DiscourseGroup: 52 | id: int 53 | name: str 54 | user_count: int 55 | allow_membership_requests: bool = None 56 | automatic: bool = None 57 | bio_cooked: str = None 58 | bio_excerpt: str = None 59 | bio_raw: str = None 60 | can_admin_group: bool = None 61 | can_see_members: bool = None 62 | default_notification_level: int = None 63 | display_name: str = None 64 | flair_bg_color: str = None 65 | flair_color: str = None 66 | flair_url: str = None 67 | full_name: str = None 68 | grant_trust_level: str = None 69 | has_messages: bool = None 70 | incoming_email: str = None 71 | is_group_owner: bool = None 72 | is_group_user: bool = None 73 | members_visibility_level: int = None 74 | membership_request_template: str = None 75 | mentionable_level: int = None 76 | messageable_level: int = None 77 | primary_group: bool = None 78 | public_admission: bool = None 79 | public_exit: bool = None 80 | publish_read_state: bool = None 81 | title: str = None 82 | visibility_level: int = None 83 | 84 | @property 85 | def alias(self) -> str: 86 | """ 87 | Conforms to using as an alias in Matrix. 88 | """ 89 | return slugify(self.name) 90 | 91 | @property 92 | def short_alias(self) -> str: 93 | """ 94 | Conforms to using as an alias in Matrix. 95 | """ 96 | return slugify(self.short_name) 97 | 98 | @property 99 | def short_name(self): 100 | """ 101 | Return the name with leading prefix stripped. 102 | 103 | This assumes group name is in format `prefix-actualname`. 104 | """ 105 | if not self.name.find("-") > -1: 106 | return self.name 107 | return self.name.split("-", 1)[1] 108 | 109 | 110 | class Discourse: 111 | client: DiscourseClient 112 | config: Config 113 | groups: Dict[str, DiscourseGroup] 114 | 115 | def __init__(self): 116 | self.config = load_config() 117 | self.client = DiscourseClient( 118 | url=self.config.discourse.get("url"), 119 | api_username=self.config.discourse.get("api_username"), 120 | api_key=self.config.discourse.get("api_key"), 121 | ) 122 | 123 | async def get_groups(self) -> Dict[str, DiscourseGroup]: 124 | """ 125 | Get list of groups from Discourse. 126 | """ 127 | self.groups = {} 128 | path = "/groups.json" 129 | while True: 130 | response = await self.client.do_request("GET", path) 131 | for group in response.get("groups", []): 132 | self.groups[group.get("name")] = DiscourseGroup(**group) 133 | if response.get("total_rows_groups") > len(self.groups.values()): 134 | path = f'/groups.json?{response.get("load_more_groups").split("?")[1]}' 135 | else: 136 | break 137 | logger.info("Found a total of %s groups from Discourse", len(self.groups.keys())) 138 | return self.groups 139 | 140 | async def sync_groups_as_spaces(self, client: AsyncClient, store: Storage): 141 | """ 142 | Sync groups from Discourse as Matrix spaces. 143 | """ 144 | spaces_config = self.config.discourse.get("spaces", {}) 145 | dry_run = spaces_config.get("dry_run", False) 146 | whitelist = spaces_config.get("whitelist", []) 147 | logger.info( 148 | "Starting Discourse groups sync to Spaces, dry_run is %s, whitelist is %s items", 149 | dry_run, len(whitelist), 150 | ) 151 | groups = await self.get_groups() 152 | for name, group in groups.items(): 153 | if whitelist and name not in whitelist: 154 | logger.debug("Skipping group %s as it's not in the whitelist") 155 | continue 156 | 157 | logger.info("Ensuring Discourse group %s has a space", name) 158 | group_display_name = group.full_name or group.title or group.short_name 159 | room_params = ( 160 | None, 161 | group_display_name, 162 | group.alias, 163 | None, 164 | group.title, 165 | None, 166 | False, 167 | False, 168 | "space", 169 | ) 170 | try: 171 | _result, space_id = await ensure_room_exists(room_params, client, store, self.config, dry_run=dry_run) 172 | except Exception as ex: 173 | logger.warning("Failed to ensure group %s exists as a space: %s", name, ex) 174 | continue 175 | 176 | # Add to parent spaces based on prefixes 177 | parts = group.name.split('-') 178 | if len(parts) > 1: 179 | prefix = parts[0] 180 | prefixes = spaces_config.get("prefixes", {}) 181 | if prefix in prefixes.keys(): 182 | # Ensure we're a subspace of this parent space 183 | parent_space = prefixes[prefix] 184 | if not dry_run: 185 | await add_membership_in_space( 186 | parent_space=parent_space, 187 | child=space_id, 188 | client=client, 189 | config=self.config, 190 | ) 191 | await add_parent_space( 192 | parent_space=parent_space, 193 | child=space_id, 194 | client=client, 195 | config=self.config, 196 | canonical=True 197 | ) 198 | 199 | def template_compile(template_str: str) -> str: 200 | result = template_str.replace("%groupdisplayname%", group_display_name) 201 | result = result.replace("%groupname%", group.name) 202 | result = result.replace("%grouptitle%", group.title or "") 203 | result = result.replace("%groupshortname%", group.short_alias) 204 | return result 205 | 206 | # Handle space rooms 207 | for room in spaces_config.get("rooms", []): 208 | room_params = ( 209 | None, 210 | template_compile(room.get("templates").get("name")), 211 | template_compile(room.get("templates").get("alias")), 212 | None, 213 | template_compile(room.get("templates").get("title")), 214 | None, 215 | room.get("encrypted"), 216 | room.get("public"), 217 | "room", 218 | ) 219 | try: 220 | _result, room_id = await ensure_room_exists( 221 | room_params, client, store, self.config, dry_run=dry_run, 222 | ) 223 | except Exception as ex: 224 | logger.warning( 225 | "Failed to ensure group %s room %s exists: %s", 226 | group.name, room.get("templates").get("name"), ex, 227 | ) 228 | continue 229 | 230 | # Maintain memberships 231 | if not dry_run: 232 | await add_membership_in_space( 233 | parent_space=space_id, 234 | child=room_id, 235 | client=client, 236 | config=self.config, 237 | suggested=room.get("suggested"), 238 | ) 239 | await add_parent_space( 240 | parent_space=space_id, 241 | child=room_id, 242 | client=client, 243 | config=self.config, 244 | canonical=True 245 | ) 246 | if room.get("joinable_via_parent", False): 247 | # TODO ensure room version compat 248 | # TODO we may want to also fail if room is public currently 249 | # Set join rules 250 | await set_join_rules( 251 | room_alias_or_id=room_id, 252 | join_rule="restricted", 253 | client=client, 254 | allow=[{ 255 | "room_id": space_id, 256 | "type": "m.room_membership", 257 | }] 258 | ) 259 | -------------------------------------------------------------------------------- /bubo/email_strings.py: -------------------------------------------------------------------------------- 1 | INVITE_LINK_EMAIL = """Subject: Invitation to register at %%organisation%% 2 | 3 | Hi! 4 | 5 | You've been invited to signup for an %%organisation%% account. Please go to the following url to signup: %%link%% 6 | 7 | This link is valid for %%days%% days. If you need a new link, please contact the person who invited you to this organisation. 8 | 9 | All the best, 10 | %%organisation%% 11 | """ 12 | -------------------------------------------------------------------------------- /bubo/emails.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | import ssl 3 | 4 | from bubo.config import Config 5 | 6 | 7 | def send_plain_email(config: Config, receiver: str, message: str): 8 | auth = config.email.get("auth") 9 | context = ssl.create_default_context() 10 | host = config.email.get("host") 11 | password = auth.get("password") 12 | port = config.email.get("port") 13 | sender = config.email.get("sender") 14 | username = auth.get("username") 15 | if config.email.get("ssl"): 16 | context = ssl.create_default_context() 17 | with smtplib.SMTP_SSL(host, port, context=context) as server: 18 | if auth: 19 | server.login(username, password) 20 | server.sendmail(sender, receiver, message) 21 | elif config.email.get("starttls"): 22 | with smtplib.SMTP(host, port) as server: 23 | server.starttls(context=context) 24 | if auth: 25 | server.login(username, password) 26 | server.sendmail(sender, receiver, message) 27 | else: 28 | raise Exception("Refusing to send non-secure emails") 29 | -------------------------------------------------------------------------------- /bubo/errors.py: -------------------------------------------------------------------------------- 1 | class ConfigError(RuntimeError): 2 | """An error encountered during reading the config file 3 | 4 | Args: 5 | msg (str): The message displayed to the user on error 6 | """ 7 | def __init__(self, msg): 8 | super(ConfigError, self).__init__("%s" % (msg,)) -------------------------------------------------------------------------------- /bubo/help_strings.py: -------------------------------------------------------------------------------- 1 | HELP_HELP = """Hello, I'm Bubo, a bot made with matrix-nio! 2 | 3 | Available commands: 4 | 5 | * breakout - Create a breakout room 6 | * groupinvite - Invite a pre-defined group to a room 7 | * groupjoin - Alias for `groupinvite` 8 | * invite - Invite one or more users to a room 9 | * join - Join a user to a room 10 | * power - Set power levels in rooms 11 | * rooms - List and manage rooms 12 | * spaces - List and manage spaces 13 | * users - List and manage users and signup links 14 | * pindora - List and manage smart lock keys 15 | 16 | More help on commands or subcommands using 'help' as the next parameter. 17 | 18 | For source code, see https://github.com/elokapina/bubo 19 | """ 20 | 21 | HELP_BREAKOUT = """Creates a breakout room. The user who requested the breakout room creation will 22 | automatically be invited to the room and made admin. The room will be created 23 | as non-public and non-encrypted. 24 | 25 | Other users can react to the breakout room creation response with any emoji reaction to 26 | get an invite to the room. 27 | 28 | Syntax: 29 | 30 | breakout TOPIC 31 | 32 | For example: 33 | 34 | breakout How awesome is Bubo? 35 | 36 | Any remaining text after `breakout` will be used as the room name. 37 | 38 | Note that while Bubo will stay in the breakout room itself, it will not maintain 39 | it in any way like the rooms created using the `rooms` command. 40 | """ 41 | 42 | HELP_GROUPINVITE = """Invite a user to a predefined group of rooms. 43 | 44 | Syntax: 45 | 46 | groupinvite @user1:domain.tld groupname [subgroups] 47 | 48 | Where "groupname" should be replaced with the group to join to. Additionally, 49 | subgroups can be given as well. 50 | 51 | Groups must be configured to the Bubo configuration file by an administrator. 52 | 53 | This command requires coordinator level permissions. 54 | """ 55 | 56 | HELP_INVITE = """"Invite to rooms. 57 | 58 | When given with only a room alias or ID parameter, invites you to that room. 59 | 60 | To invite other users, give one or more user ID's (separated by a space) after 61 | the room alias or ID. 62 | 63 | Examples: 64 | 65 | * Invite yourself to a room: 66 | 67 | `invite #room:example.com` 68 | 69 | * Invite one or more users to a room: 70 | 71 | `invite #room:example.com @user1:example.com @user2:example.org` 72 | 73 | Requires bot coordinator privileges. The bot must be in the room 74 | and with power to invite users. 75 | """ 76 | 77 | HELP_JOIN = """Join one or more users to rooms. 78 | 79 | Syntax: 80 | 81 | join !roomidoralias:domain.tld @user1:domain.tld @user2:domain.tld 82 | 83 | If Bubo has Synapse admin powers, it will try to join admin API join any local users 84 | (which still requires Bubo to be in the room and be able to invite). 85 | Otherwise, a normal onvitation is used. 86 | 87 | This command requires coordinator level permissions. 88 | """ 89 | 90 | HELP_POWER = """Set power level in a room. Usage: 91 | 92 | `power []` 93 | 94 | * `user` is the user ID, example `@user:example.tld` 95 | * `room` is a room alias or ID, example `#room:example.tld`. Bot must have power to give power there. 96 | * `level` is optional and defaults to `moderator`. 97 | 98 | Moderator rights can be given by coordinator level users. To give admin in a room, user must be admin of the bot. 99 | """ 100 | 101 | HELP_ROOMS_AND_SPACES = """Maintains %%TYPES%%. 102 | 103 | When given without parameters, Bubo will tell you about the %%TYPES%% it maintains. 104 | 105 | Subcommands: 106 | 107 | * `alias` 108 | 109 | Add, remove or set main canonical alias for a %%TYPE%%. 110 | 111 | Examples: 112 | 113 | To add an alias to a %%TYPE%%: 114 | 115 | `%%TYPES%% alias !roomidoralias:domain.tld add #alias:domain.tld` 116 | 117 | To make an alias the main canonical alias for a %%TYPE%% (the alias must exist): 118 | 119 | `%%TYPES%% alias !roomidoralias:domain.tld main #alias:domain.tld` 120 | 121 | To remove an alias from a %%TYPE%%: 122 | 123 | `%%TYPES%% alias !roomidoralias:domain.tld remove #alias:domain.tld` 124 | 125 | * `create` 126 | 127 | Create a %%TYPE%% using Bubo. Syntax: 128 | 129 | `%%TYPES%% create NAME ALIAS TITLE ENCRYPTED(yes/no) PUBLIC(yes/no)` 130 | 131 | Example: 132 | 133 | `%%TYPES%% create "My awesome room" epic-room "The best room ever!" yes no` 134 | 135 | Note, ALIAS should only contain lower case ascii characters and dashes. ENCRYPTED and PUBLIC are either 'yes' or 'no'. 136 | 137 | * `link` 138 | 139 | Add a %%TYPE%% to the Bubo database to start tracking it. Will try to join the %%TYPE%% if not a member. 140 | The %%TYPE%% must have an alias. Usage: 141 | 142 | `link #room-alias:domain.tld` 143 | 144 | * `link-and-admin` 145 | 146 | Same as link but will force join through a local user and make Bubo an admin if configured as Synapse admin. Usage: 147 | 148 | `link-and-admin #room-alias:domain.tld` 149 | 150 | * `list` 151 | 152 | Same as without a subcommand, Bubo will tell you all about the %%TYPES%% it maintains. 153 | 154 | * `list-no-admin` 155 | 156 | List any %%TYPES%% Bubo maintains where Bubo lacks admin privileges. 157 | 158 | * `recreate` 159 | 160 | Recreate the current %%TYPE%%. 161 | 162 | * `unlink` 163 | 164 | Remove the %%TYPE%% from Bubo's %%TYPE%% database. The only parameter is a %%TYPE%% ID or alias. 165 | 166 | * `unlink-and-leave` 167 | 168 | Remove the %%TYPE%% from Bubo's %%TYPE%% database, then leave the %%TYPE%%. The only parameter is a %%TYPE%% ID or 169 | alias. 170 | """ 171 | 172 | HELP_ROOMS = HELP_ROOMS_AND_SPACES.replace("%%TYPES%%", "rooms").replace("%%TYPE%%", "room") 173 | HELP_SPACES = HELP_ROOMS_AND_SPACES.replace("%%TYPES%%", "spaces").replace("%%TYPE%%", "space") 174 | 175 | HELP_ROOMS_ALIAS = """Manage room or space aliases. 176 | 177 | Allows adding and removing aliases, and setting the main (canonical) alias of a room or space. 178 | 179 | Format: 180 | 181 | rooms/spaces alias !roomidoralias:domain.tld subcommand #alias:domain.tld 182 | 183 | Where "subcommand" can be one of: "add", "remove", "main". 184 | 185 | Examples: 186 | 187 | rooms/spaces alias !roomidoralias:domain.tld add #alias:domain.tld 188 | rooms/spaces alias !roomidoralias:domain.tld remove #alias:domain.tld 189 | rooms/spaces alias !roomidoralias:domain.tld main #alias:domain.tld 190 | 191 | Notes: 192 | 193 | * "remove" cannot remove the main alias. First add another alias and set it main alias. 194 | * "main" can only handle aliases for the same domain Bubo runs on currently. This may change in the future. 195 | 196 | This command requires coordinator level permissions. 197 | """ 198 | 199 | HELP_ROOMS_RECREATE = """Recreates a room. 200 | 201 | This command is useful for example if the room has no admins. It will recreate the room using the bot, 202 | which means there will again be an admin in the room. Local members will be force joined to the room if 203 | the bot has server admin permissions on a Synapse server. Otherwise invitations will be used to get users 204 | to the new room. 205 | 206 | Requires bot administrator permissions. 207 | 208 | First issue this command without any parameters. Then issue it again with the `confirm` parameter within ten seconds. 209 | This action cannot be reversed so care should be taken. 210 | """ 211 | 212 | HELP_ROOMS_RECREATE_CONFIRM = """Please confirm room re-create with the command `%srooms recreate confirm`. 213 | You have 300s to confirm before this request expires. 214 | 215 | **This is destructive, the room will be replaced. This cannot be reversed!** 216 | """ 217 | 218 | HELP_ROOMS_UNLINK = """Give the room ID or alias to unlink as the first parameter. For example: 219 | 220 | `unlink #myroom:domain.tld` 221 | 222 | To also make Bubo leave the room, use the `unlink-and-leave` command variant. 223 | """ 224 | 225 | HELP_ROOMS_LINK = """Store an existing room in Bubo's database. 226 | Give the room ID or alias to link as the first parameter. For example: 227 | 228 | `link #myroom:domain.tld` 229 | 230 | Bubo will try to join the room, using the admin API if normal join fails. 231 | """ 232 | 233 | HELP_ROOMS_LINK_AND_ADMIN = """Store an existing room in Bubo's database. 234 | Also try to make Bubo an admin of the room, if Bubo is server admin. 235 | Give the room ID or alias to link as the first parameter. For example: 236 | 237 | `link-and-admin #myroom:domain.tld` 238 | 239 | Bubo will try to join the room, using the admin API if normal join fails. 240 | If necessary, it will use the admin API to make an admin of the room make 241 | itself admin. 242 | """ 243 | 244 | HELP_USERS = """List or manage users. 245 | 246 | Without any subcommands, lists users. Other subcommands: 247 | 248 | * `create` - Create one or more Keycloak users. 249 | 250 | * `list` - Lists users in Keycloak. 251 | 252 | * `invite` - Send a a Keycloak Signup invitation link to a user. 253 | 254 | * `rooms` - List rooms of a Matrix user. 255 | 256 | * `signuplink` - Create a signup link with Keycloak Signup. 257 | 258 | For help on subcommands, give the subcommand with a "help" parameter. 259 | """ 260 | 261 | HELP_USERS_CREATE = """Create one or more users. 262 | 263 | Takes one or more email address as parameters. Creates the users in the identity provider, 264 | marking their emails as verified. Then sends them an email with a password reset link. 265 | """ 266 | 267 | HELP_USERS_INVITE = """Invite one or more users. 268 | 269 | Takes one or more email address as parameters. Creates a self-registration page for each user 270 | and sends the user a link to it. 271 | """ 272 | 273 | HELP_USERS_KEYCLOAK_DISABLED = "The users command is not configured on this instance, sorry." 274 | 275 | HELP_USERS_KEYCLOAK_SIGNUP_DISABLED = "The users invite and signup link commands are not configured " \ 276 | "on this instance, sorry." 277 | 278 | HELP_USERS_ROOMS = """List the rooms of a user. 279 | 280 | Usage: 281 | 282 | users rooms @user:domain.tld 283 | 284 | Requires bot admin permissions. Bubo must also be a Synapse admin. 285 | """ 286 | 287 | HELP_USERS_SIGNUPLINK = """Create a self-service signup link to send to new users. 288 | 289 | Creates a unique signup link. The link will have a configured amount of maximum signups and validity days. Usage: 290 | 291 | `users signuplink ` 292 | 293 | For example `users signupslink 50 7` would create a link for a maximum of 50 signups and with a validity period 294 | of 7 days. 295 | """ 296 | 297 | HELP_PINDORA_DISABLED = """ 298 | Pindora is currently not enabled for this bot. 299 | """ 300 | 301 | HELP_KEYS = """ 302 | Create a Pindora key with name that is valid x hours from now: 303 | `pindora create key_name valid_for_hours` 304 | 305 | The default value for hours is 3. 306 | 307 | For example, `pindora create test 5` would create a key with name "test" for 5 hours. 308 | """ 309 | -------------------------------------------------------------------------------- /bubo/message_responses.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bubo.chat_functions import send_text_to_room 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class Message(object): 9 | 10 | def __init__(self, client, store, config, message_content, room, event): 11 | """Initialize a new Message 12 | 13 | Args: 14 | client (nio.AsyncClient): nio client used to interact with matrix 15 | 16 | store (Storage): Bot storage 17 | 18 | config (Config): Bot configuration parameters 19 | 20 | message_content (str): The body of the message 21 | 22 | room (nio.rooms.MatrixRoom): The room the event came from 23 | 24 | event (nio.events.room_events.RoomMessageText): The event defining the message 25 | """ 26 | self.client = client 27 | self.store = store 28 | self.config = config 29 | self.message_content = message_content 30 | self.room = room 31 | self.event = event 32 | 33 | async def process(self): 34 | """Process and possibly respond to the message""" 35 | if self.message_content.lower() == "hello world": 36 | await self._hello_world() 37 | 38 | async def _hello_world(self): 39 | """Say hello""" 40 | text = "Hello, world!" 41 | await send_text_to_room(self.client, self.room.room_id, text) 42 | 43 | -------------------------------------------------------------------------------- /bubo/migrations/001.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | # sync_token table 3 | cursor.execute(""" 4 | CREATE TABLE sync_token ( 5 | dedupe_id INTEGER PRIMARY KEY, 6 | token TEXT NOT NULL 7 | ) 8 | """) 9 | -------------------------------------------------------------------------------- /bubo/migrations/002.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | cursor.execute(""" 3 | CREATE TABLE rooms ( 4 | id INTEGER PRIMARY KEY autoincrement, 5 | name text, 6 | alias text, 7 | room_id text null, 8 | title text default '', 9 | icon text default '', 10 | encrypted integer, 11 | public integer, 12 | power_to_write integer default 0 13 | ) 14 | """) 15 | cursor.execute(""" 16 | CREATE TABLE communities ( 17 | id INTEGER PRIMARY KEY autoincrement, 18 | name text, 19 | alias text, 20 | title text default '', 21 | icon text default '', 22 | description text default '' 23 | ) 24 | """) 25 | cursor.execute(""" 26 | CREATE TABLE community_rooms ( 27 | id INTEGER PRIMARY KEY autoincrement, 28 | room_id integer, 29 | community_id integer, 30 | constraint room_community_unique_idx unique (room_id, community_id) 31 | ) 32 | """) 33 | 34 | -------------------------------------------------------------------------------- /bubo/migrations/003.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | cursor.execute(""" 3 | CREATE TABLE rooms_backup ( 4 | id INTEGER PRIMARY KEY, 5 | name text, 6 | alias text, 7 | room_id text null, 8 | title text default '', 9 | icon text default '', 10 | encrypted integer, 11 | public integer, 12 | power_to_write integer default 0 13 | ) 14 | """) 15 | cursor.execute(""" 16 | INSERT INTO rooms_backup SELECT id, name, alias, room_id, title, icon, encrypted, public, power_to_write 17 | FROM rooms 18 | """) 19 | cursor.execute(""" 20 | DROP TABLE rooms 21 | """) 22 | cursor.execute(""" 23 | CREATE TABLE rooms ( 24 | id INTEGER PRIMARY KEY autoincrement, 25 | name text, 26 | alias text constraint room_alias_unique_idx unique, 27 | room_id text null constraint room_room_id_unique_idx unique, 28 | title text default '', 29 | icon text default '', 30 | encrypted integer, 31 | public integer, 32 | power_to_write integer default 0 33 | ) 34 | """) 35 | cursor.execute(""" 36 | INSERT INTO rooms SELECT id, name, alias, room_id, title, icon, encrypted, public, power_to_write 37 | FROM rooms_backup 38 | """) 39 | cursor.execute(""" 40 | DROP TABLE rooms_backup 41 | """) 42 | -------------------------------------------------------------------------------- /bubo/migrations/004.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | cursor.execute(""" 3 | CREATE TABLE communities_backup ( 4 | id INTEGER PRIMARY KEY, 5 | name text, 6 | alias text, 7 | title text default '', 8 | icon text default '', 9 | description text default '' 10 | ) 11 | """) 12 | cursor.execute(""" 13 | INSERT INTO communities_backup SELECT id, name, alias, title, icon, description 14 | FROM communities 15 | """) 16 | cursor.execute(""" 17 | DROP TABLE communities 18 | """) 19 | cursor.execute(""" 20 | CREATE TABLE communities ( 21 | id INTEGER PRIMARY KEY autoincrement, 22 | name text, 23 | alias text constraint community_alias_unique_idx unique, 24 | title text default '', 25 | icon text default '', 26 | description text default '' 27 | ) 28 | """) 29 | cursor.execute(""" 30 | INSERT INTO communities SELECT id, name, alias, title, icon, description 31 | FROM communities_backup 32 | """) 33 | cursor.execute(""" 34 | DROP TABLE communities_backup 35 | """) 36 | -------------------------------------------------------------------------------- /bubo/migrations/005.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | cursor.execute(""" 3 | ALTER TABLE rooms 4 | ADD type text default '' 5 | """) 6 | -------------------------------------------------------------------------------- /bubo/migrations/006.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | cursor.execute(""" 3 | CREATE TABLE breakout_rooms ( 4 | id INTEGER PRIMARY KEY autoincrement, 5 | event_id text, 6 | room_id text 7 | ) 8 | """) 9 | -------------------------------------------------------------------------------- /bubo/migrations/007.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | # Remove column 'power_to_write' from rooms 3 | cursor.execute(""" 4 | CREATE TABLE rooms_backup ( 5 | id INTEGER PRIMARY KEY, 6 | name text, 7 | alias text, 8 | room_id text null, 9 | title text default '', 10 | icon text default '', 11 | encrypted integer, 12 | public integer, 13 | type text default '' 14 | ) 15 | """) 16 | cursor.execute(""" 17 | INSERT INTO rooms_backup SELECT id, name, alias, room_id, title, icon, encrypted, public, type 18 | FROM rooms 19 | """) 20 | cursor.execute(""" 21 | DROP TABLE rooms 22 | """) 23 | cursor.execute(""" 24 | CREATE TABLE rooms ( 25 | id INTEGER PRIMARY KEY autoincrement, 26 | name text, 27 | alias text constraint room_alias_unique_idx unique, 28 | room_id text null constraint room_room_id_unique_idx unique, 29 | title text default '', 30 | icon text default '', 31 | encrypted integer, 32 | public integer, 33 | type text default '' 34 | ) 35 | """) 36 | cursor.execute(""" 37 | INSERT INTO rooms SELECT id, name, alias, room_id, title, icon, encrypted, public, type 38 | FROM rooms_backup 39 | """) 40 | cursor.execute(""" 41 | DROP TABLE rooms_backup 42 | """) 43 | -------------------------------------------------------------------------------- /bubo/migrations/008.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | cursor.execute(""" 3 | CREATE TABLE recreate_rooms ( 4 | id INTEGER PRIMARY KEY autoincrement, 5 | requester text, 6 | room_id text, 7 | timestamp integer, 8 | applied integer default 0 9 | ) 10 | """) 11 | -------------------------------------------------------------------------------- /bubo/migrations/009.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | cursor.execute(""" 3 | CREATE TABLE encrypted_events ( 4 | id INTEGER PRIMARY KEY autoincrement, 5 | device_id text, 6 | event_id text unique, 7 | room_id text, 8 | session_id text, 9 | event text 10 | ) 11 | """) 12 | cursor.execute(""" 13 | CREATE INDEX encrypted_events_session_id_idx on encrypted_events (session_id); 14 | """) 15 | -------------------------------------------------------------------------------- /bubo/migrations/010.py: -------------------------------------------------------------------------------- 1 | def forward(cursor): 2 | cursor.execute(""" 3 | DROP TABLE communities 4 | """) 5 | -------------------------------------------------------------------------------- /bubo/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elokapina/bubo/abf314818af509415fbf6c1fab1d05a4e77ab5ad/bubo/migrations/__init__.py -------------------------------------------------------------------------------- /bubo/rooms.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | from copy import deepcopy 5 | from typing import Tuple, Optional, List, Dict, Union 6 | 7 | import aiohttp 8 | from aiohttp import ClientResponse 9 | # noinspection PyPackageRequirements 10 | from nio import ( 11 | AsyncClient, RoomVisibility, EnableEncryptionBuilder, RoomPutStateError, RoomGetStateEventError, 12 | RoomPutStateResponse, RoomGetStateEventResponse, MatrixRoom, RoomCreateError, RoomInviteError, 13 | ) 14 | # noinspection PyPackageRequirements 15 | from nio.http import TransportResponse 16 | # TODO fix missing __all__ in matrix-nio 17 | # noinspection PyProtectedMember, PyPackageRequirements 18 | from nio.responses import RoomPutAliasError, RoomDeleteAliasError 19 | 20 | from bubo import synapse_admin 21 | from bubo.chat_functions import invite_to_room, send_text_to_room, send_text_to_room_c2s 22 | from bubo.config import Config 23 | from bubo.storage import Storage 24 | from bubo.utils import with_ratelimit, get_users_for_access, ensure_room_id 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | async def add_alias(room_alias_or_id: str, alias: str, client: AsyncClient) -> None: 30 | """ 31 | Add a new alias to a room. Doesn't make it canonical, also 32 | doesn't modify Bubo database which contains canonical aliases only. 33 | """ 34 | room_id = await ensure_room_id(client, room_alias_or_id) 35 | response = await client.room_put_alias( 36 | room_alias=alias, 37 | room_id=room_id, 38 | ) 39 | if isinstance(response, RoomPutAliasError): 40 | raise Exception(f"Failed to add alias {alias} to room {room_id}: {response.message}") 41 | 42 | 43 | async def add_membership_in_space( 44 | parent_space: str, child: str, client: AsyncClient, config: Config, suggested: bool = False, 45 | ) -> None: 46 | """ 47 | Add a space or room as child to a space 48 | """ 49 | parent_id = await ensure_room_id(client, parent_space) 50 | child_id = await ensure_room_id(client, child) 51 | response = await client.room_get_state_event( 52 | room_id=parent_id, 53 | event_type="m.space.child", 54 | state_key=child_id, 55 | ) 56 | if isinstance(response, RoomGetStateEventResponse): 57 | content = response.content 58 | if content.get('errcode', "") != 'M_NOT_FOUND': 59 | if content.get("suggested") == suggested and content.get("via") == [config.server_name]: 60 | logger.debug("Child %s membership in %s looks good already, not adding membership", 61 | child, parent_space) 62 | return 63 | 64 | logger.info("Adding child %s to %s", child, parent_space) 65 | order = child.split(":")[0].lstrip("#") if child.startswith("#") else None 66 | content = { 67 | "suggested": suggested, 68 | "via": [config.server_name], 69 | } 70 | if order: 71 | content["order"] = order 72 | response = await client.room_put_state( 73 | room_id=parent_id, 74 | event_type="m.space.child", 75 | content=content, 76 | state_key=child_id, 77 | ) 78 | if isinstance(response, RoomPutStateError): 79 | raise Exception(f"Failed to add child {child} to {parent_space}: {response.message}") 80 | 81 | 82 | async def add_parent_space( 83 | parent_space: str, child: str, client: AsyncClient, config: Config, canonical: bool = False, 84 | ) -> None: 85 | """ 86 | Add a space as parent to space or room 87 | """ 88 | parent_id = await ensure_room_id(client, parent_space) 89 | child_id = await ensure_room_id(client, child) 90 | response = await client.room_get_state_event( 91 | room_id=child_id, 92 | event_type="m.space.parent", 93 | state_key=parent_id, 94 | ) 95 | if isinstance(response, RoomGetStateEventResponse): 96 | content = response.content 97 | if content.get('errcode', "") != 'M_NOT_FOUND': 98 | if content.get("canonical") == canonical and content.get("via") == [config.server_name]: 99 | logger.debug("Parent space %s membership in %s looks good already, not adding parent space", 100 | parent_space, child) 101 | return 102 | 103 | logger.info("Adding parent space %s to %s", parent_space, child) 104 | 105 | response = await client.room_put_state( 106 | room_id=child_id, 107 | event_type="m.space.parent", 108 | content={ 109 | "canonical": canonical, 110 | "via": [config.server_name], 111 | }, 112 | state_key=parent_id, 113 | ) 114 | if isinstance(response, RoomPutStateError): 115 | raise Exception(f"Failed to add parent space {parent_space} to {child}: {response.message}") 116 | 117 | 118 | async def create_breakout_room( 119 | name: str, client: AsyncClient, created_by: str 120 | ) -> Dict: 121 | """ 122 | Create a breakout room. 123 | """ 124 | logger.info(f"Attempting to create breakout room '{name}'") 125 | response = await with_ratelimit( 126 | client, 127 | "room_create", 128 | name=name, 129 | visibility=RoomVisibility.private, 130 | ) 131 | if getattr(response, "room_id", None): 132 | room_id = response.room_id 133 | logger.info(f"Breakout room '{name}' created at {room_id}") 134 | else: 135 | raise Exception(f"Could not create breakout room: {response.message}, {response.status_code}") 136 | await set_user_power(room_id, created_by, client, 100) 137 | await invite_to_room(client, room_id, created_by) 138 | return room_id 139 | 140 | 141 | async def delete_user_room_tag( 142 | config: Config, session: aiohttp.ClientSession, user: str, room_id: str, token: str, tag: str, 143 | ) -> bool: 144 | async with session.delete( 145 | f"{config.homeserver_url}/_matrix/client/r0/user/{user}/rooms/{room_id}/tags/{tag}", 146 | headers={ 147 | "Authorization": f"Bearer {token}", 148 | }, 149 | ) as response: 150 | if response.status == 429: 151 | await asyncio.sleep(1) 152 | return await delete_user_room_tag(config, session, user, room_id, token, tag) 153 | try: 154 | response.raise_for_status() 155 | logger.debug("Delete room tag for user: %s, %s, %s", user, room_id, tag) 156 | return True 157 | except Exception as ex: 158 | logger.warning("Failed to delete room tag %s for user %s for room %s: %s", tag, user, room_id, ex) 159 | return False 160 | 161 | 162 | async def ensure_room_power_levels( 163 | room_id: str, client: AsyncClient, config: Config, members: List, 164 | ): 165 | """ 166 | Ensure room has correct power levels. 167 | """ 168 | logger.debug(f"Ensuring power levels: {room_id}") 169 | state, users = await get_room_power_levels(client, room_id) 170 | member_ids = {member.user_id for member in members} 171 | coordinators = await get_users_for_access(client, config, "coordinators") 172 | 173 | # check existing users 174 | for mxid, level in users.items(): 175 | if mxid == config.user_id: 176 | continue 177 | 178 | # Promote users if they need more power 179 | if config.permissions_promote_users: 180 | if mxid in coordinators and mxid in member_ids and level < 50: 181 | users[mxid] = 50 182 | # Demote users if they should have less power 183 | if config.permissions_demote_users: 184 | if mxid not in coordinators and level > 0: 185 | users[mxid] = 0 186 | # Always demote users with too much power if not in the room 187 | if mxid not in member_ids: 188 | users[mxid] = 0 189 | 190 | # check new users 191 | if config.permissions_promote_users: 192 | for user in coordinators: 193 | if user in member_ids and user != config.user_id: 194 | users[user] = 50 195 | 196 | power_levels = config.rooms.get("power_levels") if config.rooms.get("enforce_power_in_old_rooms", True) else {} 197 | new_power = deepcopy(state.content) 198 | new_power.update(power_levels) 199 | new_power["users"] = users 200 | 201 | if state.content != new_power: 202 | logger.info(f"Updating room {room_id} power levels") 203 | response = await with_ratelimit( 204 | client, 205 | "room_put_state", 206 | room_id=room_id, 207 | event_type="m.room.power_levels", 208 | content=new_power, 209 | ) 210 | logger.debug(f"Power levels update response: {response}") 211 | 212 | 213 | async def ensure_room_exists( 214 | room: tuple, client: AsyncClient, store: Storage, config: Config, dry_run: bool = False, 215 | ) -> Tuple[str, str]: 216 | """ 217 | Maintains a room. 218 | 219 | Returns a tuple of: 220 | - created/exists string 221 | - room_id 222 | """ 223 | dbid, name, alias, room_id, title, icon, encrypted, public, room_type = room 224 | if room_type not in ("space", "room"): 225 | room_type = "room" 226 | logger.debug("Ensuring %s: %s", room_type, room) 227 | room_created = False 228 | logger.info("Ensuring %s %s (%s) exists", room_type, name, alias) 229 | 230 | if not dbid: 231 | # Check if we track this room already 232 | db_room = store.get_room_by_alias(alias) 233 | if db_room: 234 | dbid = db_room[0] 235 | room_id = db_room[3] 236 | logger.info("%s %s found in the database as %s", room_type.capitalize(), name, room_id) 237 | 238 | if not room_id: 239 | # Check if room exists 240 | response = await client.room_resolve_alias(f"#{alias}:{config.server_name}") 241 | if getattr(response, "room_id", None): 242 | room_id = response.room_id 243 | logger.info("%s '%s' resolved to %s", room_type.capitalize(), alias, room_id) 244 | else: 245 | logger.info("Could not resolve %s '%s', will try create", room_type, alias) 246 | # Create room 247 | space = room_type == "space" 248 | state = [] 249 | if encrypted: 250 | state.append( 251 | EnableEncryptionBuilder().as_dict(), 252 | ) 253 | if not dry_run: 254 | response = await client.room_create( 255 | visibility=RoomVisibility.public if public else RoomVisibility.private, 256 | alias=alias, 257 | name=name, 258 | topic=title, 259 | initial_state=state, 260 | power_level_override=config.rooms.get("power_levels"), 261 | space=space, 262 | ) 263 | if getattr(response, "room_id", None): 264 | room_id = response.room_id 265 | logger.info(f"Room '{alias}' created at {room_id}") 266 | room_created = True 267 | else: 268 | if response.status_code == "M_LIMIT_EXCEEDED": 269 | # Wait and try again 270 | logger.info("Hit request limits, waiting 3 seconds...") 271 | time.sleep(3) 272 | return await ensure_room_exists(room, client, store, config) 273 | raise Exception(f"Could not create room: {response.message}, {response.status_code}") 274 | else: 275 | logger.info("Not creating %s '%s' due to dry run", room_type, alias) 276 | if not dry_run: 277 | if dbid: 278 | # Store room ID 279 | store.cursor.execute(""" 280 | update rooms set room_id = ? where id = ? 281 | """, (room_id, dbid)) 282 | store.conn.commit() 283 | logger.info("%s '%s' room ID stored to database", room_type.capitalize(), alias) 284 | else: 285 | store.store_room(name, alias, room_id, title, encrypted, public, room_type) 286 | logger.info("%s '%s' creation stored to database", room_type, alias) 287 | 288 | if not dry_run: 289 | room_members = await with_ratelimit(client, "joined_members", room_id) 290 | members = getattr(room_members, "members", []) 291 | 292 | await ensure_room_power_levels(room_id, client, config, members) 293 | 294 | if room_created: 295 | return "created", room_id 296 | return "exists", room_id 297 | 298 | 299 | async def get_room_power_levels( 300 | client: AsyncClient, room_id: str, 301 | ) -> Tuple[Optional[RoomGetStateEventResponse], Optional[Dict]]: 302 | logger.debug(f"Fetching power levels for {room_id}") 303 | state = None 304 | try: 305 | state = await with_ratelimit(client, "room_get_state_event", room_id=room_id, event_type="m.room.power_levels") 306 | logger.debug(f"Found power levels state: {state}") 307 | users = state.content["users"].copy() 308 | except KeyError as ex: 309 | logger.warning(f"Error looking for power levels for room {room_id}: {ex} - state: {state}") 310 | return None, None 311 | return state, users 312 | 313 | 314 | async def get_user_room_tags( 315 | config: Config, session: aiohttp.ClientSession, user: str, room_id: str, token: str, 316 | ) -> Optional[Dict]: 317 | async with session.get( 318 | f"{config.homeserver_url}/_matrix/client/r0/user/{user}/rooms/{room_id}/tags", 319 | headers={ 320 | "Authorization": f"Bearer {token}", 321 | }, 322 | ) as response: 323 | if response.status == 429: 324 | await asyncio.sleep(1) 325 | return await get_user_room_tags(config, session, user, room_id, token) 326 | try: 327 | response.raise_for_status() 328 | data = await response.json() 329 | logger.debug("Got room tags for user: %s, %s, %s", user, room_id, data) 330 | return data["tags"] 331 | except Exception as ex: 332 | logger.warning("Failed to get room tags for user %s for room %s: %s", user, room_id, ex) 333 | return 334 | 335 | 336 | async def get_room_directory_status(config: Config, session: aiohttp.ClientSession, room_id: str) -> Optional[str]: 337 | async with session.get( 338 | f"{config.homeserver_url}/_matrix/client/r0/directory/list/room/{room_id}", 339 | headers={ 340 | "Authorization": f"Bearer {config.user_token}", 341 | }, 342 | ) as response: 343 | if response.status == 429: 344 | await asyncio.sleep(1) 345 | return await get_room_directory_status(config, session, room_id) 346 | try: 347 | response.raise_for_status() 348 | data = await response.json() 349 | logger.debug("Got room directory visibility: %s, %s", room_id, data) 350 | return data.get("visibility") 351 | except Exception as ex: 352 | logger.warning("Failed to get room directory visibility for room %s: %s", room_id, ex) 353 | return 354 | 355 | 356 | async def recreate_room( 357 | room: MatrixRoom, client: AsyncClient, config: Config, store: Storage, last_event_id: str, 358 | keep_encryption: bool = True, 359 | ) -> Optional[str]: 360 | """ 361 | Replace a room with a new room. 362 | """ 363 | try: 364 | alias = None 365 | alt_aliases = [] 366 | # Remove aliases from the old room 367 | aliases = await client.room_get_state_event(room.room_id, "m.room.canonical_alias") 368 | if isinstance(aliases, RoomGetStateEventResponse): 369 | alias = aliases.content.get("alias") 370 | alt_aliases = aliases.content.get("alt_aliases", []) 371 | 372 | if alias or alt_aliases: 373 | logger.info(f"Removing canonical alias {alias} and {len(alt_aliases)} alt aliases from old room") 374 | if alias: 375 | await client.room_delete_alias(room_alias=alias) 376 | await client.room_put_state( 377 | room_id=room.room_id, 378 | event_type="m.room.canonical_alias", 379 | content={ 380 | "alias": None, 381 | "alt_aliases": [], 382 | }, 383 | ) 384 | 385 | # Get room visibility 386 | room_visibility = await with_ratelimit(client, "room_get_visibility", room_id=room.room_id) 387 | logger.debug(f"Room visibility is: {room_visibility}") 388 | 389 | # Calculate users 390 | users = {user.user_id for user in room.users.values() if user.user_id != config.user_id} 391 | invited_users = {user.user_id for user in room.invited_users.values() if user.user_id != config.user_id} 392 | users = users.union(invited_users) 393 | 394 | # Power levels 395 | power_levels, _users = await get_room_power_levels(client, room.room_id) 396 | # Ensure we don't immediately demote ourselves 397 | power_levels.content["users"][config.user_id] = 100 398 | # Add secondary admin if configured 399 | if config.rooms.get("secondary_admin"): 400 | users.add(config.rooms.get("secondary_admin")) 401 | power_levels.content["users"][config.rooms.get("secondary_admin")] = 100 402 | 403 | # Create new room 404 | local_users = [user for user in users if user.endswith(f":{config.server_name}") and user != config.user_id] 405 | remote_users = [user for user in users if not user.endswith(f":{config.server_name}") 406 | and user != config.user_id] 407 | initial_state = [] 408 | if room.encrypted and keep_encryption: 409 | initial_state.append({ 410 | "type": "m.room.encryption", 411 | "state_key": "", 412 | "content": { 413 | "algorithm": "m.megolm.v1.aes-sha2", 414 | "rotation_period_ms": 604800000, 415 | "rotation_period_msgs": 100, 416 | }, 417 | }) 418 | 419 | logger.info(f"Recreating room {room.room_id} for {len(users)} users") 420 | federated = True if config.rooms.get("recreate_as_federated", False) else room.federate 421 | new_room = await client.room_create( 422 | visibility=RoomVisibility(room_visibility.visibility), 423 | name=room.name, 424 | topic=room.topic, 425 | federate=federated, 426 | initial_state=initial_state, 427 | power_level_override=power_levels.content, 428 | predecessor={ 429 | "event_id": last_event_id, 430 | "room_id": room.room_id, 431 | }, 432 | ) 433 | if isinstance(new_room, RoomCreateError): 434 | logger.warning(f"Failed to create new room: {new_room.status_code} / {new_room.message}") 435 | return 436 | 437 | logger.info(f"New room id for {room.room_id} is {new_room.room_id}") 438 | 439 | # Rename the old room 440 | await client.room_put_state( 441 | room_id=room.room_id, 442 | event_type="m.room.name", 443 | content={ 444 | "name": f"{config.rooms.get('recreate_old_room_name_prefix', 'OLD')} {room.name}", 445 | }, 446 | ) 447 | 448 | # Move room avatar 449 | avatar_state = await client.room_get_state_event(room.room_id, "m.room.avatar") 450 | if isinstance(avatar_state, RoomGetStateEventResponse): 451 | await client.room_put_state( 452 | room_id=new_room.room_id, 453 | event_type="m.room.avatar", 454 | content=avatar_state.content, 455 | ) 456 | await client.room_put_state( 457 | room_id=room.room_id, 458 | event_type="m.room.avatar", 459 | content={ 460 | "url": None, 461 | }, 462 | ) 463 | 464 | # If maintained by Bubo, update the database 465 | if alias and alias.endswith(f":{config.server_name}"): 466 | maintained_room_id = store.get_room_id(alias) 467 | if maintained_room_id: 468 | store.set_room_id(alias, new_room.room_id) 469 | 470 | # Various things if Synapse admin for local users 471 | if config.is_synapse_admin: 472 | # Try to force join local users 473 | if local_users: 474 | try: 475 | joined_count = await synapse_admin.join_users(config, local_users, new_room.room_id) 476 | logger.debug(f"Successfully joined {joined_count} local users to new room {new_room.room_id}") 477 | except Exception as ex: 478 | logger.warning( 479 | f"Failed to join any local users to new room {new_room.room_id} via Synapse admin: {ex}", 480 | ) 481 | 482 | # Get temporary access tokens for the users 483 | user_tokens = await synapse_admin.get_temporary_user_tokens(config, local_users) 484 | async with aiohttp.ClientSession() as session: 485 | for user, token in user_tokens.items(): 486 | logger.debug("Room tag tokens: %s, %s", user, token) 487 | # Copy over room tags 488 | tags = await get_user_room_tags(config, session, user, room.room_id, token) 489 | logger.debug("Got tags: %s", tags) 490 | if tags: 491 | # Copy to the new room 492 | await set_user_room_tags(config, session, user, new_room.room_id, token, tags) 493 | # Remove favourite from old room 494 | if "m.favourite" in tags.keys(): 495 | await delete_user_room_tag(config, session, user, room.room_id, token, "m.favourite") 496 | # Mark old room as low priority, if not already 497 | if not tags or "m.lowpriority" not in tags.keys(): 498 | await set_user_room_tag(config, session, user, room.room_id, token, "m.lowpriority", 0) 499 | 500 | # Invites, all if not synapse admin, remote if locals were joined already 501 | invite_users = remote_users 502 | if not config.is_synapse_admin: 503 | invite_users = invite_users + remote_users 504 | 505 | for user in invite_users: 506 | response = await client.room_invite(new_room.room_id, user) 507 | if isinstance(response, RoomInviteError): 508 | logger.warning(f"Failed to invite user {user} to new room {new_room.room_id}: " 509 | f"{response.message} / {response.status_code}") 510 | 511 | # Add aliases to the new room 512 | if alias or alt_aliases: 513 | if alias: 514 | # noinspection PyBroadException 515 | try: 516 | await add_alias(room_alias_or_id=new_room.room_id, alias=alias, client=client) 517 | except Exception: 518 | await send_text_to_room_c2s( 519 | config, 520 | new_room.room_id, 521 | f"**Warning**: There was an error updating the alias of the new room - please see bot logs", 522 | ) 523 | else: 524 | await client.room_put_state( 525 | room_id=new_room.room_id, 526 | event_type="m.room.canonical_alias", 527 | content={ 528 | "alias": alias, 529 | "alt_aliases": alt_aliases, 530 | }, 531 | ) 532 | 533 | # Room directory 534 | async with aiohttp.ClientSession() as session: 535 | directory_visibility = await get_room_directory_status(config, session, room.room_id) 536 | if directory_visibility == "public": 537 | await set_room_directory_status(config, session, room.room_id, "private") 538 | await set_room_directory_status(config, session, new_room.room_id, "public") 539 | 540 | # Post a message to the start of the timeline of the new room and the end of the timeline for the 541 | # old room 542 | old_room_link = f"https://matrix.to/#/{room.room_id}?via={config.server_name}" 543 | new_room_link = f"https://matrix.to/#/{new_room.room_id}?via={config.server_name}" 544 | await send_text_to_room( 545 | client, 546 | room.room_id, 547 | f"#### This room has been replaced\n\nTo continue discussion in the new room, click this " 548 | f"link: {new_room_link}", 549 | ) 550 | # Unfortunately we can't use matrix-nio here as it doesn't know about the room and will die with 551 | # LocalProtocolError as it fails to find the room in its local cache. Use the C2S API directly. 552 | await send_text_to_room_c2s( 553 | config, 554 | new_room.room_id, 555 | f"#### This room replaces the old '{room.name}' room {room.room_id}.\n\nShould you need to view " 556 | f"the old room, click this link: {old_room_link}", 557 | ) 558 | 559 | return new_room.room_id 560 | except Exception as ex: 561 | import traceback 562 | logger.error(f"Failed to recreate room {room.room_id}: {ex}") 563 | logger.error(traceback.format_exc()) 564 | try: 565 | await send_text_to_room( 566 | client, 567 | room.room_id, 568 | f"Failed to recreate room. Please look at logs or contact support.", 569 | ) 570 | except Exception as ex: 571 | logger.error(f"Failed to inform of error to the room to be recreated: {ex}") 572 | 573 | 574 | async def remove_alias(room_alias_or_id: str, alias: str, client: AsyncClient) -> None: 575 | """ 576 | Remove alias from a room. 577 | 578 | Cannot be used to remove the canonical alias. 579 | """ 580 | room_id = await ensure_room_id(client, room_alias_or_id) 581 | # Check not canonical 582 | response = await client.room_get_state_event( 583 | room_id=room_id, 584 | event_type="m.room.canonical_alias", 585 | ) 586 | if isinstance(response, RoomGetStateEventError) or response.content.get("alias") == alias: 587 | raise Exception("Unable to remove canonical alias or canonical alias not found") 588 | 589 | # Remove 590 | response = await client.room_delete_alias( 591 | room_alias=alias, 592 | ) 593 | if isinstance(response, RoomDeleteAliasError): 594 | raise Exception(f"Failed to delete alias {alias} from room {room_id}: {response.message}") 595 | 596 | 597 | async def set_canonical_alias( 598 | room_alias_or_id: str, alias: str, client: AsyncClient, store: Storage, config: Config, 599 | ) -> None: 600 | """ 601 | Set a room alias as canonical (ie main alias). 602 | 603 | Also updates Bubo database, if the room is registered to Bubo. 604 | """ 605 | # If Bubo owned room, only allow local aliases 606 | # Because of previous decisions to store aliases as the localpart. 607 | # To remove this restriction, refractoring needs to be done. 608 | try: 609 | if alias.split(':')[1] != config.server_name: 610 | raise Exception("Currently can only set canonical aliases to same server where the bot runs, sorry.") 611 | except IndexError: 612 | raise Exception("Alias format seems wrong, should be for example #alias:domain.tld") 613 | 614 | room_id = await ensure_room_id(client, room_alias_or_id) 615 | # Get any previous alt aliases 616 | alt_aliases = [] 617 | response = await client.room_get_state_event( 618 | room_id=room_id, 619 | event_type="m.room.canonical_alias", 620 | ) 621 | if isinstance(response, RoomGetStateEventResponse): 622 | alt_aliases = response.content.get("alt_aliases", []) 623 | 624 | # Set the canonical alias 625 | await client.room_put_state( 626 | room_id=room_id, 627 | event_type="m.room.canonical_alias", 628 | content={ 629 | "alias": alias, 630 | "alt_aliases": alt_aliases, 631 | }, 632 | ) 633 | if isinstance(response, RoomPutStateError): 634 | raise Exception(f"Failed to set canonical alias {alias} for room {room_id}: {response.message}") 635 | 636 | # Update Bubo db if needed, assuming this is a locally owned alias 637 | room = store.get_room_by_alias(alias) 638 | if room: 639 | store.set_room_alias(room_id, alias) 640 | 641 | 642 | async def set_join_rules( 643 | room_alias_or_id: str, join_rule: str, client: AsyncClient, allow: List = None, 644 | ) -> None: 645 | """ 646 | Set a join rule for a room or space. 647 | 648 | Optionally give allow conditions. 649 | """ 650 | room_id = await ensure_room_id(client, room_alias_or_id) 651 | response = await client.room_get_state_event( 652 | room_id=room_id, 653 | event_type="m.room.join_rules", 654 | ) 655 | if isinstance(response, RoomGetStateEventResponse): 656 | content = response.content 657 | if content.get('errcode', "") != 'M_NOT_FOUND': 658 | if content.get("join_rule") == join_rule and content.get("allow", None) == allow: 659 | logger.debug("Join rule looks good for %s, not modifying", room_id) 660 | return 661 | 662 | logger.info("Setting join rule of %s to %s with allow %s", room_id, join_rule, allow) 663 | 664 | content = { 665 | "join_rule": join_rule, 666 | } 667 | if allow: 668 | content["allow"] = allow 669 | 670 | response = await client.room_put_state( 671 | room_id=room_id, 672 | event_type="m.room.join_rules", 673 | content=content, 674 | ) 675 | if isinstance(response, RoomPutStateError): 676 | raise Exception(f"Failed to set join rules of {room_id}: {response.message}") 677 | 678 | 679 | async def set_room_directory_status( 680 | config: Config, session: aiohttp.ClientSession, room_id: str, visibility: str, 681 | ) -> bool: 682 | async with session.put( 683 | f"{config.homeserver_url}/_matrix/client/r0/directory/list/room/{room_id}", 684 | json={ 685 | "visibility": visibility, 686 | }, 687 | headers={ 688 | "Authorization": f"Bearer {config.user_token}", 689 | }, 690 | ) as response: 691 | if response.status == 429: 692 | await asyncio.sleep(1) 693 | return await set_room_directory_status(config, session, room_id, visibility) 694 | try: 695 | response.raise_for_status() 696 | logger.debug("Room directory visibility has been set: %s, %s", room_id, visibility) 697 | return True 698 | except Exception as ex: 699 | logger.warning("Failed to set room directory visibility for room %s: %s", room_id, ex) 700 | return False 701 | 702 | 703 | async def set_user_power( 704 | room_id: str, user_id: str, client: AsyncClient, power: int, 705 | ) -> Union[int, RoomGetStateEventError, RoomGetStateEventResponse, RoomPutStateError, RoomPutStateResponse]: 706 | """ 707 | Set user power in a room. 708 | """ 709 | logger.debug(f"Setting user power: {room_id}, user: {user_id}, level: {power}") 710 | state_response = await client.room_get_state_event(room_id, "m.room.power_levels") 711 | if isinstance(state_response, RoomGetStateEventError): 712 | logger.error(f"Failed to fetch room {room_id} state: {state_response.message}") 713 | return state_response 714 | if isinstance(state_response.transport_response, TransportResponse): 715 | status_code = state_response.transport_response.status_code 716 | elif isinstance(state_response.transport_response, ClientResponse): 717 | status_code = state_response.transport_response.status 718 | else: 719 | logger.error(f"Failed to determine status code from state response: {state_response}") 720 | return state_response 721 | if status_code >= 400: 722 | logger.warning( 723 | f"Failed to set user {user_id} power in {room_id}, response {status_code}" 724 | ) 725 | return status_code 726 | state_response.content["users"][user_id] = power 727 | response = await with_ratelimit( 728 | client, 729 | "room_put_state", 730 | room_id=room_id, 731 | event_type="m.room.power_levels", 732 | content=state_response.content, 733 | ) 734 | logger.debug(f"Power levels update response: {response}") 735 | return response 736 | 737 | 738 | async def set_user_room_tag( 739 | config: Config, session: aiohttp.ClientSession, user: str, room_id: str, token: str, tag: str, order: float, 740 | ) -> bool: 741 | async with session.put( 742 | f"{config.homeserver_url}/_matrix/client/r0/user/{user}/rooms/{room_id}/tags/{tag}", 743 | json={ 744 | "order": order, 745 | }, 746 | headers={ 747 | "Authorization": f"Bearer {token}", 748 | }, 749 | ) as response: 750 | if response.status == 429: 751 | await asyncio.sleep(1) 752 | return await set_user_room_tag(config, session, user, room_id, token, tag, order) 753 | try: 754 | response.raise_for_status() 755 | logger.debug("Set room tag for user: %s, %s, %s", user, room_id, tag) 756 | return True 757 | except Exception as ex: 758 | logger.warning("Failed to set room tag %s for user %s for room %s: %s", tag, user, room_id, ex) 759 | return False 760 | 761 | 762 | async def set_user_room_tags( 763 | config: Config, session: aiohttp.ClientSession, user: str, room_id: str, token: str, tags: Dict, 764 | ) -> None: 765 | for tag, data in tags.items(): 766 | await set_user_room_tag(config, session, user, room_id, token, tag, data.get("order")) 767 | 768 | 769 | async def maintain_configured_rooms(client: AsyncClient, store: Storage, config: Config): 770 | """ 771 | Maintains the list of configured rooms. 772 | 773 | Creates if missing. Corrects if details are not correct. 774 | """ 775 | logger.info("Starting maintaining of rooms") 776 | 777 | results = store.cursor.execute(""" 778 | select id, name, alias, room_id, title, icon, encrypted, public, type from rooms 779 | """) 780 | 781 | rooms = results.fetchall() 782 | for room in rooms: 783 | try: 784 | await ensure_room_exists(room, client, store, config) 785 | except Exception as e: 786 | logger.error(f"Error with room '{room[2]}': {e}") 787 | -------------------------------------------------------------------------------- /bubo/storage.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from dataclasses import asdict 5 | from importlib import import_module 6 | from typing import Optional, List 7 | 8 | import sqlite3 9 | # noinspection PyPackageRequirements 10 | from nio import MegolmEvent 11 | 12 | latest_db_version = 10 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class Storage(object): 18 | def __init__(self, db_path): 19 | """Set up the database 20 | 21 | Runs an initial setup or migrations depending on whether a database file has already 22 | been created 23 | 24 | Args: 25 | db_path (str): The name of the database file 26 | """ 27 | self.db_path = db_path 28 | 29 | self._initial_setup() 30 | self._run_migrations() 31 | 32 | def _initial_setup(self): 33 | """Initial setup of the database""" 34 | logger.info("Performing initial database setup...") 35 | 36 | # Initialize a connection to the database 37 | self.conn = sqlite3.connect(self.db_path) 38 | self.conn.row_factory = sqlite3.Row 39 | self.cursor = self.conn.cursor() 40 | 41 | # Create database_version table if it doesn't exist 42 | try: 43 | self.cursor.execute(""" 44 | CREATE TABLE database_version (version INTEGER) 45 | """) 46 | self.cursor.execute(""" 47 | insert into database_version (version) values (0) 48 | """) 49 | self.conn.commit() 50 | except sqlite3.OperationalError: 51 | pass 52 | 53 | logger.info("Database setup complete") 54 | 55 | def _run_migrations(self): 56 | """Execute database migrations""" 57 | # Get current version of db 58 | results = self.cursor.execute("select version from database_version") 59 | version = results.fetchone()[0] 60 | if version >= latest_db_version: 61 | logger.info("No migrations to run") 62 | return 63 | 64 | while version < latest_db_version: 65 | version += 1 66 | version_string = str(version).rjust(3, "0") 67 | migration = import_module(f"bubo.migrations.{version_string}") 68 | # noinspection PyUnresolvedReferences 69 | migration.forward(self.cursor) 70 | logger.info(f"Executing database migration {version_string}") 71 | # noinspection SqlWithoutWhere 72 | self.cursor.execute("update database_version set version = ?", (version_string,)) 73 | self.conn.commit() 74 | logger.info(f"...done") 75 | 76 | def delete_recreate_room(self, room_id: str): 77 | self.cursor.execute(""" 78 | delete from recreate_rooms where room_id = ?; 79 | """, (room_id,)) 80 | self.conn.commit() 81 | 82 | def get_breakout_room_id(self, event_id: str): 83 | results = self.cursor.execute(""" 84 | select room_id from breakout_rooms where event_id = ?; 85 | """, (event_id,)) 86 | room = results.fetchone() 87 | if room: 88 | return room[0] 89 | 90 | def get_encrypted_events(self, session_id: str): 91 | results = self.cursor.execute(""" 92 | select * from encrypted_events where session_id = ?; 93 | """, (session_id,)) 94 | return results.fetchall() 95 | 96 | def get_recreate_room(self, room_id: str): 97 | results = self.cursor.execute(""" 98 | select requester, timestamp, applied from recreate_rooms where room_id = ?; 99 | """, (room_id,)) 100 | return results.fetchone() 101 | 102 | def get_room(self, room_id: str) -> Optional[sqlite3.Row]: 103 | results = self.cursor.execute(""" 104 | select * from rooms where room_id = ? 105 | """, (room_id,)) 106 | return results.fetchone() 107 | 108 | def get_room_by_alias(self, alias: str) -> Optional[sqlite3.Row]: 109 | if alias.startswith("#"): 110 | localpart = alias.split(":")[0].strip("#") 111 | else: 112 | localpart = alias 113 | results = self.cursor.execute(""" 114 | select id, name, alias, room_id, title, icon, encrypted, public, type from rooms where alias = ? 115 | """, (localpart,)) 116 | return results.fetchone() 117 | 118 | def get_room_id(self, alias: str) -> Optional[str]: 119 | results = self.cursor.execute(""" 120 | select room_id from rooms where alias = ? 121 | """, (alias.split(":")[0].strip("#"),)) 122 | room = results.fetchone() 123 | if room: 124 | return room[0] 125 | 126 | def get_rooms(self, spaces=False) -> List[sqlite3.Row]: 127 | query = "select * from rooms" 128 | if spaces: 129 | query = f"{query} where type = 'space'" 130 | results = self.cursor.execute(query) 131 | return results.fetchall() 132 | 133 | def remove_encrypted_event(self, event_id: str): 134 | self.cursor.execute(""" 135 | delete from encrypted_events where event_id = ?; 136 | """, (event_id,)) 137 | self.conn.commit() 138 | 139 | def set_recreate_room_applied(self, room_id: str): 140 | self.cursor.execute(""" 141 | update recreate_rooms set applied = 1 where room_id = ?; 142 | """, (room_id,)) 143 | self.conn.commit() 144 | 145 | def set_room_alias(self, room_id: str, alias: str) -> None: 146 | self.cursor.execute(""" 147 | update rooms set alias = ? where room_id = ?; 148 | """, (alias.split(":")[0].strip("#"), room_id)) 149 | self.conn.commit() 150 | 151 | def set_room_id(self, alias: str, room_id: str) -> None: 152 | self.cursor.execute(""" 153 | update rooms set room_id = ? where alias = ?; 154 | """, (room_id, alias.split(":")[0].strip("#"))) 155 | self.conn.commit() 156 | 157 | def store_breakout_room(self, event_id: str, room_id: str): 158 | self.cursor.execute(""" 159 | insert into breakout_rooms 160 | (event_id, room_id) values 161 | (?, ?); 162 | """, (event_id, room_id)) 163 | self.conn.commit() 164 | 165 | def store_encrypted_event(self, event: MegolmEvent): 166 | try: 167 | event_dict = asdict(event) 168 | event_json = json.dumps(event_dict) 169 | self.cursor.execute(""" 170 | insert into encrypted_events 171 | (device_id, event_id, room_id, session_id, event) values 172 | (?, ?, ?, ?, ?) 173 | """, (event.device_id, event.event_id, event.room_id, event.session_id, event_json)) 174 | self.conn.commit() 175 | except Exception as ex: 176 | logger.error("Failed to store encrypted event %s: %s" % (event.event_id, ex)) 177 | 178 | def store_recreate_room(self, requester: str, room_id: str): 179 | timestamp = int(time.time()) 180 | self.cursor.execute(""" 181 | insert into recreate_rooms 182 | (requester, room_id, timestamp) values 183 | (?, ?, ?); 184 | """, (requester, room_id, timestamp)) 185 | self.conn.commit() 186 | 187 | def store_room(self, name, alias, room_id, title, encrypted, public, room_type): 188 | self.cursor.execute(""" 189 | insert into rooms ( 190 | name, alias, room_id, title, encrypted, public, type 191 | ) values ( 192 | ?, ?, ?, ?, ?, ?, ? 193 | ) 194 | """, (name, alias, room_id, title, encrypted, public, room_type)) 195 | self.conn.commit() 196 | 197 | def unlink_room(self, room_id: str): 198 | self.cursor.execute(""" 199 | delete from rooms where room_id = ? 200 | """, (room_id,)) 201 | self.conn.commit() 202 | -------------------------------------------------------------------------------- /bubo/synapse_admin.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | from typing import List, Optional, Dict 5 | from urllib.parse import quote_plus 6 | 7 | import aiohttp 8 | 9 | from bubo.config import Config 10 | from bubo.utils import get_request_headers 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | API_PREFIX_V1 = "/_synapse/admin/v1" 15 | API_PREFIX_V2 = "/_synapse/admin/v2" 16 | 17 | 18 | async def get_room(config: Config, session: aiohttp.ClientSession, room_id: str) -> Optional[Dict]: 19 | headers = get_request_headers(config) 20 | async with session.get( 21 | f"{config.homeserver_url}{API_PREFIX_V1}/rooms/{room_id}", 22 | headers=headers, 23 | ) as response: 24 | if response.status == 429: 25 | await asyncio.sleep(1) 26 | return await get_room(config, session, room_id) 27 | try: 28 | response.raise_for_status() 29 | return await response.json() 30 | except Exception as ex: 31 | logger.warning("Failed to get room %s: %s", room_id, ex) 32 | return 33 | 34 | 35 | async def get_temporary_user_token( 36 | config: Config, session: aiohttp.ClientSession, headers: Dict, user: str, 37 | ) -> Optional[str]: 38 | async with session.post( 39 | f"{config.homeserver_url}{API_PREFIX_V1}/users/{user}/login", 40 | json={ 41 | "valid_until_ms": (int(time.time()) + 60*15) * 1000, # 15 minutes 42 | }, 43 | headers=headers, 44 | ) as response: 45 | if response.status == 429: 46 | await asyncio.sleep(1) 47 | return await get_temporary_user_token(config, session, headers, user) 48 | try: 49 | response.raise_for_status() 50 | data = await response.json() 51 | return data["access_token"] 52 | except Exception as ex: 53 | logger.warning("Failed to get temporary access token for user %s: %s", user, ex) 54 | return 55 | 56 | 57 | async def get_user_rooms(config: Config, user_id: str) -> List: 58 | headers = get_request_headers(config) 59 | async with aiohttp.ClientSession() as session: 60 | async with session.get( 61 | f"{config.homeserver_url}{API_PREFIX_V1}/users/{user_id}/joined_rooms", 62 | headers=headers, 63 | ) as response: 64 | if response.status == 429: 65 | await asyncio.sleep(1) 66 | return await get_user_rooms(config, user_id) 67 | try: 68 | response.raise_for_status() 69 | data = await response.json() 70 | except Exception as ex: 71 | logger.warning("Failed to get user rooms for user %s: %s", user_id, ex) 72 | return [] 73 | rooms = [] 74 | for room_id in data.get("joined_rooms", []): 75 | room = await get_room(config, session, room_id) 76 | if room: 77 | rooms.append(room) 78 | else: 79 | rooms.append({ 80 | "room_id": room_id, 81 | }) 82 | return rooms 83 | 84 | 85 | async def get_temporary_user_tokens(config: Config, users: List[str]) -> Dict: 86 | headers = get_request_headers(config) 87 | tokens = {} 88 | async with aiohttp.ClientSession() as session: 89 | for user in users: 90 | token = await get_temporary_user_token(config, session, headers, user) 91 | if token: 92 | logger.debug("Got temporary token for user %s", user) 93 | tokens[user] = token 94 | else: 95 | logger.debug("Failed to get temporary token for user %s", user) 96 | return tokens 97 | 98 | 99 | async def join_user( 100 | config: Config, headers: Dict, room_id_or_alias: str, session: aiohttp.ClientSession, user: str, 101 | ) -> bool: 102 | async with session.post( 103 | f"{config.homeserver_url}{API_PREFIX_V1}/join/{quote_plus(room_id_or_alias)}", 104 | json={ 105 | "user_id": user, 106 | }, 107 | headers=headers, 108 | ) as response: 109 | if response.status == 429: 110 | await asyncio.sleep(1) 111 | return await join_user(config, headers, room_id_or_alias, session, user) 112 | try: 113 | response.raise_for_status() 114 | return True 115 | except Exception as ex: 116 | logger.warning("Failed to join user %s: %s", user, ex) 117 | return False 118 | 119 | 120 | async def join_users(config: Config, users: List[str], room_id_or_alias: str) -> int: 121 | headers = get_request_headers(config) 122 | 123 | total_joined = 0 124 | async with aiohttp.ClientSession() as session: 125 | for user in users: 126 | result = await join_user(config, headers, room_id_or_alias, session, user) 127 | if result: 128 | total_joined += 1 129 | return total_joined 130 | 131 | 132 | async def make_room_admin(config: Config, room_id: str, user_id: str) -> bool: 133 | headers = get_request_headers(config) 134 | async with aiohttp.ClientSession() as session: 135 | async with session.post( 136 | f"{config.homeserver_url}{API_PREFIX_V1}/rooms/{room_id}/make_room_admin", 137 | json={ 138 | "user_id": user_id, 139 | }, 140 | headers=headers, 141 | ) as response: 142 | if response.status == 429: 143 | await asyncio.sleep(1) 144 | return await make_room_admin(config, room_id, user_id) 145 | try: 146 | response.raise_for_status() 147 | return True 148 | except Exception as ex: 149 | logger.warning("Failed to make room admin in %s for %s: %s", room_id, user_id, ex) 150 | return False 151 | -------------------------------------------------------------------------------- /bubo/users.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List, Dict, Optional 3 | 4 | import requests 5 | # noinspection PyPackageRequirements 6 | from keycloak import KeycloakAdmin 7 | 8 | from bubo.config import Config 9 | from bubo.emails import send_plain_email 10 | from bubo.email_strings import INVITE_LINK_EMAIL 11 | from bubo.errors import ConfigError 12 | 13 | 14 | def get_admin_client(config: Config) -> KeycloakAdmin: 15 | params = { 16 | "server_url": config.keycloak["url"], 17 | "realm_name": config.keycloak["realm_name"], 18 | "client_secret_key": config.keycloak["client_secret_key"], 19 | "verify": True, 20 | } 21 | return KeycloakAdmin(**params) 22 | 23 | 24 | def create_signup_link(config: Config, creator: str, max_signups: int, days_valid: int) -> str: 25 | if not config.keycloak_signup.get('enabled'): 26 | raise ConfigError("Keycloak-Signup not configured") 27 | response = requests.post( 28 | f"{config.keycloak_signup.get('url')}/api/pages", 29 | json={ 30 | "creator": creator, 31 | "maxSignups": max_signups, 32 | "validDays": days_valid, 33 | "token": config.keycloak_signup.get("token"), 34 | }, 35 | headers={ 36 | "Content-Type": "application/json", 37 | }, 38 | ) 39 | response.raise_for_status() 40 | signup_token = response.json().get("signup_token") 41 | return f"{config.keycloak_signup.get('url')}/{signup_token}" 42 | 43 | 44 | def create_user(config: Config, username: str, email: str) -> Optional[str]: 45 | if not config.keycloak.get('enabled'): 46 | return 47 | keycloak_admin = get_admin_client(config) 48 | return keycloak_admin.create_user({ 49 | "email": email, 50 | "emailVerified": True, 51 | "enabled": True, 52 | "username": username, 53 | }) 54 | 55 | 56 | def invite_user(config: Config, email: str, creator: str): 57 | if not config.keycloak_signup.get('enabled'): 58 | return 59 | # Create page 60 | response = requests.post( 61 | f"{config.keycloak_signup.get('url')}/api/pages", 62 | json={ 63 | "creator": creator, 64 | "maxSignups": 1, 65 | "validDays": config.keycloak_signup.get("page_valid_days"), 66 | "token": config.keycloak_signup.get("token"), 67 | }, 68 | headers={ 69 | "Content-Type": "application/json", 70 | }, 71 | ) 72 | response.raise_for_status() 73 | # Send invite link 74 | signup_token = response.json().get("signup_token") 75 | message = INVITE_LINK_EMAIL \ 76 | .replace("%%organisation%%", config.keycloak_signup.get("organisation")) \ 77 | .replace("%%link%%", f"{config.keycloak_signup.get('url')}/{signup_token}") \ 78 | .replace("%%days%%", str(config.keycloak_signup.get("page_days_valid"))) 79 | send_plain_email(config, email, message) 80 | 81 | 82 | def send_password_reset(config: Config, user_id: str) -> Dict: 83 | if not config.keycloak.get('enabled'): 84 | return {} 85 | keycloak_admin = get_admin_client(config) 86 | keycloak_admin.send_update_account( 87 | user_id=user_id, 88 | payload=json.dumps(['UPDATE_PASSWORD']), 89 | ) 90 | 91 | 92 | def get_user_by_attr(config: Config, attr: str, value: str) -> Optional[Dict]: 93 | if not config.keycloak.get('enabled'): 94 | return 95 | keycloak_admin = get_admin_client(config) 96 | users = keycloak_admin.get_users({ 97 | attr: value, 98 | }) 99 | if len(users) == 1: 100 | return users[0] 101 | elif len(users) > 1: 102 | raise Exception(f"More than one user found with the same {attr} = {value}") 103 | 104 | 105 | def list_users(config: Config) -> List[Dict]: 106 | if not config.keycloak.get('enabled'): 107 | return [] 108 | keycloak_admin = get_admin_client(config) 109 | users = keycloak_admin.get_users({}) 110 | return users 111 | -------------------------------------------------------------------------------- /bubo/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import Set 4 | 5 | # noinspection PyPackageRequirements 6 | from nio import AsyncClient, JoinedMembersResponse, RoomResolveAliasError, ProtocolError 7 | 8 | from bubo.config import Config 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | async def ensure_room_id(client: AsyncClient, room_id_or_alias: str) -> str: 14 | if room_id_or_alias.startswith("#"): 15 | response = await client.room_resolve_alias(room_id_or_alias) 16 | if isinstance(response, RoomResolveAliasError): 17 | raise ProtocolError(f"Could not resolve {room_id_or_alias} room ID") 18 | return response.room_id 19 | return room_id_or_alias 20 | 21 | 22 | def get_request_headers(config): 23 | return { 24 | "Authorization": f"Bearer {config.user_token}", 25 | } 26 | 27 | 28 | async def get_users_for_access(client: AsyncClient, config: Config, access_type: str) -> Set: 29 | if access_type == "admins": 30 | existing_list = list(set(config.admins[:])) 31 | elif access_type == "coordinators": 32 | existing_list = list(set(config.admins[:] + config.coordinators[:])) 33 | elif access_type == "pindora_users": 34 | existing_list = list(set(config.pindora_users[:])) 35 | else: 36 | logger.error(f"Invalid access type: {access_type}") 37 | return set() 38 | users = existing_list[:] 39 | for room_id in existing_list: 40 | if not room_id.startswith("!"): 41 | continue 42 | response = await with_ratelimit(client=client, method="joined_members", room_id=room_id) 43 | if isinstance(response, JoinedMembersResponse): 44 | logger.debug(f"Found {len(response.members)} users for {access_type} access type in room {room_id}") 45 | users.extend([member.user_id for member in response.members]) 46 | else: 47 | logger.warning(f"Failed to get list of users for access from room {room_id}: {response.message}") 48 | return set(users) 49 | 50 | 51 | # TODO remove usage of this wrapper for any matrix-nio calls 52 | # Reading that code it seems it already handles rate limits 😅 53 | async def with_ratelimit(client: AsyncClient, method: str, *args, **kwargs): 54 | func = getattr(client, method) 55 | response = await func(*args, **kwargs) 56 | if getattr(response, "status_code", None) == "M_LIMIT_EXCEEDED": 57 | time.sleep(3) 58 | return with_ratelimit(client, method, *args, **kwargs) 59 | return response 60 | -------------------------------------------------------------------------------- /docker/build_and_install_libolm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # 3 | # Call with the following arguments: 4 | # 5 | # ./build_and_install_libolm.sh 6 | # 7 | # Example: 8 | # 9 | # ./build_and_install_libolm.sh 3.1.4 /python-bindings 10 | # 11 | # Note that if a python bindings installation directory is not supplied, bindings will 12 | # be installed to the default directory. 13 | # 14 | 15 | set -ex 16 | 17 | # Download the specified version of libolm 18 | git clone -b "$1" https://gitlab.matrix.org/matrix-org/olm.git olm && cd olm 19 | 20 | # Build libolm 21 | cmake . -Bbuild 22 | cmake --build build 23 | 24 | # Install 25 | make install 26 | 27 | # Build the python3 bindings 28 | cd python && make olm-python3 29 | 30 | # Install python3 bindings 31 | mkdir -p "$2" || true 32 | DESTDIR="$2" make install-python3 33 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import logging 3 | from time import sleep 4 | 5 | import aiolog 6 | import asyncio 7 | # noinspection PyPackageRequirements 8 | from aiohttp import ( 9 | ServerDisconnectedError, 10 | ClientConnectionError 11 | ) 12 | # noinspection PyPackageRequirements 13 | from nio import ( 14 | AsyncClient, 15 | AsyncClientConfig, 16 | ForwardedRoomKeyEvent, 17 | InviteMemberEvent, 18 | LocalProtocolError, 19 | LoginError, 20 | MegolmEvent, 21 | RoomKeyEvent, 22 | RoomMessageText, 23 | UnknownEvent, 24 | ) 25 | 26 | from bubo.callbacks import Callbacks 27 | from bubo.config import Config, load_config 28 | from bubo.rooms import maintain_configured_rooms 29 | from bubo.storage import Storage 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | 34 | async def main(config: Config): 35 | # Configure the database 36 | store = Storage(config.database_filepath) 37 | 38 | # Configuration options for the AsyncClient 39 | client_config = AsyncClientConfig( 40 | max_limit_exceeded=0, 41 | max_timeouts=0, 42 | store_sync_tokens=True, 43 | encryption_enabled=True, 44 | ) 45 | 46 | # Initialize the matrix client 47 | client = AsyncClient( 48 | config.homeserver_url, 49 | config.user_id, 50 | device_id=config.device_id, 51 | store_path=config.store_filepath, 52 | config=client_config, 53 | ) 54 | if config.user_token: 55 | client.access_token = config.user_token 56 | client.user_id = config.user_id 57 | 58 | # Set up event callbacks 59 | callbacks = Callbacks(client, store, config) 60 | # noinspection PyTypeChecker 61 | client.add_event_callback(callbacks.message, (RoomMessageText,)) 62 | # noinspection PyTypeChecker 63 | client.add_event_callback(callbacks.invite, (InviteMemberEvent,)) 64 | # noinspection PyTypeChecker 65 | client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,)) 66 | # Nio doesn't currently have m.reaction events so we catch UnknownEvent for reactions and filter there 67 | # noinspection PyTypeChecker 68 | client.add_event_callback(callbacks.reaction, (UnknownEvent,)) 69 | # noinspection PyTypeChecker 70 | client.add_to_device_callback(callbacks.room_key, (ForwardedRoomKeyEvent, RoomKeyEvent)) 71 | 72 | # Keep trying to reconnect on failure (with some time in-between) 73 | while True: 74 | try: 75 | if config.user_token: 76 | client.load_store() 77 | else: 78 | # Try to login with the configured username/password 79 | try: 80 | login_response = await client.login( 81 | password=config.user_password, 82 | device_name=config.device_name, 83 | ) 84 | 85 | # Check if login failed 86 | if type(login_response) == LoginError: 87 | logger.error(f"Failed to login: %s", login_response.message) 88 | return False 89 | except LocalProtocolError as e: 90 | # There's an edge case here where the user hasn't installed the correct C 91 | # dependencies. In that case, a LocalProtocolError is raised on login. 92 | logger.fatal( 93 | "Failed to login. Have you installed the correct dependencies? " 94 | "https://github.com/poljar/matrix-nio#installation " 95 | "Error: %s", e 96 | ) 97 | return False 98 | 99 | # Login succeeded! 100 | 101 | # Sync encryption keys with the server 102 | # Required for participating in encrypted rooms 103 | if client.should_upload_keys: 104 | await client.keys_upload() 105 | 106 | # Maintain rooms 107 | await maintain_configured_rooms(client, store, config) 108 | 109 | logger.info(f"Logged in as {config.user_id}") 110 | await client.sync_forever(timeout=30000, full_state=True) 111 | 112 | except (ClientConnectionError, ServerDisconnectedError): 113 | logger.warning("Unable to connect to homeserver, retrying in 15s...") 114 | 115 | # Sleep so we don't bombard the server with login requests 116 | sleep(15) 117 | finally: 118 | # Make sure to close the client connection on disconnect 119 | await client.close() 120 | 121 | 122 | config_file = load_config() 123 | 124 | aiolog.start() 125 | 126 | asyncio.get_event_loop().run_until_complete(main(config_file)).run_util_complete(aiolog.stop()) 127 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | # Matrix Handler that needs upstreaming 2 | git+https://github.com/jaywink/aiolog.git@0db27c59a1694857f58c2bae6874fb25c6c6f262#egg=aiolog 3 | 4 | # Versions pinned to the major version when pip-tools was added, 5 | # no other reason for any of these not to be upgraded further once 6 | # changelogs are checked. 7 | aiohttp<4 8 | email-validator<2 9 | matrix-nio[e2e]<0.21.0 10 | Markdown<4 11 | python-keycloak<3 12 | python-slugify<7 13 | PyYAML<7 14 | requests<3 15 | pytz 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.8 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | aiofiles==0.6.0 8 | # via matrix-nio 9 | aiohttp==3.9.2 10 | # via 11 | # -r requirements.in 12 | # aiohttp-socks 13 | # matrix-nio 14 | aiohttp-socks==0.7.1 15 | # via matrix-nio 16 | aiolog @ git+https://github.com/jaywink/aiolog.git@0db27c59a1694857f58c2bae6874fb25c6c6f262 17 | # via -r requirements.in 18 | aiosignal==1.3.1 19 | # via aiohttp 20 | async-timeout==4.0.2 21 | # via 22 | # aiohttp 23 | # aiolog 24 | # python-socks 25 | atomicwrites==1.4.1 26 | # via matrix-nio 27 | attrs==22.2.0 28 | # via 29 | # aiohttp 30 | # aiohttp-socks 31 | # jsonschema 32 | cachetools==4.2.4 33 | # via matrix-nio 34 | certifi==2023.7.22 35 | # via requests 36 | cffi==1.15.1 37 | # via python-olm 38 | charset-normalizer==2.1.1 39 | # via requests 40 | dnspython==2.2.1 41 | # via email-validator 42 | ecdsa==0.18.0 43 | # via python-jose 44 | email-validator==1.3.0 45 | # via -r requirements.in 46 | frozenlist==1.3.3 47 | # via 48 | # aiohttp 49 | # aiosignal 50 | future==0.18.3 51 | # via 52 | # matrix-nio 53 | # python-olm 54 | h11==0.12.0 55 | # via matrix-nio 56 | h2==4.1.0 57 | # via matrix-nio 58 | hpack==4.0.0 59 | # via h2 60 | hyperframe==6.0.1 61 | # via h2 62 | idna==3.4 63 | # via 64 | # email-validator 65 | # requests 66 | # yarl 67 | importlib-metadata==6.8.0 68 | # via markdown 69 | importlib-resources==6.1.1 70 | # via jsonschema 71 | jsonschema==4.17.3 72 | # via matrix-nio 73 | logbook==1.5.3 74 | # via matrix-nio 75 | markdown==3.4.1 76 | # via -r requirements.in 77 | matrix-nio[e2e]==0.20.1 78 | # via 79 | # -r requirements.in 80 | # matrix-nio 81 | multidict==6.0.4 82 | # via 83 | # aiohttp 84 | # yarl 85 | peewee==3.15.4 86 | # via matrix-nio 87 | pkgutil-resolve-name==1.3.10 88 | # via jsonschema 89 | pyasn1==0.4.8 90 | # via 91 | # python-jose 92 | # rsa 93 | pycparser==2.21 94 | # via cffi 95 | pycryptodome==3.19.1 96 | # via matrix-nio 97 | pyrsistent==0.19.3 98 | # via jsonschema 99 | python-jose==3.3.0 100 | # via python-keycloak 101 | python-keycloak==2.8.0 102 | # via -r requirements.in 103 | python-olm==3.1.3 104 | # via matrix-nio 105 | python-slugify==6.1.2 106 | # via -r requirements.in 107 | python-socks[asyncio]==2.1.1 108 | # via 109 | # aiohttp-socks 110 | # python-socks 111 | pytz==2022.7 112 | # via -r requirements.in 113 | pyyaml==6.0 114 | # via -r requirements.in 115 | requests==2.31.0 116 | # via 117 | # -r requirements.in 118 | # python-keycloak 119 | # requests-toolbelt 120 | requests-toolbelt==0.9.1 121 | # via python-keycloak 122 | rsa==4.9 123 | # via python-jose 124 | six==1.16.0 125 | # via ecdsa 126 | text-unidecode==1.3 127 | # via python-slugify 128 | unpaddedbase64==2.1.0 129 | # via matrix-nio 130 | urllib3==1.26.18 131 | # via 132 | # python-keycloak 133 | # requests 134 | yarl==1.8.2 135 | # via aiohttp 136 | zipp==3.17.0 137 | # via 138 | # importlib-metadata 139 | # importlib-resources 140 | -------------------------------------------------------------------------------- /sample.config.yaml: -------------------------------------------------------------------------------- 1 | # Welcome to the sample Bubo config file 2 | # Below you will find various config sections and options 3 | # Default values are shown 4 | 5 | # The string to prefix messages with to talk to the bot in group chats 6 | command_prefix: "!bubo" 7 | 8 | # Options for connecting to the bot's Matrix account 9 | matrix: 10 | # The Matrix User ID of the bot account 11 | user_id: "@bubo:example.com" 12 | # Token for login will be used if given 13 | user_token: "" 14 | # Matrix account password, used if no token given 15 | user_password: "" 16 | # The URL of the homeserver to connect to 17 | homeserver_url: https://example.com 18 | # The server name of the Matrix server 19 | server_name: example.com 20 | # The device ID that is **non pre-existing** device 21 | # If this device ID already exists, messages will be dropped silently in encrypted rooms 22 | device_id: ABCDEFGHIJ 23 | # What to name the logged in device 24 | device_name: bubo 25 | # Has Synapse admin? 26 | # Set to true if Bubo has Synapse admin API access 27 | is_synapse_admin: false 28 | 29 | # Different commands might require a permission. 30 | permissions: 31 | # Users or list of users based on room membership, who are allowed to do anything with the bot. 32 | # Note currently this is actually just the same as coordinator, but more powerful 33 | # actions might be added that are admin only. 34 | # Admins get power 50 in rooms they are joined to 35 | # if "promote_users" is set to "true". 36 | # If this is set to a room, the bot must be a member of that room. 37 | admins: 38 | - "@admin:example.com" 39 | - "!qwerty12345:example.com" 40 | # Users or list of users based on room membership, who are allowed to 41 | # maintain a limited set of functions. 42 | # Coordinators get power 50 in rooms they are joined to 43 | # if "promote_users" is set to "true". 44 | # If this is set to a room, the bot must be a member of that room. 45 | coordinators: 46 | - "@coordinator:example.com" 47 | - "!asdfgh12345:example.com" 48 | ### Some configuration 49 | # Demote users who have too much power in a room 50 | demote_users: false 51 | # Promote users who should have more power in a room 52 | promote_users: true 53 | 54 | # Configuration related to user management. 55 | # Currently Keycloak is the only provider. 56 | users: 57 | keycloak: 58 | # Set to true to enable keycloak integration 59 | enabled: false 60 | # Please note you need to create a client in your keycloak realm, with the following: 61 | # - client protocol: openid-connect 62 | # - access type: confidential 63 | # - service accounts enabled: true 64 | # - service account roles -> client roles -> realm management: manage-users 65 | # - service account roles -> client roles -> realm management: query-groups 66 | # Then copy the credentials client secret here in 'client_secret_key' 67 | url: "http://keycloak.domain.tld/auth/" 68 | # Define your realm 69 | realm_name: "master" 70 | client_secret_key: "client-secret" 71 | # If running an instance of https://github.com/elokapina/keycloak-signup 72 | # you can enable the "invite" command for self-registration. 73 | keycloak_signup: 74 | # Set to true to enable self-registration integration 75 | # NOTE! Requires email sending to be enabled 76 | enabled: false 77 | url: "https://keycloak-signup.domain.tld" 78 | # Secret token to communicate with the keycloak-signup instance 79 | token: "abcdefg" 80 | # How many days a self-registration page should be valid 81 | page_days_valid: 7 82 | # What organisation name to use in the emails to user 83 | organisation: Elokapina 84 | 85 | # Configuration for rooms maintained by Bubo. 86 | rooms: 87 | # Default power levels. Bubo will ensure these are set for the rooms 88 | # it maintains on creation and startup. 89 | power_levels: 90 | ban: 50 91 | events: 92 | "m.room.avatar": 50 93 | "m.room.canonical_alias": 50 94 | "m.room.encryption": 100 95 | "m.room.history_visibility": 100 96 | "m.room.name": 50 97 | "m.room.power_levels": 100 98 | "m.room.server_acl": 100 99 | "m.room.tombstone": 100 100 | events_default: 0 101 | invite: 0 102 | kick: 50 103 | redact: 50 104 | state_default: 50 105 | users_default: 0 106 | # Should bubo enforce the default power levels in old rooms as well? 107 | enforce_power_in_old_rooms: true 108 | # Secondary admin to invite to maintained rooms (optional) 109 | #secondary_admin: "@user:domain.tld" 110 | # Always recreate rooms in federated mode 111 | # Useful to set when some rooms have been created as non-federated and the 112 | # room recreate is used to create new versions in federated mode. 113 | recreate_as_federated: false 114 | # Prefix to use when renaming old recreated rooms 115 | recreate_old_room_name_prefix: "OLD" 116 | 117 | # Room groups 118 | # Here you can create groups of rooms, which can be used by with the "groupjoin" command 119 | # to join one or more users to a group of rooms with one command. 120 | # 121 | # Each level of groups (including the root level) can have a special key "__all__", which 122 | # is a list of rooms users will be joined to for groups on this level or lower. 123 | # 124 | # A group can also just be a list of rooms for the lowest level. 125 | # 126 | # Example value: 127 | # groups: 128 | # __all__: 129 | # - #common:domain.tld" 130 | # group1: 131 | # __all__: 132 | # - "#coolspace:domain.tld" 133 | # subgroup1: 134 | # - "#room:domain.tld" 135 | # - "!123456789:domain.tld" 136 | 137 | # Configuration related to bot callbacks 138 | callbacks: 139 | # Send a 'unable to decrypt this message' reply to the room if Bubo can't decrypt a message. 140 | unable_to_decrypt_responses: true 141 | 142 | # Email sending configuration 143 | email: 144 | # Enable first if email sending is required 145 | enabled: false 146 | host: smtp.example.com 147 | port: 465 148 | # Set either starttls or ssl, not both 149 | starttls: false 150 | ssl: false 151 | sender: noreply@example.com 152 | auth: 153 | username: janet 154 | password: foobar 155 | 156 | # Pindora smart lock API configuration 157 | pindora: 158 | enabled: false 159 | token: secrettoken 160 | id: pindoraid 161 | timezone: Europe/Helsinki 162 | # These users are allowed to generate Pindora door lock codes 163 | pindora_users: 164 | - "@pindora_user_1:example.com" 165 | - "!pindora_room:example.com" 166 | 167 | # Storage related configuration 168 | storage: 169 | # The path to the database 170 | database_filepath: "/data/bubo.db" 171 | # The path to a directory for internal bot storage 172 | # containing encryption keys, sync tokens, etc. 173 | store_filepath: "/data" 174 | 175 | # Logging setup 176 | logging: 177 | # Logging level 178 | # Allowed levels are 'INFO', 'WARNING', 'ERROR', 'DEBUG' where DEBUG is most verbose 179 | level: INFO 180 | # Configure logging to a file 181 | file_logging: 182 | # Whether logging to a file is enabled 183 | enabled: false 184 | # The path to the file to log to. May be relative or absolute 185 | filepath: bubo.log 186 | # Configure logging to the console output 187 | console_logging: 188 | # Whether logging to the console is enabled 189 | enabled: true 190 | matrix_logging: 191 | # Whether logging to Matrix is enabled. 192 | # Note! User access token must be specified when using Matrix logging. 193 | enabled: false 194 | # Room ID 195 | room: !logs:example.com 196 | 197 | # Discourse integration 198 | discourse: 199 | # URL to discourse instance 200 | #url: https://discourse.domain.tld 201 | # API credentials 202 | #api_username: username 203 | #api_key: secretkey 204 | 205 | spaces: 206 | # Spaces to add Discourse group spaces into based on their prefix 207 | # '-' as the delimiter to find the prefix. 208 | # For example 'abc-foobar' would be added to the space with the 'abc' key. 209 | prefixes: 210 | #abc: "#space-abc:domain.tld" 211 | 212 | # Rooms to create 213 | # For templates, the following variables are available: 214 | # - Group display name: %groupdisplayname% 215 | # - Group name: %groupname% 216 | # - Group title: %grouptitle% 217 | # - Group shortname (name with any prefix stripped): %groupshortname% 218 | rooms: 219 | - templates: 220 | alias: "%groupshortname%" 221 | name: "%groupdisplayname% - Living room" 222 | title: "%grouptitle%" 223 | joinable_via_parent: true 224 | encrypted: true 225 | public: false 226 | suggested: true 227 | - templates: 228 | alias: "%groupshortname%-announcements" 229 | name: "%groupdisplayname% - Announcements" 230 | title: "%grouptitle%" 231 | joinable_via_parent: true 232 | encrypted: true 233 | public: false 234 | suggested: true 235 | 236 | # If set, only these groups will be synced to spaces 237 | # Use the Discourse group name for list items. 238 | whitelist: [] 239 | 240 | # Set to true to dry-run spaces sync - no spaces or rooms will be actually created 241 | dry_run: False 242 | --------------------------------------------------------------------------------