├── setup.cfg ├── params.py ├── requirements.txt ├── .vscode └── settings.json ├── translations.py ├── setup_db.py ├── main.py ├── LICENSE ├── README.md ├── bridges.txt ├── .gitignore ├── lib.py └── lib.test.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /params.py: -------------------------------------------------------------------------------- 1 | nb_bridges_per_pool = 3 2 | max_recs_per_day = 5 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio 2 | black 3 | isort 4 | semaphore-bot 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "python.formatting.provider": "black" 4 | } 5 | -------------------------------------------------------------------------------- /translations.py: -------------------------------------------------------------------------------- 1 | help_text_en = """Hello! I am GettorBot. 2 | 3 | Please text me one of: 4 | 5 | - help: sends this current help message 6 | - get_bridge: sends one bridge info 7 | - recommend NUMBER: recommends one phone number 8 | """ 9 | 10 | help_text_ru = """Привет! Я - GettorBot. 11 | 12 | Доступные команды: 13 | 14 | - help: отправляет текущее сообщение помощи 15 | - get_bridge: отправляет адрес моста 16 | """ 17 | 18 | translations = { 19 | "en": { 20 | "help_text": help_text_en, 21 | "no_bridges": "No bridges available", 22 | }, 23 | "ru": { 24 | "help_text": help_text_ru, 25 | "no_bridges": "Нет доступных мостов", 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /setup_db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Remove any existing database and sets up the tables for new one 3 | """ 4 | 5 | import os 6 | from sqlite3 import connect 7 | from params import nb_bridges_per_pool 8 | 9 | 10 | try: 11 | os.remove("db.db") 12 | except: 13 | pass 14 | con = connect("db.db") 15 | cur = con.cursor() 16 | cur.execute("CREATE TABLE bridges (value TEXT, pool INT)") 17 | file = open("./bridges.txt") 18 | bridges = [line[:-1] for line in file] 19 | for i in range(len(bridges)): 20 | cur.execute( 21 | "INSERT INTO bridges (value, pool) VALUES (?, ?)", 22 | (bridges[i], i // nb_bridges_per_pool), 23 | ) 24 | cur.execute("CREATE TABLE users (username TEXT, bridge TEXT, trust FLOAT, lang TEXT)") 25 | cur.execute( 26 | "CREATE TABLE recommendations (src TEXT, dst TEXT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)" 27 | ) 28 | con.commit() 29 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import sys 3 | from sqlite3 import connect 4 | 5 | import anyio 6 | from semaphore import Bot, ChatContext 7 | 8 | from lib import respond 9 | 10 | if __name__ == "__main__": 11 | 12 | if len(sys.argv) < 2: 13 | print(f"Usage: python {sys.argv[0]} PHONE_NUMBER") 14 | exit(1) 15 | 16 | con = connect("db.db") 17 | con.row_factory = sqlite3.Row 18 | 19 | bot = Bot(sys.argv[1]) 20 | 21 | @bot.handler("") 22 | async def handler(ctx: ChatContext) -> None: 23 | text = ctx.message.get_body() 24 | username = ctx.message.source.number 25 | response = respond(con, text, username) 26 | print(f"{username} <\n{text}") 27 | print(f"{sys.argv[1]} >\n{response}") 28 | await ctx.message.reply(response) 29 | 30 | async def main(): 31 | async with bot: 32 | await bot.set_profile("GettorBot") 33 | await bot.start() 34 | 35 | anyio.run(main) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nino Filiu 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 | # gettor-bot 2 | 3 | A Signal chatbot to broadcast Tor bridges in countries where Tor relays are blocked/monitored 4 | 5 | 🥇 Ranked 1st at the [DemHack 4](https://demhack.ru/) hackathon 🥇 6 | 7 | ## Installation 8 | 9 | 1. Install [signald](https://signald.org/) 10 | 2. Start the signald daemon 11 | 3. Register or link a phone number to signald 12 | 4. Clone this repo, setup the python virtual environment, install deps, and setup the database 13 | ```sh 14 | git clone git@github.com:ninofiliu/gettor-bot 15 | cd gettor-bot 16 | python -m venv .env 17 | source .env/bin/activate 18 | pip install -r requirements.txt 19 | python setup_db.py 20 | ``` 21 | 22 | If you encounter 23 | 24 | ``` 25 | ImportError: cannot import name 'Bot' from 'semaphore' 26 | ``` 27 | 28 | while installing requirements, build [semaphore](https://github.com/lwesterhof/semaphore) manually instead 29 | 30 | ```sh 31 | cd path/to/cloned/semaphore 32 | pip install wheel 33 | make build 34 | make install 35 | cd path/to/cloned/gettor-bot 36 | pip install -r requirements.txt 37 | ``` 38 | 39 | # Getting started 40 | 41 | Inside the virtual env, run the main script with the phone number you're using with signald 42 | 43 | ```sh 44 | python ./main.py +330123456789 45 | ``` 46 | 47 | If you encounter permission issues while doing this, add yourself to the signald group and restart your computer: 48 | 49 | ```sh 50 | usermod -a -G signald $(whoami) 51 | ``` 52 | -------------------------------------------------------------------------------- /bridges.txt: -------------------------------------------------------------------------------- 1 | obfs4 38.229.1.78:80 C8CBDB2464FC9804A69531437BCF2BE31FDD2EE4 cert=Hmyfd2ev46gGY7NoVxA9ngrPF2zCZtzskRTzoWXbxNkzeVnGFPWmrTtILRyqCTjHR+s9dg iat-mode=1 2 | obfs4 38.229.33.83:80 0BAC39417268B96B9F514E7F63FA6FBA1A788955 cert=VwEFpk9F/UN9JED7XpG1XOjm/O8ZCXK80oPecgWnNDZDv5pdkhq1OpbAH0wNqOT6H6BmRQ iat-mode=1 3 | obfs4 192.95.36.142:443 CDF2E852BF539B82BD10E27E9115A31734E378C2 cert=qUVQ0srL1JI/vO6V6m/24anYXiJD3QP2HgzUKQtQ7GRqqUvs7P+tG43RtAqdhLOALP7DJQ iat-mode=1 4 | obfs4 37.218.245.14:38224 D9A82D2F9C2F65A18407B1D2B764F130847F8B5D cert=bjRaMrr1BRiAW8IE9U5z27fQaYgOhX1UCmOpg2pFpoMvo6ZgQMzLsaTzzQNTlm7hNcb+Sg iat-mode=0 5 | obfs4 85.31.186.98:443 011F2599C0E9B27EE74B353155E244813763C3E5 cert=ayq0XzCwhpdysn5o0EyDUbmSOx3X/oTEbzDMvczHOdBJKlvIdHHLJGkZARtT4dcBFArPPg iat-mode=0 6 | obfs4 85.31.186.26:443 91A6354697E6B02A386312F68D82CF86824D3606 cert=PBwr+S8JTVZo6MPdHnkTwXJPILWADLqfMGoVvhZClMq/Urndyd42BwX9YFJHZnBB3H0XCw iat-mode=0 7 | obfs4 144.217.20.138:80 FB70B257C162BF1038CA669D568D76F5B7F0BABB cert=vYIV5MgrghGQvZPIi1tJwnzorMgqgmlKaB77Y3Z9Q/v94wZBOAXkW+fdx4aSxLVnKO+xNw iat-mode=0 8 | obfs4 193.11.166.194:27015 2D82C2E354D531A68469ADF7F878FA6060C6BACA cert=4TLQPJrTSaDffMK7Nbao6LC7G9OW/NHkUwIdjLSS3KYf0Nv4/nQiiI8dY2TcsQx01NniOg iat-mode=0 9 | obfs4 193.11.166.194:27020 86AC7B8D430DAC4117E9F42C9EAED18133863AAF cert=0LDeJH4JzMDtkJJrFphJCiPqKx7loozKN7VNfuukMGfHO0Z8OGdzHVkhVAOfo1mUdv9cMg iat-mode=0 10 | obfs4 193.11.166.194:27025 1AE2C08904527FEA90C4C4F8C1083EA59FBC6FAF cert=ItvYZzW5tn6v3G4UnQa6Qz04Npro6e81AP70YujmK/KXwDFPTs3aHXcHp4n8Vt6w/bv8cA iat-mode=0 11 | obfs4 209.148.46.65:443 74FAD13168806246602538555B5521A0383A1875 cert=ssH+9rP8dG2NLDN2XuFw63hIO/9MNNinLmxQDpVa+7kTOa9/m+tGWT1SmSYpQ9uTBGa6Hw iat-mode=0 12 | obfs4 146.57.248.225:22 10A6CD36A537FCE513A322361547444B393989F0 cert=K1gDtDAIcUfeLqbstggjIw2rtgIKqdIhUlHp82XRqNSq/mtAjp1BIC9vHKJ2FAEpGssTPw iat-mode=0 13 | obfs4 45.145.95.6:27015 C5B7CD6946FF10C5B3E89691A7D3F2C122D2117C cert=TD7PbUO0/0k6xYHMPW3vJxICfkMZNdkRrb63Zhl5j9dW3iRGiCx0A7mPhe5T2EDzQ35+Zw iat-mode=0 14 | obfs4 [2a0c:4d80:42:702::1]:27015 C5B7CD6946FF10C5B3E89691A7D3F2C122D2117C cert=TD7PbUO0/0k6xYHMPW3vJxICfkMZNdkRrb63Zhl5j9dW3iRGiCx0A7mPhe5T2EDzQ35+Zw iat-mode=0 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Database 2 | db.db 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | -------------------------------------------------------------------------------- /lib.py: -------------------------------------------------------------------------------- 1 | """ 2 | Library part of gettor-bot 3 | 4 | It should *not* depend on "services": semaphore, libsignal etc 5 | Instead, use this lib inside files like main.py that use services 6 | This way, this lib 7 | - is pure-python, pure-logic 8 | - works everywhere 9 | - can be easily reused to create bots for other platforms than Signal 10 | - and can be properly unit tested 11 | 12 | (* ^ ω ^) 13 | """ 14 | 15 | import random 16 | import re 17 | import sqlite3 18 | from typing import List 19 | 20 | from params import max_recs_per_day 21 | from translations import translations 22 | 23 | 24 | def respond( 25 | con: sqlite3.Connection, 26 | text: str, 27 | username: str, 28 | ) -> str: 29 | def read(query: str, variables=None): 30 | cur = con.cursor() 31 | cur.execute(query, variables) 32 | return cur.fetchall() 33 | 34 | def write(query: str, variables=None): 35 | cur = con.cursor() 36 | cur.execute(query, variables) 37 | con.commit() 38 | 39 | def get_all(table: str) -> List[sqlite3.Row]: 40 | cur = con.cursor() 41 | cur.execute(f"SELECT * FROM {table}") 42 | return cur.fetchall() 43 | 44 | def set_one( 45 | table: str, 46 | filter_key, 47 | filter_value, 48 | set_key, 49 | set_value, 50 | ): 51 | cur = con.cursor() 52 | cur.execute( 53 | f"UPDATE {table} SET {set_key} = ? WHERE {filter_key} = ?", 54 | (set_value, filter_value), 55 | ) 56 | con.commit() 57 | 58 | # if text == "list_languages": 59 | # return "en, ru" 60 | 61 | # if text == "choose_language": 62 | 63 | # lang_match = re.match(r"^choose_language (.*)", text) 64 | # if lang_match is not None: 65 | # lang = lang_match.group(0) 66 | # username, lang 67 | 68 | if text == "help": 69 | return translations["en"]["help_text"] 70 | 71 | if text == "get_bridge": 72 | bridges = get_all("bridges") 73 | if len(bridges) == 0: 74 | return translations["en"]["no_bridges"] 75 | users = read("SELECT * FROM users WHERE username = ?", (username,)) 76 | if (len(users)) == 0: 77 | return "Failed: you are unknown" 78 | user = users[0] 79 | if user["trust"] < 0.5: 80 | return "Failed: you are not trusted enough" 81 | if user["bridge"] is None: 82 | new_bridge = random.choice(bridges) 83 | set_one("users", "username", username, "bridge", new_bridge["value"]) 84 | return new_bridge["value"] 85 | return user["bridge"] 86 | 87 | recommend_match = re.match("recommend (.+)", text) 88 | if recommend_match is not None: 89 | recommenders = read("SELECT * FROM users WHERE username = ?", (username,)) 90 | if len(recommenders) == 0: 91 | return f"Failed: you are unknown" 92 | recommender = recommenders[0] 93 | 94 | if recommender["trust"] == 0: 95 | return f"Failed: you are not trusted yet" 96 | 97 | already_recommended_recently = read( 98 | "SELECT * FROM recommendations WHERE src = ? AND ts > DATETIME('now', '-1 DAYS')", 99 | (username,), 100 | ) 101 | if len(already_recommended_recently) > max_recs_per_day: 102 | return f"Can't recomment more than {max_recs_per_day} users per day" 103 | 104 | recommendee_username = recommend_match.group(1) 105 | already_recommended_same = read( 106 | "SELECT * FROM recommendations WHERE src = ? AND dst = ?", 107 | (username, recommendee_username), 108 | ) 109 | if len(already_recommended_same) > 0: 110 | return f"You already recommended {recommendee_username}" 111 | 112 | recommendees = read( 113 | "SELECT * FROM users WHERE username = ?", (recommendee_username,) 114 | ) 115 | if len(recommendees) == 0: 116 | new_trust = recommender["trust"] / 2 117 | write( 118 | "INSERT INTO users (username, trust) VALUES (?, ?)", 119 | (recommendee_username, new_trust), 120 | ) 121 | write( 122 | "INSERT INTO recommendations (src, dst) VALUES (?, ?)", 123 | (username, recommendee_username), 124 | ) 125 | return f"Successfully recommended {recommendee_username}" 126 | 127 | recommendee = recommendees[0] 128 | if recommendee["trust"] > recommender["trust"]: 129 | return f"{recommendee_username} is already more trusted than you. You can recommend someone else." 130 | 131 | updated_trust = (recommendee["trust"] + recommender["trust"]) / 2 132 | write( 133 | "UPDATE users SET trust = ? WHERE username = ?", 134 | (updated_trust, recommendee_username), 135 | ) 136 | write( 137 | "INSERT INTO recommendations (src, dst) VALUES (?, ?)", 138 | (username, recommendee_username), 139 | ) 140 | return f"Successfully improved trust of {recommendee_username}" 141 | 142 | else: 143 | return translations["en"]["help_text"] 144 | -------------------------------------------------------------------------------- /lib.test.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import unittest 3 | from datetime import datetime, timedelta 4 | from typing import List, Tuple 5 | 6 | from lib import respond 7 | from params import max_recs_per_day 8 | from translations import translations 9 | 10 | nb_bridges_per_pool = 3 11 | 12 | con = sqlite3.connect(":memory:") 13 | con.row_factory = sqlite3.Row 14 | cur = con.cursor() 15 | cur.execute("CREATE TABLE bridges (value TEXT, pool INT)") 16 | cur.execute("CREATE TABLE users (username TEXT, bridge TEXT, trust FLOAT)") 17 | cur.execute( 18 | "CREATE TABLE recommendations (src TEXT, dst TEXT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)" 19 | ) 20 | 21 | 22 | def fill_db( 23 | bridges: List[Tuple] = [], 24 | users: List[Tuple] = [], 25 | recommendations: List[Tuple] = [], 26 | ) -> None: 27 | cur = con.cursor() 28 | cur.execute("DELETE FROM bridges") 29 | cur.executemany("INSERT INTO bridges (value, pool) VALUES (?, ?)", bridges) 30 | cur.execute("DELETE FROM users") 31 | cur.executemany( 32 | "INSERT INTO users (username, bridge, trust) VALUES (?, ?, ?)", users 33 | ) 34 | cur.execute("DELETE FROM recommendations") 35 | cur.executemany( 36 | "INSERT INTO recommendations (src, dst, ts) VALUES (?, ?, ?)", recommendations 37 | ) 38 | 39 | 40 | class TestRespond(unittest.TestCase): 41 | def assert_db_equals( 42 | self, 43 | bridges: List[Tuple] = [], 44 | users: List[Tuple] = [], 45 | recommendations: List[Tuple] = [], 46 | ) -> None: 47 | cur = con.cursor() 48 | self.assertEqual( 49 | [ 50 | (list(row)[0], list(row)[1]) 51 | for row in cur.execute("SELECT * FROM bridges").fetchall() 52 | ], 53 | bridges, 54 | ) 55 | self.assertEqual( 56 | [ 57 | (list(row)[0], list(row)[1], list(row)[2]) 58 | for row in cur.execute("SELECT * FROM users").fetchall() 59 | ], 60 | users, 61 | ) 62 | self.assertEqual( 63 | [ 64 | (list(row)[0], list(row)[1]) # do not check time stamp 65 | for row in cur.execute("SELECT * FROM recommendations").fetchall() 66 | ], 67 | recommendations, 68 | ) 69 | 70 | ## help 71 | 72 | def test_help(self): 73 | fill_db() 74 | 75 | response = respond(con, "help", "bobby") 76 | 77 | self.assertEqual(response, translations["en"]["help_text"]) 78 | 79 | ## get_bridge 80 | 81 | # No bridges available: should fail gracefully 82 | def test_get_bridge_0(self): 83 | fill_db() 84 | 85 | response = respond(con, "get_bridge", "bobby") 86 | self.assertEqual(response, "No bridges available") 87 | 88 | # User does not exist: should fail gracefully 89 | def test_get_bridge_1(self): 90 | fill_db( 91 | bridges=[("b0", 0), ("b1", 0), ("b2", 0)], users=[("not-bobby", "b0", 0)] 92 | ) 93 | 94 | response = respond(con, "get_bridge", "bobby") 95 | self.assertEqual(response, "Failed: you are unknown") 96 | 97 | # User exist but is not trusted: should fail gracefully 98 | def test_get_bridge_2(self): 99 | fill_db(bridges=[("b0", 0), ("b1", 0), ("b2", 0)], users=[("bobby", "b0", 0)]) 100 | response = respond(con, "get_bridge", "bobby") 101 | self.assertEqual(response, "Failed: you are not trusted enough") 102 | 103 | # User already exists and is trusted: should send the same bridge when asked several times 104 | def test_get_bridge_3(self): 105 | fill_db(bridges=[("b0", 0), ("b1", 0), ("b2", 0)], users=[("bobby", "b0", 0.8)]) 106 | 107 | first_response = respond(con, "get_bridge", "bobby") 108 | self.assertTrue(first_response in ["b0", "b1", "b2"]) 109 | for i in range(10): 110 | subsequent_response = respond(con, "get_bridge", "bobby") 111 | self.assertEqual(first_response, subsequent_response) 112 | 113 | ## recommend 114 | 115 | # No specified username: should respond with help text 116 | def test_recommend_0(self): 117 | fill_db() 118 | response = respond(con, "recommend", "bobby") 119 | self.assertEqual(response, translations["en"]["help_text"]) 120 | 121 | # Unknown recommender 122 | def test_recommend_1(self): 123 | fill_db() 124 | response = respond(con, "recommend charlie", "bobby") 125 | self.assertEqual(response, "Failed: you are unknown") 126 | 127 | # Untrusted recommender 128 | def test_recommend_2(self): 129 | fill_db(users=[("bobby", None, 0)]) 130 | response = respond(con, "recommend charlie", "bobby") 131 | self.assertEqual(response, "Failed: you are not trusted yet") 132 | 133 | # Too many recommendations 134 | def test_recommend_3(self): 135 | fill_db( 136 | users=[("bobby", None, 0.8)], 137 | recommendations=[ 138 | ("bobby", f"friend of bobby #{i}", datetime.now()) 139 | for i in range(2 * max_recs_per_day) 140 | ], 141 | ) 142 | response = respond(con, "recommend charlie", "bobby") 143 | self.assertEqual(response, "Can't recomment more than 5 users per day") 144 | 145 | # Already recommended 146 | def test_recommend_4(self): 147 | fill_db( 148 | users=[("bobby", None, 0.8), ("charlie", None, 0.4)], 149 | recommendations=[("bobby", "charlie", datetime.now() - timedelta(days=2))], 150 | ) 151 | response = respond(con, "recommend charlie", "bobby") 152 | self.assertEqual(response, "You already recommended charlie") 153 | 154 | # Recomendee does not exist yet 155 | def test_recommend_5(self): 156 | fill_db( 157 | users=[("bobby", None, 0.8)], 158 | ) 159 | response = respond(con, "recommend charlie", "bobby") 160 | self.assertEqual(response, "Successfully recommended charlie") 161 | self.assert_db_equals( 162 | users=[("bobby", None, 0.8), ("charlie", None, 0.4)], 163 | recommendations=[("bobby", "charlie")], 164 | ) 165 | 166 | # Recomendee exists and is more trustworthy 167 | def test_recommend_6(self): 168 | users = [("bobby", None, 0.5), ("charlie", None, 0.8)] 169 | fill_db( 170 | users=users, 171 | ) 172 | response = respond(con, "recommend charlie", "bobby") 173 | self.assertEqual( 174 | response, 175 | "charlie is already more trusted than you. You can recommend someone else.", 176 | ) 177 | self.assert_db_equals( 178 | users=users, 179 | recommendations=[], 180 | ) 181 | 182 | # Recomendee exists and is less trustworthy 183 | def test_recommend_7(self): 184 | fill_db( 185 | users=[("bobby", None, 0.8), ("charlie", None, 0.2)], 186 | ) 187 | response = respond(con, "recommend charlie", "bobby") 188 | self.assertEqual( 189 | response, 190 | "Successfully improved trust of charlie", 191 | ) 192 | self.assert_db_equals( 193 | users=[("bobby", None, 0.8), ("charlie", None, 0.5)], 194 | recommendations=[("bobby", "charlie")], 195 | ) 196 | 197 | ## default 198 | 199 | def test_default(self): 200 | fill_db() 201 | 202 | response = respond( 203 | con, 204 | "not_a_known_command", 205 | "bobby", 206 | ) 207 | self.assertEqual(response, translations["en"]["help_text"]) 208 | 209 | 210 | if __name__ == "__main__": 211 | unittest.main() 212 | --------------------------------------------------------------------------------