├── LICENSE ├── README.md ├── base-config.yaml ├── invite.py └── maubot.yaml /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jona Abdinghoff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | this is a simple [maubot](https://github.com/maubot/maubot) plugin which interacts with a 2 | [matrix-registration](https://github.com/zeratax/matrix-registration) deployment and generates invite tokens. 3 | 4 | NOTICE: this bot depends on Maubot engine and matrix-registration. it does not work with synapse's native registration 5 | tokens. reasons you may still prefer this project: 6 | 7 | 1. you already have set up maubot 8 | 2. you already have set up matrix-registration 9 | 3. this bot only requires administrative access to matrix-registration, whereas the ability to generate synapse 10 | registration tokens would require the bot to be a server administrator 11 | 12 | if you would prefer to use native synapse registration tokens, please check out 13 | [matrix-registration-bot](https://github.com/moan0s/matrix-registration-bot/), an unrelated but similar project which is 14 | a standalone bot (no maubot engine). 15 | 16 | ## usage 17 | 18 | modify the config to point to your matrix-registration url, include your admin secret to authenticate, and ensure that 19 | you're in the list of approved users. 20 | 21 | *please note* that matrix-registration versions BELOW 0.9.0 have different API endpoints, expected date structures, json 22 | arguments, etc. and require the `legacy_mr` value in your config to be set to `True`! If your matrix-registration 23 | instance is 0.9.0 or greater, leave this as `false`. 24 | 25 | once your bot is running, simply use the command 26 | 27 | !invite generate 28 | 29 | to generate a token with some generic text you can copy-paste when sharing it with your invitee! 30 | 31 | to check the status of a specific token, use 32 | 33 | !invite status MyTokenName 34 | 35 | and similarly to list all tokens in the database, use 36 | 37 | !invite list 38 | 39 | to revoke a token, use 40 | 41 | !invite revoke MyTokenName 42 | 43 | -------------------------------------------------------------------------------- /base-config.yaml: -------------------------------------------------------------------------------- 1 | # admin secret for the matrix-registration instance 2 | admin_secret: mYsUp3rs3CretKEY12345 3 | # versions of matrix-registration earlier than 0.9.0 used 4 | # a different api endpoint. if you are running a version 5 | # that is greater than or equal to 0.9.0, set this to false 6 | legacy_mr: false 7 | 8 | # how to get to the matrix-registration instance 9 | # note the lack of trailing slash! 10 | # e.g. 11 | # reg_url: http://localhost:5000 12 | reg_url: 'https://www.yourwebsite.com' 13 | 14 | # the URI to use for registering tokens 15 | # leave this as the default value unless you are using 16 | # a custom registration page! 17 | # you can also set this to a full URL if your registration 18 | # page is accessible by a completely different host from 19 | # your registration endpoint, just be sure to update your 20 | # formatted message below to remove the reg_url value. 21 | # e.g. 22 | # reg_page: https://someotherwebsite.com/registration-page.html 23 | reg_page: '/register' 24 | 25 | # the html-formatted text you would like the bot to respond with 26 | # when a new token is generated. you may use the following variables: 27 | # {token} for the registration token 28 | # {reg_url} for the registration url set in this config 29 | # {reg_page} for the registration uri set in this config 30 | # {expiration} for the expiration value set in this config 31 | # 32 | # if unset, a default message will be used. 33 | # 34 | # example message: 35 | # 36 | message: | 37 | Invitation token {token} created!
38 |
39 | Your unique url for registering is:
40 | {reg_url}{reg_page}?token={token}
41 | This invite token will expire in {expiration} days.
42 | If it expires before use, you must request a new token. 43 | 44 | # the duration the invitation should be valid, in days, before expiring 45 | expiration: 3 46 | 47 | # approved members who can generate invite tokens 48 | admins: 49 | - '@admin:yourwebsite.com' 50 | -------------------------------------------------------------------------------- /invite.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper 4 | from maubot import Plugin, MessageEvent 5 | from maubot.handlers import command 6 | 7 | import json 8 | import datetime 9 | 10 | class Config(BaseProxyConfig): 11 | def do_update(self, helper: ConfigUpdateHelper) -> None: 12 | helper.copy("admin_secret") 13 | helper.copy("legacy_mr") 14 | helper.copy("reg_url") 15 | helper.copy("reg_page") 16 | helper.copy("admins") 17 | helper.copy("expiration") 18 | helper.copy("message") 19 | 20 | class Invite(Plugin): 21 | async def start(self) -> None: 22 | await super().start() 23 | self.config.load_and_update() 24 | 25 | @classmethod 26 | def get_config_class(cls) -> Type[BaseProxyConfig]: 27 | return Config 28 | 29 | async def can_manage(self, evt: MessageEvent) -> bool: 30 | if evt.sender in self.config["admins"]: 31 | return True 32 | else: 33 | await evt.respond("You don't have permission to manage invitations for this server.") 34 | return False 35 | 36 | def set_api_endpoints(self) -> None: 37 | self.config["api_url"] = self.config["reg_url"] + "/api" 38 | 39 | if self.config["legacy_mr"] == True: 40 | self.config["api_url"] = self.config["reg_url"] 41 | 42 | @command.new(name="invite", help="Generate a unique invitation code to this matrix homeserver", \ 43 | require_subcommand=True) 44 | async def invite(self, evt: MessageEvent) -> None: 45 | pass 46 | 47 | @invite.subcommand("generate", help="Generate a new invitation token.") 48 | async def generate(self, evt: MessageEvent) -> None: 49 | await evt.mark_read() 50 | 51 | if not await self.can_manage(evt): 52 | return 53 | 54 | self.set_api_endpoints() 55 | 56 | ex_date = datetime.datetime.strftime( \ 57 | (datetime.date.today() + datetime.timedelta(days=self.config["expiration"])), \ 58 | "%Y-%m-%d") 59 | # use re-ordered date if using legacy code 60 | if self.config["legacy_mr"] == True: 61 | ex_date = datetime.datetime.strftime( \ 62 | (datetime.date.today() + datetime.timedelta(days=self.config["expiration"])), \ 63 | "%m.%d.%Y") 64 | headers = { 65 | 'Authorization': f"SharedSecret {self.config['admin_secret']}", 66 | 'Content-Type': 'application/json' 67 | } 68 | 69 | try: 70 | response = await self.http.post(f"{self.config['api_url']}/token", headers=headers, \ 71 | json={"max_usage": 1, "one_time": True, "ex_date": ex_date, "expiration_date": ex_date}) 72 | status = response.status 73 | resp_json = await response.json() 74 | except Exception as e: 75 | body = await response.text() 76 | await evt.respond(f"Uh oh! I got a {status} response from your registration endpoint:
\ 77 | {body}
\ 78 | which prompted me to produce this error:
\ 79 | {e.message}", allow_html=True) 80 | return None 81 | try: 82 | token = resp_json['name'] 83 | except Exception as e: 84 | await evt.respond(f"I got a bad response back, sorry, something is borked. \n\ 85 | {resp_json}") 86 | self.log.exception(e) 87 | return None 88 | 89 | msg = '
'.join( 90 | [ 91 | f"Invitation token {token} created!", 92 | f"", 93 | f"Your unique url for registering is:", 94 | f"{self.config['reg_url']}{self.config['reg_page']}?token={token}", 95 | f"This invite token will expire in {self.config['expiration']} days.", 96 | f"If it expires before use, you must request a new token." 97 | ]) 98 | 99 | if self.config['message']: 100 | msg = self.config["message"].format(token=token, reg_url=self.config['reg_url'], 101 | reg_page=self.config['reg_page'], expiration=self.config['expiration']) 102 | 103 | await evt.respond(msg, allow_html=True) 104 | 105 | @invite.subcommand("status", help="Return the status of an invite token.") 106 | @command.argument("token", "Token", pass_raw=True, required=True) 107 | async def status(self, evt: MessageEvent, token: str) -> None: 108 | await evt.mark_read() 109 | 110 | if not await self.can_manage(evt): 111 | return 112 | 113 | self.set_api_endpoints() 114 | 115 | if not token: 116 | await evt.respond("you must supply a token to check") 117 | 118 | headers = { 119 | 'Authorization': f"SharedSecret {self.config['admin_secret']}", 120 | 'Content-Type': 'application/json' 121 | } 122 | 123 | try: 124 | response = await self.http.get(f"{self.config['api_url']}/token/{token}", headers=headers) 125 | resp_json = await response.json() 126 | except Exception as e: 127 | await evt.respond(f"request failed: {e.message}") 128 | return None 129 | 130 | # this isn't formatted nicely but i don't really care that much 131 | await evt.respond(f"Status of token {token}: \n
{json.dumps(resp_json, indent=4)}
", allow_html=True) 132 | 133 | @invite.subcommand("revoke", help="Disable an existing invite token.") 134 | @command.argument("token", "Token", pass_raw=True, required=True) 135 | async def revoke(self, evt: MessageEvent, token: str) -> None: 136 | await evt.mark_read() 137 | 138 | if not await self.can_manage(evt): 139 | return 140 | 141 | self.set_api_endpoints() 142 | 143 | if not token: 144 | await evt.respond("you must supply a token to revoke") 145 | 146 | headers = { 147 | 'Authorization': f"SharedSecret {self.config['admin_secret']}", 148 | 'Content-Type': 'application/json' 149 | } 150 | 151 | # this is a really gross way of handling legacy installs and should be cleaned up 152 | # basically this command used to use PUT but now uses PATCH 153 | if self.config["legacy_mr"] == True: 154 | try: 155 | response = await self.http.put(f"{self.config['api_url']}/token/{token}", headers=headers, \ 156 | json={"disable": True}) 157 | resp_json = await response.json() 158 | except Exception as e: 159 | await evt.respond(f"request failed: {e.message}") 160 | return None 161 | else: 162 | try: 163 | response = await self.http.patch(f"{self.config['api_url']}/token/{token}", headers=headers, \ 164 | json={"disabled": True}) 165 | resp_json = await response.json() 166 | except Exception as e: 167 | await evt.respond(f"request failed: {e.message}") 168 | return None 169 | 170 | # this isn't formatted nicely but i don't really care that much 171 | await evt.respond(f"
{json.dumps(resp_json, indent=4)}
", allow_html=True) 172 | 173 | @invite.subcommand("list", help="List all tokens that have been generated.") 174 | async def list(self, evt: MessageEvent) -> None: 175 | await evt.mark_read() 176 | 177 | if not await self.can_manage(evt): 178 | return 179 | 180 | self.set_api_endpoints() 181 | 182 | headers = { 183 | 'Authorization': f"SharedSecret {self.config['admin_secret']}" 184 | } 185 | 186 | try: 187 | response = await self.http.get(f"{self.config['api_url']}/token", headers=headers) 188 | resp_json = await response.json() 189 | except Exception as e: 190 | await evt.respond(f"request failed: {e.message}") 191 | return None 192 | 193 | # this isn't formatted nicely but i don't really care that much 194 | await evt.respond(f"
{json.dumps(resp_json, indent=4)}
", allow_html=True) 195 | -------------------------------------------------------------------------------- /maubot.yaml: -------------------------------------------------------------------------------- 1 | maubot: 0.1.0 2 | id: org.jobmachine.invitebot 3 | version: 0.3.1 4 | license: MIT 5 | modules: 6 | - invite 7 | main_class: Invite 8 | database: false 9 | 10 | extra_files: 11 | - base-config.yaml 12 | --------------------------------------------------------------------------------