├── 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 |
--------------------------------------------------------------------------------