├── .gitignore ├── LICENSE ├── README.md ├── example-config.yaml ├── example-registration.yaml ├── matrix_imposter_bot ├── __init__.py ├── __main__.py ├── apputils.py ├── config.py ├── main.py ├── messages.py ├── meta.py ├── sql │ └── db_prep.sql └── utils.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | registration.yaml 3 | .venv 4 | *.db 5 | */__pycache__ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # matrix-imposter-bot 2 | A [Matrix](https://matrix.org/) bot for relaying messages. 3 | 4 | It works by monitoring all messages sent in rooms that it's present in, and re-posting those messages as if they were sent by a user whose access token the bot was given. 5 | 6 | The purpose of this is to give relay-bot capabilities to puppeting-only bridges. The idea is to let the bot send messages from a Matrix account that is signed in to a puppeting bridge. 7 | 8 | ## Installation 9 | * Clone this repository and `cd` to it. 10 | * (Optional, but recommended) Create a Python virtual environment (like with `python3 -m venv .venv`), and activate it (`source .venv/bin/activate`). 11 | * Install requirements with `pip install -r requirements.txt`. 12 | * Copy `example-config.yaml` to `config.yaml` and edit at least the `homeserver` section to refer to your homeserver's domain. You may also set `avatar` to a `mxc://` URI of an image to be used as the bot user's avatar. 13 | * Copy `example-registration.yaml` to `registration.yaml` and update `as_token` and `hs_token` with hard-to-guess values (such as the output of `pwgen -s 64 1`). 14 | * Edit your homeserver's configuration to add this as a registered appservice. If using Synapse, edit your `homeserver.yaml` to contain the path of your `registration.yaml` file as one of the `app_service_config_files`. 15 | * Run the appservice with `python3 -m matrix_imposter_bot`. 16 | 17 | ## Usage 18 | The bot tries to walk you through how to set it up, but here are the starting steps: 19 | 20 | * Invite the bot to a direct chat (its default username is `@_imposter_bot:your_domain`). This is your "control room" where you send commands to the bot. 21 | * The bot will ask you for your access token, which it needs in order to work. (You can find your access token in Riot/Web under Settings->Help & About). Give the bot your token with a command of `token `. 22 | * Invite the bot to a room where you want it to use your account to repeat other people's messages, then say "mimicme" in your control room with the bot. 23 | 24 | A `help` command is available as well, which explains other commands. The most important is `blacklist`, which accepts one or more patterns of Matrix user IDs to *not* repeat messages for. 25 | 26 | ## Example use case 27 | This bot can be used to give relay-bot capabilities to the [mautrix-facebook](https://github.com/mautrix/facebook) bridge, with a few tweaks to that bridge. This means that Matrix users not logged into the mautrix-facebook bridge can participate in portal rooms bridged to Facebook chats. 28 | 29 | * Use [this fork](https://github.com/mrjohnson22/mautrix-facebook/tree/outbound-only-rebased) of mautrix-facebook (or clone the original bridge and use the fork as a remote), which allows the usage of "outbound-only" accounts that can send (but not receive) messages from a Facebook account that another Matrix user is already logged in as. 30 | * In your mautrix-facebook config, set `allow_invites` to `true`, which allows you to invite arbitrary Matrix users to portal rooms managed by the bridge. 31 | * Create an alt of your Matrix account. 32 | * [Follow these steps](https://docs.mau.fi/bridges/python/facebook/authentication.html) to log into Facebook with both your main Matrix account and your alt account. 33 | * With both accounts, join any portal rooms of Facebook chats you want to have relay support in. 34 | * With your alt account: 35 | * Start a direct chat with `@_imposter_bot:your_domain`, and give it your alt account's access token. 36 | * In the direct chat with the bot, say `blacklist @your_main_account:your_domain @facebook_.+:your_domain @facebookbot:your_domain`. This prevents the bot from repeating messages from your main account & bot users representing Facebook users, which are already bridged properly, and messages from the bridge bot itself, which don't need to be relayed. 37 | * Invite `@_imposter_bot:your_domain` to all of the Facebook portal rooms you just joined. 38 | * In the direct chat with the bot, reply with `mimicme` for each portal room you joined. 39 | 40 | You can now use your main account to invite & chat with other Matrix users rooms in Facebook portal rooms! Any messages sent by other Matrix users will be re-sent by your alt account, which means those messages will get bridged to Facebook. You and other Matrix users may set your alt account as an "ignored user" to avoid seeing duplicate messages (the original message and the reposted one). 41 | 42 | At this point, it is also possible to plumb the room to other services via bridges that support both plumbing and relaying, such as [matrix-appservice-discord](https://github.com/Half-Shot/matrix-appservice-discord). Note that to prevent duplicate messages from being seen by those services, any other bridge acting as a relay-bot must be configured to blacklist messages sent by this bot. For matrix-appservice-discord, [this PR](https://github.com/Half-Shot/matrix-appservice-discord/pull/576) may be used to achieve that. 43 | -------------------------------------------------------------------------------- /example-config.yaml: -------------------------------------------------------------------------------- 1 | homeserver: 2 | address: http://localhost:8008 3 | domain: localhost 4 | 5 | appservice: 6 | https: false 7 | host: 127.0.0.1 8 | port: 10007 9 | db_name: imposter.db 10 | 11 | bot: 12 | displayname: 'ImposterBot' 13 | avatar: '' 14 | -------------------------------------------------------------------------------- /example-registration.yaml: -------------------------------------------------------------------------------- 1 | id: matrix-imposter-bot 2 | url: http://localhost:10007 3 | as_token: abcdefghijklmnopqrstuvwxyz 4 | hs_token: qwertyuiopasdfghjklzxcvbnm 5 | sender_localpart: _imposter_bot 6 | namespaces: 7 | users: [] 8 | aliases: [] 9 | rate_limited: false 10 | -------------------------------------------------------------------------------- /matrix_imposter_bot/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | 4 | app = Flask(__name__) 5 | 6 | from . import main 7 | 8 | def start(): 9 | from .meta import prep 10 | prep() 11 | return app 12 | -------------------------------------------------------------------------------- /matrix_imposter_bot/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from . import app 4 | from . import config 5 | 6 | 7 | dev = False 8 | debug = False 9 | skip_prep = False 10 | 11 | for arg in sys.argv[1:]: 12 | if arg == '-d': 13 | dev = True 14 | elif arg == '-g': 15 | debug = True 16 | elif arg == '--skip': 17 | skip_prep = True 18 | else: 19 | print(f'Invalid argument: {arg}') 20 | sys.exit(-1) 21 | 22 | if debug: 23 | dev = True 24 | 25 | if not skip_prep: 26 | from .meta import prep 27 | prep() 28 | sys.argv.append('--skip') 29 | 30 | 31 | host=config.cfg_settings['appservice']['host'] 32 | port=config.cfg_settings['appservice']['port'] 33 | 34 | if not dev: 35 | from waitress import serve 36 | res = serve(app, host=host, port=port) 37 | else: 38 | app.run(host=host, port=port, debug=debug) 39 | -------------------------------------------------------------------------------- /matrix_imposter_bot/apputils.py: -------------------------------------------------------------------------------- 1 | from . import config 2 | from . import utils 3 | 4 | import re 5 | 6 | 7 | class Linkable: 8 | def __init__(self, link, text): 9 | self._link = link 10 | self._text = text 11 | 12 | @property 13 | def link(self): 14 | return self._link 15 | 16 | @property 17 | def text(self): 18 | return self._text 19 | 20 | class MxLinkable(Linkable): 21 | def __init__(self, id, text=None): 22 | super().__init__(None, None) 23 | self.id = id # invoke setter 24 | # TODO Riot Web always renders pills with their current name, not with the text you give it... 25 | if text != None: 26 | self._text = text 27 | self._dirty = False 28 | self._link = get_mx_link(self._id, self._text) 29 | 30 | @property 31 | def id(self): 32 | return self._id 33 | 34 | @id.setter 35 | def id(self, id): 36 | self._id = id 37 | self._dirty = True 38 | 39 | @property 40 | def text(self): 41 | if self._dirty: 42 | self._update() 43 | return self._text if self._text else self._id 44 | 45 | @property 46 | def link(self): 47 | if self._dirty: 48 | self._update() 49 | return self._link 50 | 51 | def _update(self): 52 | self._text = self._updater() 53 | self._link = get_mx_link(self._id, self._text) 54 | #TODO if this is ever long-lived, there needs to be a way to re-dirty it 55 | self._dirty = self._text != None 56 | 57 | def _updater(self): 58 | raise NotImplementedError() 59 | 60 | class MxRoomLink(MxLinkable): 61 | def _updater(self): 62 | return get_room_name(self._id) 63 | 64 | class MxUserLink(MxLinkable): 65 | def _updater(self): 66 | return get_display_name(self._id) 67 | 68 | def get_mx_link(id, text): 69 | return f'{text if text else id}' 70 | 71 | def get_link_fmt_pair(template, linkables, *args): 72 | texts = [] 73 | links = [] 74 | for linkable in linkables: 75 | texts.append(linkable.text) 76 | links.append(linkable.link) 77 | 78 | plain = template.format(*texts, *args) 79 | html = template.replace('\n', '
').format(*links, *args) 80 | return (plain, html) 81 | 82 | 83 | room_pattern = re.compile('[#!][^:]+:[a-z0-9._:]+') 84 | def is_room_id(room_id): 85 | return bool(room_pattern.fullmatch(room_id)) 86 | 87 | 88 | def get_next_txnId(): 89 | conn = utils.get_db_conn() 90 | c = conn.cursor() 91 | c.execute('INSERT INTO transactions_out VALUES (null, 0)') 92 | c.execute('SELECT txnId FROM transactions_out WHERE txnId=(SELECT MAX(txnId) FROM transactions_out)') 93 | 94 | txnId = c.fetchone()[0] 95 | return txnId 96 | 97 | def commit_txnId(txnId): 98 | utils.get_db_conn().execute( 99 | 'UPDATE transactions_out SET committed=1 WHERE txnId=?', (txnId,)) 100 | # Not actually committing here, relying on HS to no-op re-requests 101 | 102 | 103 | def mx_request(method, endpoint, json=None, access_token=None, verbose=True, **kwargs): 104 | headers = { 105 | 'Content-Type':'application/json', 106 | 'Authorization':'Bearer {}'.format( 107 | access_token if access_token else config.as_token) 108 | }; 109 | 110 | txnId = None 111 | if method == 'PUT': 112 | slash_index = endpoint.rfind('/') + 1 113 | if endpoint[slash_index:] == 'txnId': 114 | txnId = get_next_txnId() 115 | endpoint = endpoint[:slash_index] + str(txnId) 116 | 117 | r = utils.make_request(method, config.hs_address + endpoint, json, headers, verbose, **kwargs) 118 | 119 | # TODO handle failure 120 | if txnId != None and r.status_code == 200: 121 | commit_txnId(txnId) 122 | 123 | return r 124 | 125 | 126 | def get_room_name(room_id): 127 | # TODO cache this in the DB!! 128 | r = mx_request('GET', f'/_matrix/client/r0/rooms/{room_id}/state/m.room.name') 129 | if r.status_code == 200: 130 | return r.json()['name'] 131 | elif r.status_code == 404: 132 | r = mx_request('GET', f'/_matrix/client/r0/rooms/{room_id}/state/m.room.canonical_alias') 133 | if r.status_code == 200: 134 | return r.json()['alias'] 135 | elif r.status_code == 404: 136 | return 'Unnamed room' 137 | 138 | # TODO This is really an error condition, but making it fatal is annoying 139 | return 'Unknown room' 140 | 141 | def get_display_name(mxid): 142 | # TODO cache this in the DB!! 143 | # TODO consider failing on network error. But not failing makes it much easier. 144 | r = mx_request('GET', f'/_matrix/client/r0/profile/{mxid}/displayname') 145 | return r.json()['displayname'] if r.status_code == 200 else None 146 | 147 | 148 | def post_message(room_id, message_plain, message_html=None, access_token=None): 149 | json={'body': message_plain, 'msgtype': 'm.text'} 150 | if message_html != None: 151 | json['format'] = 'org.matrix.custom.html' 152 | json['formatted_body'] = message_html 153 | 154 | return mx_request('PUT', 155 | f'/_matrix/client/r0/rooms/{room_id}/send/m.room.message/txnId', 156 | json, 157 | access_token=access_token) 158 | 159 | def post_message_status(*args, **kwargs): 160 | return post_message(*args, **kwargs).status_code == 200 161 | -------------------------------------------------------------------------------- /matrix_imposter_bot/config.py: -------------------------------------------------------------------------------- 1 | import re 2 | import yaml 3 | 4 | 5 | with open('registration.yaml') as reg_file: 6 | reg_settings = yaml.safe_load(reg_file) 7 | 8 | with open('config.yaml') as cfg_file: 9 | cfg_settings = yaml.safe_load(cfg_file) 10 | 11 | 12 | as_token = reg_settings['as_token'] 13 | hs_token = reg_settings['hs_token'] 14 | 15 | hs_address = cfg_settings['homeserver']['address'] 16 | hs_domain = cfg_settings['homeserver']['domain'] 17 | 18 | db_name = cfg_settings['appservice']['db_name'] 19 | 20 | as_botname = '@{}:{}'.format(reg_settings['sender_localpart'], hs_domain) 21 | as_disname = cfg_settings['bot']['displayname'] 22 | as_avatar = cfg_settings['bot']['avatar'] 23 | -------------------------------------------------------------------------------- /matrix_imposter_bot/main.py: -------------------------------------------------------------------------------- 1 | import re 2 | from traceback import format_exception 3 | 4 | from flask import request 5 | from sqlite3 import IntegrityError 6 | 7 | from . import app 8 | from . import config 9 | from . import messages 10 | from . import utils 11 | from .apputils import mx_request, post_message, post_message_status, MxRoomLink, MxUserLink, is_room_id 12 | 13 | CONTROL_ROOM_NAME = 'ImposterBot control room' 14 | 15 | @app.teardown_appcontext 16 | def teardown(exc): 17 | utils.close_db_conn() 18 | 19 | 20 | def validate_hs_token(request_args): 21 | if 'access_token' not in request_args: 22 | return ({'errcode': 'M_UNAUTHORIZED'}, 401) 23 | if request_args['access_token'] != config.hs_token: 24 | return ({'errcode': 'M_FORBIDDEN'}, 403) 25 | return None 26 | 27 | 28 | def get_committed_txn_event_idxs(txnId): 29 | print(f'\n!!!\nreceiving txnId = {txnId}') 30 | ret = [] 31 | c = utils.get_db_conn().cursor() 32 | for row in c.execute('SELECT event_idx FROM transactions_in WHERE txnId=(?)', (txnId,)): 33 | ret.append(row[0]) 34 | print(f'Events we saw already = {ret}') 35 | return ret 36 | 37 | def commit_txn_event(txnId, event_idx): 38 | print(f'Successfully handled event #{event_idx} of txnId = {txnId}') 39 | conn = utils.get_db_conn() 40 | conn.execute('INSERT INTO transactions_in VALUES (?, ?)', (txnId, event_idx)) 41 | # This will commit everything done during the event! 42 | conn.commit() 43 | 44 | 45 | def get_listening_room_users(room_id, exclude_users=[]): 46 | bot_in_room = True 47 | r = mx_request('GET', f'/_matrix/client/r0/rooms/{room_id}/joined_members') 48 | if r.status_code == 403: 49 | # Fall back to other API which remembers users of room bot used to be in 50 | bot_in_room = False 51 | r = mx_request('GET', f'/_matrix/client/r0/rooms/{room_id}/members?membership=join') 52 | elif r.status_code != 200: 53 | return None 54 | 55 | # A listening user is a user with a control room. 56 | listening_users = set() 57 | c = utils.get_db_conn().cursor() 58 | for row in c.execute('SELECT mxid FROM control_rooms'): 59 | listening_users.add(row[0]) 60 | 61 | users = set() 62 | if bot_in_room: 63 | for member in r.json()['joined']: 64 | users.add(member) 65 | else: 66 | for member_event in r.json()['chunk']: 67 | users.add(member_event['state_key']) 68 | 69 | return users.difference(exclude_users).intersection(listening_users) 70 | 71 | 72 | def is_user_in_monitored_room(mxid, room_id): 73 | # TODO consider caching room memberships in the DB 74 | c = utils.get_db_conn().cursor() 75 | c.execute('SELECT 1 FROM rooms WHERE room_id=?', (room_id,)) 76 | if not utils.fetchone_single(c): 77 | return False 78 | 79 | r = mx_request('GET', f'/_matrix/client/r0/rooms/{room_id}/joined_members') 80 | if r.status_code != 200: 81 | return False 82 | 83 | return mxid in r.json()['joined'] 84 | 85 | def get_rooms_for_user(mxid): 86 | # TODO consider caching room memberships in the DB 87 | room_list = [] 88 | c = utils.get_db_conn().cursor() 89 | for row in c.execute('SELECT room_id FROM rooms'): 90 | room_id = row[0] 91 | r = mx_request('GET', f'/_matrix/client/r0/rooms/{room_id}/joined_members') 92 | if r.status_code == 200 and mxid in r.json()['joined']: 93 | room_list.append(room_id) 94 | return room_list 95 | 96 | def get_placeholders_for_room_list(room_list): 97 | return f'{",".join(["?"]*len(room_list))}' 98 | 99 | 100 | def is_user_blacklisted(target_user, mimic_user, room_id): 101 | c = utils.get_db_conn().cursor() 102 | c.execute('SELECT blacklist FROM blacklists WHERE mimic_user=? AND room_id=?', (mimic_user, room_id)) 103 | row = c.fetchone() 104 | if row == None: 105 | c.execute('SELECT blacklist FROM blacklists WHERE mimic_user=? AND room_id is NULL', (mimic_user,)) 106 | row = c.fetchone() 107 | blacklist = row[0] if row != None else None 108 | 109 | if blacklist != None: 110 | for blacklist_word in blacklist.split(): 111 | if re.match(blacklist_word, target_user): 112 | return True 113 | 114 | return False 115 | 116 | 117 | def insert_control_room(mxid, room_id): 118 | utils.get_db_conn().execute('INSERT INTO control_rooms VALUES (?, ?, NULL)', (mxid, room_id)) 119 | 120 | def find_or_prepare_control_room(mxid): 121 | control_room = find_existing_control_room(mxid) 122 | 123 | if control_room == None: 124 | is_new_room = True 125 | r = mx_request('POST', 126 | '/_matrix/client/r0/createRoom', 127 | json={ 128 | 'name': CONTROL_ROOM_NAME, 129 | 'topic': 'Room for managing ImposterBot settings', 130 | 'invite': [mxid], 131 | 'creation_content': {'m.federate': False}, 132 | 'preset': 'trusted_private_chat', 133 | 'is_direct': True 134 | }) 135 | 136 | if r.status_code == 200: 137 | control_room = r.json()['room_id'] 138 | insert_control_room(mxid, control_room) 139 | 140 | else: 141 | is_new_room = False 142 | 143 | return control_room, is_new_room 144 | 145 | def find_existing_control_room(mxid): 146 | return utils.fetchone_single( 147 | utils.get_db_conn().execute('SELECT room_id FROM control_rooms WHERE mxid=?', (mxid,))) 148 | 149 | def get_mimic_user(target_room): 150 | return utils.fetchone_single( 151 | utils.get_db_conn().execute('SELECT mimic_user FROM rooms WHERE room_id=?', (target_room,))) 152 | 153 | def is_control_room(room_id): 154 | return utils.fetchone_single( 155 | utils.get_db_conn().execute('SELECT 1 FROM control_rooms WHERE room_id=?', (room_id,))) != None 156 | 157 | def get_control_room_user(room_id): 158 | return utils.fetchone_single( 159 | utils.get_db_conn().execute('SELECT mxid FROM control_rooms WHERE room_id=?', (room_id,))) 160 | 161 | 162 | def get_mimic_info_for_room_and_sender(room_id, sender): 163 | mimic_user = None 164 | access_token = None 165 | 166 | c = utils.get_db_conn().cursor() 167 | c.execute('SELECT mimic_user, access_token FROM rooms JOIN control_rooms ON rooms.mimic_user=control_rooms.mxid WHERE rooms.room_id=?', (room_id,)) 168 | row = c.fetchone() 169 | if row != None: 170 | mimic_user, access_token = row 171 | if mimic_user == sender: 172 | # Never replay the mimic user's own messages 173 | mimic_user = None 174 | 175 | if mimic_user != None and access_token != None and is_user_blacklisted(sender, mimic_user, room_id): 176 | mimic_user = None 177 | 178 | return mimic_user, access_token 179 | 180 | 181 | def control_room_notify(user_to, target_room_info, notify_fn, *args): 182 | control_room = find_existing_control_room(user_to) 183 | if control_room == None: 184 | return True 185 | 186 | if target_room_info.id != control_room: 187 | # TODO non-blocking 188 | return command_notify(control_room, target_room_info, True, notify_fn, *args) 189 | else: 190 | return True 191 | 192 | def command_notify(control_room, target_room_info, set_latest, notify_fn, *args): 193 | # Use this when the control room is known & a reply link is needed 194 | r, reply_link = notify_fn(control_room, target_room_info, *args) 195 | if r != None and r.status_code == 200: 196 | if reply_link: 197 | event_id = r.json()['event_id'] 198 | c = utils.get_db_conn().cursor() 199 | c.execute('INSERT INTO reply_links VALUES (?, ?, ?)', 200 | (control_room, event_id, target_room_info.id)) 201 | if set_latest: 202 | c.execute('INSERT INTO latest_reply_link VALUES (?, ?)', 203 | (control_room, event_id)) 204 | return True 205 | else: 206 | return False 207 | 208 | def notify_bot_joined(control_room, target_room_info): 209 | return post_message(control_room, 210 | *messages.bot_joined(target_room_info)), True 211 | 212 | def notify_user_joined(control_room, target_room_info): 213 | mimic_user = get_mimic_user(target_room_info.id) 214 | return post_message(control_room, 215 | *messages.user_joined(target_room_info, MxUserLink(mimic_user))), True 216 | 217 | def notify_bot_left(control_room, target_room_info): 218 | return post_message(control_room, 219 | *messages.bot_left(target_room_info)), False 220 | 221 | def notify_accepted_mimic(control_room, target_room_info): 222 | return post_message(control_room, 223 | *messages.accepted_mimic(target_room_info)), True 224 | 225 | def notify_mimic_taken(control_room, target_room_info, mimic_user_info): 226 | return post_message(control_room, 227 | *messages.mimic_taken(target_room_info, mimic_user_info)), True 228 | 229 | def notify_mimic_user_left(control_room, target_room_info, mimic_user_info): 230 | return post_message(control_room, 231 | *messages.mimic_user_left(target_room_info, mimic_user_info)), True 232 | 233 | def notify_room_name(control_room, target_room_info): 234 | return post_message(control_room, 235 | *messages.room_name(target_room_info)), True 236 | 237 | def notify_room_name_and_mimic_user(control_room, target_room_info, mimic_user_info): 238 | return post_message(control_room, 239 | *messages.room_name_and_mimic_user(target_room_info, mimic_user_info)), True 240 | 241 | def notify_expired_token(control_room, target_room_info): 242 | return post_message(control_room, messages.expired_token()), False 243 | 244 | 245 | # TODO move all this to its own file once I think of a good way to remove circular deps 246 | from inspect import signature 247 | 248 | def run_command(command_text, sender, control_room, replied_event=None): 249 | command_word = command_text[0].lower() 250 | command = COMMANDS.get(command_word) 251 | if command == None: 252 | return post_message_status(control_room, messages.invalid_command()) 253 | 254 | command_args = command_text[1:] 255 | is_room_command = len(signature(command.func).parameters) == 4 256 | 257 | if not is_room_command: 258 | return command.func(command_args, sender, control_room) 259 | else: 260 | target_room = None 261 | if len(command_args) >= 1 and is_room_id(command_args[0]): 262 | room_arg = command_args.pop(0) 263 | if room_arg[0] == '#': 264 | room_alias = room_arg.replace('#', '%23') 265 | r = mx_request('GET', f'/_matrix/client/r0/directory/room/{room_alias}') 266 | if r.status_code == 200: 267 | target_room = r.json()['room_id'] 268 | elif room_arg[0] == '!': 269 | target_room = room_arg 270 | elif command.uses_reply_link: 271 | c = utils.get_db_conn().cursor() 272 | if replied_event != None: 273 | c.execute('SELECT room_id FROM reply_links WHERE control_room=? AND event_id=?', 274 | (control_room, replied_event)) 275 | else: 276 | c.execute('SELECT room_id FROM reply_links NATURAL JOIN latest_reply_link WHERE control_room=?', 277 | (control_room,)) 278 | 279 | target_room = utils.fetchone_single(c) 280 | 281 | return command.func( 282 | command_args, sender, control_room, 283 | MxRoomLink(target_room) if target_room and is_user_in_monitored_room(sender, target_room) else None) 284 | 285 | 286 | def cmd_help(command_args, sender, control_room): 287 | text = '' 288 | for command_word, command in COMMANDS.items(): 289 | if command.help != None: 290 | example = f'{command.example} :' if command.example else ':' 291 | text += f'> {command_word} {example} {command.help}\n\n' 292 | 293 | return post_message_status(control_room, text) 294 | 295 | def cmd_register_token(command_args, sender, control_room): 296 | # Validate token first 297 | if len(command_args) < 1: 298 | return post_message_status(control_room, messages.empty_token()) 299 | 300 | access_token = command_args[0] 301 | r = mx_request('GET', '/_matrix/client/r0/account/whoami', access_token=access_token) 302 | if r.status_code == 200 and r.json()['user_id'] == sender: 303 | utils.get_db_conn().execute('UPDATE control_rooms SET access_token=? WHERE mxid=?', (access_token, sender)) 304 | return post_message_status(control_room, messages.received_token()) 305 | else: 306 | return post_message_status(control_room, messages.invalid_token()) 307 | 308 | def cmd_revoke_token(command_args, sender, control_room): 309 | unmimic_user(sender) 310 | c = utils.get_db_conn().execute('UPDATE control_rooms SET access_token=NULL WHERE mxid=?', (sender,)) 311 | return post_message_status(control_room, 312 | messages.revoked_token() if c.rowcount == 1 else messages.no_revoke_token()) 313 | 314 | def cmd_set_mimic_user(command_args, sender, control_room, target_room_info): 315 | if target_room_info == None: 316 | return post_message_status(control_room, messages.no_room()) 317 | 318 | c = utils.get_db_conn().cursor() 319 | c.execute('SELECT access_token FROM control_rooms WHERE mxid=?', (sender,)) 320 | if utils.fetchone_single(c) == None: 321 | return post_message_status(control_room, messages.cant_mimic()) 322 | else: 323 | mimic_user = get_mimic_user(target_room_info.id) 324 | if mimic_user == sender: 325 | return post_message_status(control_room, *messages.already_mimic(target_room_info)) 326 | elif mimic_user != None: 327 | return post_message_status(control_room, *messages.rejected_mimic(target_room_info, MxUserLink(mimic_user))) 328 | else: 329 | c.execute('UPDATE rooms SET mimic_user=? WHERE room_id=?', (sender, target_room_info.id)) 330 | event_success = command_notify( 331 | control_room, target_room_info, True, 332 | notify_accepted_mimic) 333 | 334 | # Notify other users that someone took mimic rights 335 | room_users = get_listening_room_users(target_room_info.id, [config.as_botname, sender]) 336 | if room_users != None and len(room_users) > 0: 337 | sender_info = MxUserLink(sender) 338 | for room_member in room_users: 339 | event_success = control_room_notify( 340 | room_member, target_room_info, 341 | notify_mimic_taken, sender_info) and event_success 342 | elif room_users == None: 343 | event_success = False 344 | 345 | return event_success 346 | 347 | def cmd_unset_user(command_args, sender, control_room, target_room_info): 348 | if target_room_info == None: 349 | return post_message_status(control_room, messages.no_room()) 350 | 351 | c = utils.get_db_conn().cursor() 352 | c.execute('UPDATE rooms SET mimic_user=NULL WHERE mimic_user=? AND room_id=?', (sender, target_room_info.id)) 353 | if c.rowcount != 0: 354 | event_success = post_message_status(control_room, *messages.stopped_mimic(target_room_info)) 355 | 356 | # Notify other users that they can be mimic targets 357 | room_users = get_listening_room_users(target_room_info.id, [config.as_botname, sender]) 358 | if room_users != None and len(room_users) > 0: 359 | sender_info = MxUserLink(sender) 360 | for room_member in room_users: 361 | event_success = control_room_notify( 362 | room_member, target_room_info, 363 | notify_mimic_user_left, sender_info) and event_success 364 | elif room_users == None: 365 | event_success = False 366 | 367 | return event_success 368 | else: 369 | return post_message_status(control_room, 370 | *(messages.never_mimicked(target_room_info) if c.rowcount == 1 else messages.never_mimicked(target_room_info))) 371 | 372 | def cmd_set_mode(command_args, sender, control_room, target_room_info): 373 | if len(command_args) < 1: 374 | return post_message_status(control_room, messages.invalid_mode()) 375 | 376 | c = utils.get_db_conn().cursor() 377 | 378 | mode = command_args[0] 379 | if mode == 'default' and target_room_info != None: 380 | c.execute('DELETE FROM response_modes WHERE mimic_user=? AND room_id=?', (sender, target_room_info.id)) 381 | mfunc = messages.default_response_mode_in_room if c.rowcount != 0 else messages.same_default_response_mode_in_room 382 | return post_message_status(control_room, *mfunc(target_room_info)) 383 | elif mode == 'echo': 384 | replace = 0 385 | elif mode == 'replace': 386 | replace = 1 387 | else: 388 | return post_message_status(control_room, messages.invalid_mode()) 389 | 390 | noop = False 391 | if target_room_info == None: 392 | if replace: 393 | try: 394 | c.execute('INSERT INTO response_modes VALUES (?,NULL,1)', (sender,)) 395 | except IntegrityError: 396 | noop = True 397 | else: 398 | c.execute('DELETE FROM response_modes WHERE mimic_user=? AND room_id is NULL', (sender,)) 399 | noop = c.rowcount == 0 400 | 401 | mfunc = messages.set_response_mode if not noop else messages.same_response_mode 402 | return post_message_status(control_room, mfunc(replace)) 403 | 404 | else: 405 | c.execute('SELECT replace FROM response_modes WHERE mimic_user=? AND room_id=?', (sender, target_room_info.id)) 406 | oldreplace = utils.fetchone_single(c) 407 | if oldreplace == replace: 408 | noop = True 409 | elif oldreplace == None: 410 | c.execute('INSERT INTO response_modes VALUES (?,?,?)', (sender, target_room_info.id, replace)) 411 | else: 412 | c.execute('UPDATE response_modes SET replace=? WHERE mimic_user=? AND room_id=?', (replace, sender, target_room_info.id)) 413 | 414 | mfunc = messages.set_response_mode_in_room if not noop else messages.same_response_mode_in_room 415 | return post_message_status(control_room, *mfunc(replace, target_room_info)) 416 | 417 | def cmd_set_blacklist(command_args, sender, control_room, target_room_info): 418 | if len(command_args) < 1: 419 | return post_message_status(control_room, messages.empty_blacklist()) 420 | 421 | c = utils.get_db_conn().cursor() 422 | 423 | # TODO match user IDs for all but single-word special cases 424 | blacklist = ' '.join(command_args) 425 | if blacklist == 'default' and target_room_info != None: 426 | c.execute('DELETE FROM blacklists WHERE mimic_user=? AND room_id=?', (sender, target_room_info.id)) 427 | mfunc = messages.default_blacklist_in_room if c.rowcount != 0 else messages.same_default_blacklist_in_room 428 | return post_message_status(control_room, *mfunc(target_room_info)) 429 | elif blacklist == 'none': 430 | blacklist = None 431 | 432 | # TODO consider noop messages, but probably unnecessary 433 | if target_room_info == None and blacklist == None: 434 | c.execute('DELETE FROM blacklists WHERE mimic_user=? AND room_id is NULL', (sender,)) 435 | else: 436 | c.execute('INSERT INTO blacklists VALUES (?,?,?)', (sender, target_room_info.id if target_room_info != None else None, blacklist)) 437 | 438 | if target_room_info == None: 439 | return post_message_status(control_room, messages.set_blacklist()) 440 | else: 441 | return post_message_status(control_room, *messages.set_blacklist_in_room(target_room_info)) 442 | 443 | def cmd_get_blacklist(command_args, sender, control_room, target_room_info): 444 | query = 'SELECT blacklist FROM blacklists WHERE mimic_user=? AND room_id IS ?' 445 | c = utils.get_db_conn().cursor() 446 | c.execute(query, (sender, target_room_info.id if target_room_info else None)) 447 | row = c.fetchone() 448 | if target_room_info != None and row == None: 449 | c.execute(query, (sender, None)) 450 | default_blacklist = utils.fetchone_single(c) 451 | if default_blacklist == None: 452 | default_blacklist = 'no blacklist' 453 | return post_message_status(control_room, messages.blacklist_follows_default(default_blacklist)) 454 | else: 455 | blacklist = row[0] if row != None else None 456 | return post_message_status(control_room, blacklist if blacklist else messages.no_blacklist()) 457 | 458 | def cmd_show_status(command_args, sender, control_room): 459 | # TODO allow requesting room-specific status 460 | c = utils.get_db_conn().cursor() 461 | # Don't quick-reply to anything 462 | c.execute('DELETE FROM latest_reply_link WHERE control_room=?', (control_room,)) 463 | 464 | mimic_room_infos = [] 465 | for row in c.execute('SELECT room_id FROM rooms WHERE mimic_user=?', (sender,)): 466 | room_id = row[0] 467 | mimic_room_infos.append(MxRoomLink(room_id)) 468 | 469 | sender_rooms = get_rooms_for_user(sender) 470 | placeholders = get_placeholders_for_room_list(sender_rooms) 471 | 472 | # TODO distinguish between echo and replace 473 | monitored_room_infos = [] 474 | for row in c.execute(f'SELECT room_id, mimic_user FROM rooms WHERE room_id IN ({placeholders}) ' \ 475 | f'AND mimic_user IS NOT NULL ' \ 476 | f'AND mimic_user IS NOT ?', (*sender_rooms, sender)): 477 | room_id, mimic_user = row 478 | if not is_user_blacklisted(sender, mimic_user, room_id): 479 | monitored_room_infos.append((MxRoomLink(room_id), MxUserLink(mimic_user))) 480 | 481 | if len(mimic_room_infos) == 0: 482 | if not post_message_status(control_room, messages.mimic_none()): 483 | return False 484 | else: 485 | if not post_message_status(control_room, messages.mimic_status()): 486 | return False 487 | for target_room_info in mimic_room_infos: 488 | if not command_notify(control_room, target_room_info, False, notify_room_name): 489 | return False 490 | 491 | if len(monitored_room_infos) == 0: 492 | if not post_message_status(control_room, messages.monitor_none()): 493 | return False 494 | else: 495 | if not post_message_status(control_room, messages.monitor_status()): 496 | return False 497 | for target_room_info, user_info in monitored_room_infos: 498 | if not command_notify(control_room, target_room_info, False, notify_room_name_and_mimic_user, user_info): 499 | return False 500 | 501 | return True 502 | 503 | def cmd_show_actions(command_args, sender, control_room): 504 | c = utils.get_db_conn().cursor() 505 | # Don't quick-reply to anything after this 506 | c.execute('DELETE FROM latest_reply_link WHERE control_room=?', (control_room,)) 507 | 508 | sender_rooms = get_rooms_for_user(sender) 509 | placeholders = get_placeholders_for_room_list(sender_rooms) 510 | 511 | mimic_room_infos = [] 512 | for row in c.execute(f'SELECT room_id FROM rooms WHERE room_id IN ({placeholders}) AND mimic_user IS NULL', sender_rooms): 513 | room_id = row[0] 514 | mimic_room_infos.append(MxRoomLink(room_id)) 515 | 516 | if len(mimic_room_infos) == 0: 517 | if not post_message_status(control_room, messages.mimic_none_available()): 518 | return False 519 | else: 520 | if not post_message_status(control_room, messages.mimic_available()): 521 | return False 522 | for target_room_info in mimic_room_infos: 523 | if not command_notify(control_room, target_room_info, False, notify_room_name): 524 | return False 525 | 526 | return True 527 | 528 | class Command: 529 | # TODO template and description 530 | def __init__(self, func, example, help, uses_reply_link=False): 531 | self.func = func 532 | self.example = example 533 | self.help = help 534 | self.uses_reply_link = uses_reply_link 535 | 536 | COMMANDS = { 537 | 'help': Command(cmd_help, None, None), 538 | 'token': Command(cmd_register_token, '', messages.cmd_token), 539 | 'revoke': Command(cmd_revoke_token, None, messages.cmd_revoke), 540 | 'mimicme': Command(cmd_set_mimic_user, '[room_alias_or_id]', messages.cmd_mimicme, True), 541 | 'stopit': Command(cmd_unset_user, '[room_alias_or_id]', messages.cmd_stopit, True), 542 | 'setmode': Command(cmd_set_mode, '[room_alias_or_id] echo|replace|default', messages.cmd_setmode), 543 | 'blacklist': Command(cmd_set_blacklist, '[room_alias_or_id] ', messages.cmd_blacklist), 544 | 'getblacklist': Command(cmd_get_blacklist, '[room_alias_or_id]', messages.cmd_getblacklist), 545 | 'status': Command(cmd_show_status, None, messages.cmd_status), 546 | 'actions': Command(cmd_show_actions, None, messages.cmd_actions) 547 | } 548 | 549 | 550 | def bot_leave_room(room_id): 551 | r = mx_request('POST', f'/_matrix/client/r0/rooms/{room_id}/leave') 552 | # If 403, bot was somehow removed from the room without it knowing. 553 | # Don't hard-fail on that, otherwise we'll never recover!! 554 | return r.status_code == 200 or r.status_code == 403 555 | 556 | def user_leave_room(member, room_left): 557 | event_success = True 558 | c = utils.get_db_conn().cursor() 559 | c.execute('UPDATE rooms SET mimic_user=NULL WHERE mimic_user=? AND room_id=?', (member, room_left)) 560 | if c.rowcount != 0: 561 | # User was mimic target: remove all room-specific rules for the room 562 | c.execute('DELETE FROM response_modes WHERE mimic_user=? AND room_id=?', (member, room_left)) 563 | c.execute('DELETE FROM blacklists WHERE mimic_user=? AND room_id=?', (member, room_left)) 564 | 565 | room_users = get_listening_room_users(room_left, [config.as_botname, member]) 566 | if room_users != None: 567 | room_left_info = MxRoomLink(room_left) 568 | member_info = MxUserLink(member) 569 | for room_member in room_users: 570 | # TODO non-blocking? Success gating or not? Do the same for other occurrences of this pattern. 571 | event_success = control_room_notify( 572 | room_member, room_left_info, 573 | notify_mimic_user_left, member_info) and event_success 574 | else: 575 | event_success = False 576 | 577 | return event_success 578 | 579 | def unmimic_user(mxid): 580 | # TODO have a "multi" version of the leave notification... 581 | event_success = True 582 | c = utils.get_db_conn().cursor() 583 | for row in c.execute('SELECT room_id FROM rooms WHERE mimic_user=?', (mxid,)): 584 | event_success = user_leave_room(mxid, row[0]) and event_success 585 | 586 | return event_success 587 | 588 | 589 | def prepend_with_author(message, sender_info, isHTML): 590 | if not isHTML: 591 | author = sender_info.text 592 | linebreak = '\n' 593 | else: 594 | author = sender_info.link 595 | linebreak = '
' 596 | 597 | return '{0} says:{2}{1}'.format(author, message, linebreak*2) 598 | 599 | 600 | @app.route('/transactions/', methods=['PUT']) 601 | def transactions(txnId): 602 | response = validate_hs_token(request.args) 603 | if response is not None: 604 | return response 605 | 606 | # Assume success until failure 607 | txn_success = True 608 | 609 | committed_event_idxs = get_committed_txn_event_idxs(txnId) 610 | events = request.get_json()['events'] 611 | seen_event_ids = {} 612 | for i in range(len(events)): 613 | 614 | if len(committed_event_idxs) > 0 and i == committed_event_idxs[0]: 615 | committed_event_idxs.pop(0) 616 | continue 617 | 618 | # Assume success until failure 619 | event_success = True 620 | 621 | event = events[i] 622 | event_id = event['event_id'] 623 | seen_event_id = event_id 624 | room_id = event.get('room_id') 625 | content = event['content'] 626 | type = event['type'] 627 | 628 | print('\n---BEGIN EVENT') 629 | print('type: ' + type) 630 | for key in event: 631 | if key != 'type': 632 | print(f'{key}: {event[key]}') 633 | print('--- END EVENT') 634 | 635 | if type.find('m.room') == 0: 636 | stype = type[7:] 637 | sender = event['sender'] 638 | 639 | c = utils.get_db_conn().cursor() 640 | 641 | if stype == 'member': 642 | member = event['state_key'] 643 | membership = content['membership'] 644 | 645 | # TODO map of memberships to functions 646 | 647 | if membership == 'invite': 648 | if member == config.as_botname: 649 | refused = False 650 | if content.get('is_direct'): 651 | # Only accept 1:1 invites if bot doesn't already have a control room for the inviter. 652 | # TODO Does this need to handle invites to existing "direct" rooms with >1 people in them already...? 653 | control_room = find_existing_control_room(sender) 654 | 655 | if control_room != None: 656 | refused = True 657 | # Don't try to get the room name, because bot may not have access to it! 658 | if post_message_status(control_room, messages.already_controlled()): 659 | event_success = bot_leave_room(room_id) 660 | else: 661 | insert_control_room(sender, room_id) 662 | 663 | else: 664 | # Always accept group chat invites. 665 | # Since the bot was interacted with, create a control room for the sender. 666 | c.execute('INSERT INTO rooms VALUES (?, NULL)', (room_id,)) 667 | event_success = find_or_prepare_control_room(sender) != None 668 | 669 | if event_success and not refused: 670 | # TODO non-blocking? 671 | r = mx_request('POST', f'/_matrix/client/r0/rooms/{room_id}/join') 672 | if r.status_code != 200: 673 | event_success = False 674 | 675 | elif membership == 'join': 676 | if utils.get_from_dict(event, 'prev_content', 'membership') == 'join': 677 | # Do nothing if this is a repeat of a previous join event 678 | # If not in a control room, post message for a user changing their name 679 | if not is_control_room(room_id): 680 | mimic_user, access_token = get_mimic_info_for_room_and_sender(room_id, sender) 681 | if mimic_user != None and access_token != None: 682 | old_sender_name = utils.get_from_dict(event, 'prev_content', 'displayname') 683 | new_sender_name = utils.get_from_dict(event, 'content', 'displayname') 684 | # TODO Riot Web always renders pills with their current name, not with the text you give it... 685 | #old_sender_info = MxUserLink(sender, old_sender_name) 686 | new_sender_info = MxUserLink(sender, new_sender_name) 687 | event_success = post_message_status( 688 | room_id, 689 | *messages.user_renamed_msg(old_sender_name, new_sender_info), 690 | access_token) 691 | 692 | else: 693 | in_control_room = is_control_room(room_id) 694 | if in_control_room == None: 695 | # Only possibility is that the bot somehow joined a room it didn't know about. 696 | # Just leave. 697 | event_success = bot_leave_room(room_id) 698 | 699 | elif in_control_room: 700 | if member == config.as_botname: 701 | # TODO non-blocking 702 | 703 | control_room_user = get_control_room_user(room_id) 704 | r = mx_request('GET', f'/_matrix/client/r0/rooms/{room_id}/joined_members') 705 | bot_created_room = r.json()['joined'] == [config.as_botname] 706 | if not bot_created_room: 707 | mx_request('PUT', 708 | f'/_matrix/client/r0/rooms/{room_id}/state/m.room.name', 709 | json={'name': CONTROL_ROOM_NAME}) 710 | 711 | # TODO Maybe don't send messages into this room until the user joins. 712 | # For now just don't auto-run "actions" because joins will be shown 713 | event_success = \ 714 | post_message_status(room_id, messages.welcome()) 715 | #post_message_status(room_id, messages.welcome()) and \ 716 | #cmd_show_actions(None, get_control_room_user(room_id), room_id) 717 | else: 718 | # If someone other than the control room's user joined it, 719 | # send them a violation message. 720 | # Do nothing otherwise (don't need to respond to a user joining their control room). 721 | control_room_user = get_control_room_user(room_id) 722 | if member != control_room_user: 723 | event_success = post_message_status(room_id, 724 | *messages.invalid_control_room_user(MxUserLink(member), MxUserLink(control_room_user))) 725 | 726 | else: 727 | if member == config.as_botname: 728 | # Notify each present listening user that the bot joined this room. 729 | room_users = get_listening_room_users(room_id, [config.as_botname]) 730 | if room_users != None: 731 | room_info = MxRoomLink(room_id) 732 | for room_member in room_users: 733 | event_success = control_room_notify( 734 | room_member, room_info, 735 | notify_bot_joined) and event_success 736 | else: 737 | event_success = False 738 | 739 | else: 740 | mimic_user, access_token = get_mimic_info_for_room_and_sender(room_id, sender) 741 | if mimic_user != None and access_token != None: 742 | sender_info = MxUserLink(sender) 743 | event_success = post_message_status(room_id, *messages.user_joined_msg(sender_info), access_token) 744 | 745 | # Notify the user that they joined a room that the bot is in. 746 | event_success = control_room_notify( 747 | member, MxRoomLink(room_id), 748 | notify_user_joined) and event_success 749 | 750 | elif membership == 'leave': 751 | in_control_room = is_control_room(room_id) 752 | control_room_user = get_control_room_user(room_id) if in_control_room else None 753 | if in_control_room == None: 754 | # Someone left an unmonitored room. 755 | # Ignore. 756 | pass 757 | 758 | elif member == config.as_botname: 759 | if in_control_room: 760 | # Bot left a control room, so it should be shut down. 761 | # Act as if the monitored user left all rooms they were being mimicked in. 762 | event_success = unmimic_user(control_room_user) 763 | else: 764 | # For each listening user in room, say that bot left 765 | room_users = get_listening_room_users(room_id, [config.as_botname]) 766 | if room_users != None: 767 | room_info = MxRoomLink(room_id) 768 | for room_member in room_users: 769 | event_success = control_room_notify( 770 | room_member, room_info, 771 | notify_bot_left) and event_success 772 | else: 773 | event_success = False 774 | 775 | # NOTE This should trigger a lot of cascaded deletions! 776 | c.execute(f'DELETE FROM {"rooms" if not in_control_room else "control_rooms"} WHERE room_id=?', (room_id,)) 777 | 778 | else: 779 | if in_control_room: 780 | if member == control_room_user: 781 | # User left their control room or rejected an invite. 782 | # Bot should leave the room too. Its leave event will handle the rest. 783 | event_success = bot_leave_room(room_id) and event_success 784 | else: 785 | event_success = user_leave_room(member, room_id) 786 | 787 | # If no one else is in the room, the bot should leave. 788 | room_empty = False 789 | r = mx_request('GET', f'/_matrix/client/r0/rooms/{room_id}/joined_members') 790 | if r.status_code == 403: 791 | room_empty = True 792 | elif r.status_code == 200: 793 | # Look for just 1 user, which is the bot itself 794 | room_empty = len(r.json()['joined']) == 1 795 | else: 796 | event_success = False 797 | 798 | if event_success: 799 | if not room_empty: 800 | # If the room is not empty, relay a message saying the user left. 801 | mimic_user, access_token = get_mimic_info_for_room_and_sender(room_id, sender) 802 | if mimic_user != None and access_token != None: 803 | sender_info = MxUserLink(sender) 804 | post_message(room_id, *messages.user_left_msg(sender_info), access_token) 805 | else: 806 | event_success = bot_leave_room(room_id) 807 | 808 | 809 | 810 | elif stype == 'message': 811 | if 'body' not in content: 812 | # Event is redacted, ignore 813 | pass 814 | elif is_control_room(room_id): 815 | if sender == get_control_room_user(room_id): 816 | replied_event = None 817 | try: 818 | replied_event = content['m.relates_to']['m.in_reply_to']['event_id'] 819 | body = content['formatted_body'] 820 | reply_end = body.find('') 821 | body = body[reply_end+11:] 822 | except KeyError: 823 | body = content['body'] 824 | 825 | event_success = run_command(body.split(), sender, room_id, replied_event) 826 | 827 | elif sender != config.as_botname: 828 | 829 | # Create control room for a user who mentions the bot. 830 | if content['body'].find(config.as_botname) != -1 or \ 831 | ('formatted_body' in content and content['formatted_body'].find(config.as_botname) != -1): 832 | control_room, is_new_room = find_or_prepare_control_room(sender) 833 | if control_room == None: 834 | event_success = False 835 | elif not is_new_room: 836 | post_message(control_room, messages.ping()) 837 | 838 | else: 839 | mimic_user = None 840 | access_token = None 841 | 842 | c.execute('SELECT 1 FROM generated_messages WHERE event_id=? AND room_id=?', (event_id, room_id)) 843 | if c.fetchone() == None: 844 | mimic_user, access_token = get_mimic_info_for_room_and_sender(room_id, sender) 845 | 846 | if mimic_user != None and access_token != None: 847 | sender_info = MxUserLink(sender) 848 | 849 | content['formatted_body'] = prepend_with_author( 850 | content['formatted_body' if 'formatted_body' in content else 'body'], sender_info, True) 851 | 852 | content['body'] = prepend_with_author( 853 | content['body'], sender_info, False) 854 | 855 | content['format'] = 'org.matrix.custom.html' 856 | 857 | r = mx_request('PUT', 858 | f'/_matrix/client/r0/rooms/{room_id}/send/m.room.message/txnId', 859 | json=content, 860 | access_token=access_token) 861 | 862 | if r.status_code == 200: 863 | seen_event_id = r.json()['event_id'] 864 | c.execute('INSERT INTO generated_messages VALUES (?, ?)', (seen_event_id, room_id)) 865 | 866 | c.execute('SELECT replace FROM response_modes WHERE mimic_user=? AND room_id=?', (mimic_user, room_id)) 867 | replace = utils.fetchone_single(c) 868 | if replace == None: 869 | c.execute('SELECT replace FROM response_modes WHERE mimic_user=? AND room_id is NULL', (mimic_user,)) 870 | replace = utils.fetchone_single(c) 871 | 872 | if replace: 873 | r = mx_request('PUT', 874 | f'/_matrix/client/r0/rooms/{room_id}/redact/{event_id}/txnId', 875 | json={'reason':'Replaced by ImposterBot'}) 876 | if r.status_code not in [200, 403, 404]: 877 | event_success = False 878 | elif r.status_code == 200: 879 | seen_event_id = r.json()['event_id'] 880 | 881 | elif r.json()['errcode'] == 'M_UNKNOWN_TOKEN': 882 | event_success = control_room_notify( 883 | mimic_user, MxRoomLink(room_id), 884 | notify_expired_token) 885 | else: 886 | # Unknown token is a "valid" error. For anything else, want to retry 887 | event_success = False 888 | 889 | else: 890 | print(f'Unsupported room event type: {type}') 891 | else: 892 | print(f'Unsupported event type: {type}') 893 | 894 | if event_success: 895 | if room_id != None and not is_control_room(room_id): 896 | seen_event_ids[room_id] = seen_event_id 897 | commit_txn_event(txnId, i) 898 | else: 899 | print('\nEvent unsuccessful!!!!!') 900 | txn_success = False 901 | # Discard any uncommitted changes 902 | utils.close_db_conn() 903 | 904 | for room_id, event_id in seen_event_ids.items(): 905 | # TODO non-blocking 906 | mx_request('POST', f'/_matrix/client/r0/rooms/{room_id}/receipt/m.read/{event_id}') 907 | 908 | return ({}, 200 if txn_success else 500) 909 | -------------------------------------------------------------------------------- /matrix_imposter_bot/messages.py: -------------------------------------------------------------------------------- 1 | from .apputils import get_link_fmt_pair 2 | 3 | # TODO translations??? 4 | 5 | def welcome(): 6 | return 'Hi! I\'m imposter-bot. Send me commands in this room.\nTo get started, give me your access token by saying "token ".' 7 | 8 | def ping(): 9 | return 'You called?' 10 | 11 | def already_controlled(): 12 | return 'You invited me to a direct chat room, but we already have this room, and I can only be in one direct chat at a time!' 13 | 14 | def invalid_control_room_user(bad_user_info, good_user_info): 15 | return get_link_fmt_pair('Hey {}, you aren\'t allowed in here! This is {}\'s control room!', [bad_user_info, good_user_info]) 16 | 17 | def invalid_command(): 18 | return 'Not a valid command!' 19 | 20 | # TODO get command names from const vars 21 | 22 | def ask_for_token(): 23 | return 'You can give me your token by saying "token ".' 24 | 25 | def empty_token(): 26 | return 'Must provide a token!' 27 | 28 | def offer_mimic(): 29 | return 'Reply "mimicMe" to this message to make me post other people\'s messages on your behalf in that room.' 30 | 31 | def offer_stop(): 32 | return 'To make me stop, reply "stopit" to this message.' 33 | 34 | def mimic_taken(room_info, user_info): 35 | return get_link_fmt_pair( 36 | 'I am now mimicking {0} in the following room:\n{1}', 37 | [user_info, room_info]) 38 | 39 | def cant_mimic(): 40 | return 'I can\'t mimic you until I have your access token! ' + ask_for_token() 41 | 42 | def bot_joined(room_info): 43 | return get_link_fmt_pair('I just joined a room:\n{}\n{}', [room_info], offer_mimic()) 44 | 45 | def user_joined(room_info, mimic_user_info): 46 | return get_link_fmt_pair( 47 | 'You just joined a room that I am present in:\n{}\n' + (offer_mimic() if not mimic_user_info.id else 'I am already mimicking {} in this room.'), 48 | [room_info, mimic_user_info]) 49 | 50 | def user_joined_msg(user_info): 51 | return get_link_fmt_pair('{} joined the conversation', [user_info]) 52 | 53 | def user_left_msg(user_info): 54 | return get_link_fmt_pair('{} left the conversation', [user_info]) 55 | 56 | # TODO Riot Web always renders user pills with their current name, not with the text you give it... 57 | def user_renamed_msg(old_user_name, new_user_info): 58 | return get_link_fmt_pair('{1} changed their display name to {0}', [new_user_info], old_user_name) 59 | 60 | def bot_left(room_info): 61 | return get_link_fmt_pair('I just left this room:\n{}\nMy reign of terror in that room is over. I won\'t alter messages in it anymore.', [room_info]) 62 | 63 | def mimic_user_left(room_info, mimic_user_info): 64 | return get_link_fmt_pair('I am no longer mimicking {0} in following room:\n{1}\nNo one\'s messages will appear as coming from them anymore. This means you can now ask me to mimic you in that room if you like!\n{2}', [mimic_user_info, room_info], offer_mimic()) 65 | 66 | def received_token(): 67 | return 'Thanks for your access token! I can now mimic you. To make me revoke this token, say "revoke".\nTry saying "actions" to see a list of everything you can make me do.' 68 | 69 | def invalid_token(): 70 | return 'Invalid access token!!' 71 | 72 | def revoked_token(): 73 | return 'OK, I discarded the access token you gave me. If I was mimicking you anywhere, I won\'t anymore.' 74 | 75 | def no_revoke_token(): 76 | return 'You never gave me a token to revoke!' 77 | 78 | def expired_token(): 79 | return 'The token I have for your account is invalid. I can\'t mimic you until you give me a new, valid access token.\n' + ask_for_token() 80 | 81 | def no_room(): 82 | return 'I can only answer that command in response to a room that we are both present in!' 83 | 84 | def accepted_mimic(room_info): 85 | return get_link_fmt_pair('I am now mimicking you in {}!\n{}', [room_info], offer_stop()) 86 | 87 | def rejected_mimic(room_info, user_info): 88 | return get_link_fmt_pair('I can\'t mimic you in {}, because I am already mimicking {} in that room.', [room_info, user_info]) 89 | 90 | def already_mimic(room_info): 91 | return get_link_fmt_pair('I am already mimicking you in {}!', [room_info]) 92 | 93 | def stopped_mimic(room_info): 94 | return get_link_fmt_pair('Okay, I stopped mimicking you in {}.', [room_info]) 95 | 96 | def never_mimicked(room_info): 97 | return get_link_fmt_pair('I was never mimicking you in {}!', [room_info]) 98 | 99 | def replace_reminder(): 100 | return ' Don\'t forget to give me a power level high enough to delete messages!' 101 | 102 | def set_response_mode(replace): 103 | return 'I will now {} people\'s messages.{}'.format('echo' if not replace else 'replace', replace_reminder() if replace else '') 104 | 105 | def set_response_mode_in_room(replace, room_info): 106 | return get_link_fmt_pair('I will now {1} people\'s messages in {0}, overriding your global preference.{2}', [room_info], 'echo' if not replace else 'replace', replace_reminder() if replace else '') 107 | 108 | def same_response_mode(replace): 109 | return 'I was already {} people\'s messages!'.format('echoing' if not replace else 'replacing') 110 | 111 | def same_response_mode_in_room(replace, room_info): 112 | return get_link_fmt_pair('I was already {1} people\'s messages in {0}!', [room_info], 'echoing' if not replace else 'replacing') 113 | 114 | def invalid_mode(): 115 | return 'Valid modes are "echo", "replace", or "default" (only for room-specific modes).' 116 | 117 | def default_response_mode_in_room(room_info): 118 | return get_link_fmt_pair('I will now follow your global preference for how to handle people\'s messages in {}.{}', [room_info]) 119 | 120 | def same_default_response_mode_in_room(room_info): 121 | return get_link_fmt_pair('I was already following your global preference for how to handle people\'s messages in {}!', [room_info]) 122 | 123 | def empty_blacklist(): 124 | return 'Must provide a blacklist!' 125 | 126 | def set_blacklist(): 127 | return 'Global blacklist applied. I will not monitor messages sent by anyone matching the provided pattern.' 128 | 129 | def set_blacklist_in_room(room_info): 130 | return get_link_fmt_pair('Room-specific blacklist applied in {}. I will not monitor messages sent by anyone matching the provided pattern in that room.', [room_info]) 131 | 132 | def default_blacklist_in_room(room_info): 133 | return get_link_fmt_pair('I will now use your global blacklist for {} instead of a room-specific blacklist.', [room_info]) 134 | 135 | def same_default_blacklist_in_room(room_info): 136 | return get_link_fmt_pair('I was already using your global blacklist for {}!', [room_info]) 137 | 138 | def no_blacklist(): 139 | return 'No blacklist!' 140 | 141 | def blacklist_follows_default(default_blacklist): 142 | return f'Following the global blacklist of {default_blacklist}.' 143 | 144 | def mimic_none_available(): 145 | return 'There are no rooms where I can mimic you in!' 146 | 147 | def mimic_available(): 148 | return 'I can mimic you in the following rooms. Reply "mimicMe" to the room you mimic you in.' 149 | 150 | def mimic_none(): 151 | return 'I am not mimicking you in any rooms!' 152 | 153 | def mimic_status(): 154 | return 'I am mimicking you in the following rooms. Reply "stopit" to the room you want me to stop mimicking you in.' 155 | 156 | def monitor_none(): 157 | return 'I am not repeating your messages in any rooms!' 158 | 159 | def monitor_status(): 160 | return 'I am repeating your messages in the following rooms.' 161 | 162 | def room_name(room_info): 163 | return get_link_fmt_pair('{}', [room_info]) 164 | 165 | def room_name_and_mimic_user(room_info, user_info): 166 | return get_link_fmt_pair('{}, as {}', [room_info, user_info]) 167 | 168 | 169 | cmd_token = 'Provides me with your Matrix account\'s access token, so that I may post messages on your behalf.' 170 | cmd_revoke = 'Makes me forget about any access token you gave me.' 171 | cmd_mimicme = 'Makes me use your account to repost other people\'s messages in the specified room. (If no room is provided, I\'ll use the most recent room I mentioned.)' 172 | cmd_stopit = 'Makes me stop mimicking you in the specified room. (If you don\'t specify a room, I\'ll use the most recent room I mentioned.)' 173 | cmd_setmode = 'Sets whether or not I delete people\'s messages when I repost them. This can have a different setting per room.' 174 | cmd_blacklist = 'Sets which accounts I will never repost messages for. The blacklist is one or more regex patterns; user IDs that match any pattern will be blacklisted. This can have a different setting per room.' 175 | cmd_getblacklist = 'Returns your global blacklist, or if a room is specified, your blacklist for that room.' 176 | cmd_status = 'Returns a list of all rooms I am mimicking you in, and all rooms I am reposting your messages in.' 177 | cmd_actions = 'Returns a list of all rooms where you can ask me to mimic you in.' -------------------------------------------------------------------------------- /matrix_imposter_bot/meta.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import signal 3 | import sys 4 | from threading import Timer 5 | 6 | import pkg_resources 7 | import sqlite3 8 | from flask import Flask 9 | 10 | from . import config 11 | from . import utils 12 | from .apputils import mx_request 13 | 14 | 15 | def run_sql(filename): 16 | conn = sqlite3.connect(config.db_name) 17 | c = conn.cursor() 18 | cmds = pkg_resources.resource_string(__name__, 'sql/' + filename).decode('utf8') 19 | for cmd in cmds.split(';\n\n'): 20 | c.execute(cmd) 21 | conn.commit() 22 | conn.close() 23 | 24 | 25 | def initial_setup(): 26 | # TODO use alembic 27 | run_sql('db_prep.sql') 28 | 29 | # TODO non-blocking and error checking 30 | r = mx_request('GET', f'/_matrix/client/r0/profile/{config.as_botname}/displayname', wait=True) 31 | if r.status_code == 404: 32 | r = mx_request('POST', '/_matrix/client/r0/register', wait=True, 33 | json={ 34 | 'type': 'm.login.application_service', 35 | 'username': config.as_botname[1:].split(':')[0] 36 | }) 37 | 38 | if r.status_code == 200: 39 | initial_setup() 40 | else: 41 | return 42 | 43 | elif r.status_code != 200 or utils.get_from_dict(r.json(), 'displayname') != config.as_disname: 44 | mx_request('PUT', f'/_matrix/client/r0/profile/{config.as_botname}/displayname', wait=True, 45 | json={'displayname': config.as_disname}) 46 | 47 | if config.as_avatar == '': 48 | return 49 | r = mx_request('GET', f'/_matrix/client/r0/profile/{config.as_botname}/avatar_url', wait=True) 50 | if r.status_code != 200 or utils.get_from_dict(r.json(), 'avatar_url') != config.as_avatar: 51 | mx_request('PUT', f'/_matrix/client/r0/profile/{config.as_botname}/avatar_url', wait=True, 52 | json={'avatar_url': config.as_avatar}) 53 | 54 | 55 | def leave_bad_rooms(): 56 | app = Flask(__name__) 57 | with app.app_context(): 58 | c = utils.get_db_conn().cursor() 59 | r = mx_request('GET', '/_matrix/client/r0/joined_rooms') 60 | for room_id in r.json()['joined_rooms']: 61 | c.execute('SELECT 1 FROM rooms WHERE room_id=?', (room_id,)) 62 | room_found = utils.fetchone_single(c) 63 | if not room_found: 64 | c.execute('SELECT 1 FROM control_rooms WHERE room_id=?', (room_id,)) 65 | room_found = utils.fetchone_single(c) 66 | 67 | if not room_found: 68 | mx_request('POST', f'/_matrix/client/r0/rooms/{room_id}/leave') 69 | 70 | 71 | timer = None 72 | def update_presence(): 73 | mx_request('PUT', f'/_matrix/client/r0/presence/{config.as_botname}/status', wait=True, 74 | json={'presence': 'online'}, verbose=False) 75 | 76 | global timer 77 | timer = Timer(20.0, update_presence) 78 | timer.start() 79 | 80 | def sighandler(sig, frame): 81 | print(f'Caught signal {sig}') 82 | 83 | global timer 84 | if timer != None: 85 | timer.cancel() 86 | sys.exit(0) 87 | 88 | 89 | def on_exit(): 90 | print('Shutting down') 91 | mx_request('PUT', f'/_matrix/client/r0/presence/{config.as_botname}/status', 92 | json={'presence': 'offline'}) 93 | 94 | 95 | def prep(): 96 | initial_setup() 97 | 98 | # TODO is there any other missed state to sync? 99 | leave_bad_rooms() 100 | 101 | for sig in [signal.SIGINT, signal.SIGTERM, signal.SIGQUIT]: 102 | signal.signal(sig, sighandler) 103 | update_presence() 104 | 105 | atexit.register(on_exit) 106 | -------------------------------------------------------------------------------- /matrix_imposter_bot/sql/db_prep.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys = ON; 2 | 3 | CREATE TABLE IF NOT EXISTS rooms ( 4 | room_id text PRIMARY KEY, 5 | mimic_user text 6 | ); 7 | 8 | CREATE TABLE IF NOT EXISTS control_rooms ( 9 | mxid text PRIMARY KEY, 10 | room_id text NOT NULL UNIQUE, 11 | access_token text 12 | ); 13 | 14 | CREATE TABLE IF NOT EXISTS response_modes ( 15 | mimic_user text NOT NULL, 16 | room_id text, 17 | replace integer CHECK (replace IN (0,1)), 18 | 19 | PRIMARY KEY (mimic_user, room_id), 20 | FOREIGN KEY (mimic_user) 21 | REFERENCES control_rooms (mxid) 22 | ON DELETE CASCADE, 23 | FOREIGN KEY (room_id) 24 | REFERENCES rooms (room_id) 25 | ON DELETE CASCADE 26 | ); 27 | 28 | CREATE TRIGGER IF NOT EXISTS unique_null_response_modes 29 | BEFORE INSERT ON response_modes 30 | WHEN NEW.room_id IS NULL 31 | BEGIN 32 | SELECT CASE 33 | WHEN EXISTS (SELECT 1 FROM response_modes WHERE room_id IS NULL AND mimic_user=NEW.mimic_user) 34 | THEN RAISE(ABORT, 'Duplicate global entries') END; 35 | END; 36 | 37 | CREATE TABLE IF NOT EXISTS blacklists ( 38 | mimic_user text NOT NULL, 39 | room_id text, 40 | blacklist text, 41 | 42 | PRIMARY KEY (mimic_user, room_id) ON CONFLICT REPLACE, 43 | FOREIGN KEY (mimic_user) 44 | REFERENCES control_rooms (mxid) 45 | ON DELETE CASCADE, 46 | FOREIGN KEY (room_id) 47 | REFERENCES rooms (room_id) 48 | ON DELETE CASCADE 49 | ); 50 | 51 | CREATE TRIGGER IF NOT EXISTS unique_null_blacklists 52 | BEFORE INSERT ON blacklists 53 | WHEN NEW.room_id IS NULL 54 | BEGIN 55 | DELETE FROM blacklists WHERE room_id IS NULL AND mimic_user=NEW.mimic_user; 56 | END; 57 | 58 | CREATE TABLE IF NOT EXISTS generated_messages ( 59 | event_id text NOT NULL, 60 | room_id text NOT NULL, 61 | 62 | PRIMARY KEY (event_id, room_id), 63 | FOREIGN KEY (room_id) 64 | REFERENCES rooms (room_id) 65 | ON DELETE CASCADE 66 | ); 67 | 68 | CREATE TABLE IF NOT EXISTS reply_links ( 69 | control_room text NOT NULL, 70 | event_id text NOT NULL, 71 | room_id text NOT NULL, 72 | 73 | PRIMARY KEY (control_room, event_id), 74 | FOREIGN KEY (control_room) 75 | REFERENCES control_rooms (room_id) 76 | ON DELETE CASCADE 77 | ); 78 | 79 | CREATE TABLE IF NOT EXISTS latest_reply_link ( 80 | control_room text PRIMARY KEY ON CONFLICT REPLACE, 81 | event_id text NOT NULL, 82 | 83 | FOREIGN KEY (control_room, event_id) 84 | REFERENCES reply_links (control_room, event_id) 85 | ON DELETE CASCADE 86 | ); 87 | 88 | CREATE TABLE IF NOT EXISTS transactions_in ( 89 | txnId integer NOT NULL, 90 | event_idx integer NOT NULL, 91 | 92 | PRIMARY KEY (txnId, event_idx) 93 | ); 94 | 95 | CREATE TABLE IF NOT EXISTS transactions_out ( 96 | txnId integer PRIMARY KEY AUTOINCREMENT, 97 | committed integer CHECK (committed IN (0,1)) 98 | ); 99 | 100 | -------------------------------------------------------------------------------- /matrix_imposter_bot/utils.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | from flask import g 4 | from requests import request 5 | from requests.exceptions import ConnectionError 6 | 7 | from time import sleep 8 | 9 | from .config import db_name 10 | 11 | 12 | def get_db_conn(): 13 | if 'conn' not in g: 14 | g.conn = sqlite3.connect(db_name) 15 | g.conn.execute('PRAGMA foreign_keys = ON') 16 | 17 | return g.conn 18 | 19 | def close_db_conn(): 20 | conn = g.pop('conn', None) 21 | 22 | if conn != None: 23 | # Do not commit here! Other places should do it themselves 24 | conn.close() 25 | 26 | 27 | def get_from_dict(dict, *keys): 28 | for key in keys[:-1]: 29 | if key in dict: 30 | dict = dict[key] 31 | else: 32 | return None 33 | return dict[keys[-1]] if keys[-1] in dict else None 34 | 35 | def fetchone_single(c): 36 | seq = c.fetchone() 37 | return seq[0] if seq else None 38 | 39 | def make_request(method, endpoint, json=None, headers=None, verbose=True, wait=False, **kwargs): 40 | if verbose: 41 | print('\n---BEGIN REQUEST') 42 | print('Method: ' + method) 43 | print('URL: ' + endpoint) 44 | #if headers != None: 45 | # for key in headers: 46 | # print(f'{key}: {headers[key]}') 47 | if json != None: 48 | for key in json: 49 | print(f'{key}: {json[key]}') 50 | print('--- END REQUEST') 51 | 52 | while True: 53 | try: 54 | r = request(method, endpoint, json=json, headers=headers, **kwargs) 55 | break 56 | except ConnectionError as e: 57 | if verbose: 58 | print('Error, try again in 5 seconds...') 59 | sleep(5) 60 | 61 | if verbose: 62 | print('\n---BEGIN RESPONSE') 63 | try: 64 | json = r.json() 65 | for key in json: 66 | print(f'{key}: {json[key]}') 67 | except: 68 | print(r.text) 69 | 70 | print(f'STATUS: {r.status_code}') 71 | #print(r.headers.items()) 72 | print('--- END RESPONSE') 73 | 74 | return r 75 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | waitress 3 | PyYAML 4 | requests 5 | --------------------------------------------------------------------------------