├── 09_hellomotoot ├── .gitignore ├── favicon.ico ├── audio │ ├── beep.mp3 │ ├── boop.mp3 │ ├── error.mp3 │ ├── intro.mp3 │ ├── 2_mentions.mp3 │ ├── 3_boopsound.mp3 │ ├── pleasehold.mp3 │ ├── pleaserate.mp3 │ ├── wrongrating.mp3 │ ├── correctrating.mp3 │ ├── pleaseregister.mp3 │ └── 1_recordmessage.mp3 ├── templates │ ├── index.html │ ├── manage.html │ ├── footer.html │ └── header.html └── hellomotoot.py ├── README.md ├── requirements.txt ├── 07_florps ├── templates │ ├── form_post.htm │ ├── post_content.htm │ ├── post_list.htm │ ├── button_boost.htm │ ├── post_actions.htm │ ├── button_fav.htm │ ├── post.htm │ ├── login.htm │ └── authed.htm ├── ui_data.json └── florps.py ├── 06_mastomash ├── templates │ ├── post_content.htm │ ├── post_actions.htm │ ├── login.htm │ └── authed.htm ├── mastomash.py └── ui_data.json ├── 08_trunkshow ├── templates │ ├── post_list.htm │ ├── enter.htm │ └── gallery.htm └── trunkshow.py ├── tooling ├── app_data_registry.py ├── login_oauth.py └── secret_registry.py ├── .gitignore ├── 01_latency_observatory ├── latency_web.py └── latencies.py ├── 03_clippy ├── templates │ ├── authed.htm │ └── login.htm └── clippy.py ├── 04_alphant ├── templates │ ├── login.htm │ └── authed.htm └── alphant.py ├── 02_nicer_latency_observatory ├── latency_web.py └── latencies.py └── 05_toot_it_forward ├── templates └── login.htm └── tootitforward.py /09_hellomotoot/.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mastodon.py Examples 2 | -------------------------------------------------------------------------------- /09_hellomotoot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/favicon.ico -------------------------------------------------------------------------------- /09_hellomotoot/audio/beep.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/beep.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/boop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/boop.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/error.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/error.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/intro.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/intro.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/2_mentions.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/2_mentions.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/3_boopsound.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/3_boopsound.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/pleasehold.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/pleasehold.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/pleaserate.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/pleaserate.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/wrongrating.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/wrongrating.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/correctrating.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/correctrating.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/pleaseregister.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/pleaseregister.mp3 -------------------------------------------------------------------------------- /09_hellomotoot/audio/1_recordmessage.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halcy/MastodonpyExamples/HEAD/09_hellomotoot/audio/1_recordmessage.mp3 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | locket 2 | flask 3 | mastodon.py 4 | numpy 5 | matplotlib 6 | validators 7 | torch 8 | torchvision 9 | torchaudio 10 | transformers 11 | fast-pytorch-kmeans 12 | htmx 13 | twilio 14 | qrcode 15 | -------------------------------------------------------------------------------- /07_florps/templates/form_post.htm: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
-------------------------------------------------------------------------------- /07_florps/templates/post_content.htm: -------------------------------------------------------------------------------- 1 | {{ process_emoji(post.content, post.emojis)|safe }} 2 | {% if post.poll %} 3 | (Polls unsupported) 4 | {% endif %} 5 | {% for attachment in post.media_attachments %} 6 | 7 | 8 | 9 | {% endfor %} -------------------------------------------------------------------------------- /06_mastomash/templates/post_content.htm: -------------------------------------------------------------------------------- 1 | {{ process_emoji(post.content, post.emojis)|safe }} 2 | {% if post.poll %} 3 | (Polls unsupported) 4 | {% endif %} 5 | {% for attachment in post.media_attachments %} 6 | 7 | 8 | 9 | {% endfor %} -------------------------------------------------------------------------------- /07_florps/templates/post_list.htm: -------------------------------------------------------------------------------- 1 | {% if next_id != 0 %} 2 |
3 | {% endif %} 4 | {% for post in posts %} 5 | {% with post=post %} 6 | {% include "post.htm" %} 7 | {% endwith %} 8 | {% endfor %} 9 | {% if next_id == 0 %} 10 |
Loading more posts...
11 | {% endif %} 12 | -------------------------------------------------------------------------------- /07_florps/templates/button_boost.htm: -------------------------------------------------------------------------------- 1 | {% if not post.reblogged %} 2 |
3 | 4 | 5 |
6 | {% else %} 7 |
8 | 9 | 10 |
11 | {% endif %} -------------------------------------------------------------------------------- /07_florps/templates/post_actions.htm: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | {% include "button_fav.htm" %} 7 | {% include "button_boost.htm" %} 8 |
9 | 10 |
-------------------------------------------------------------------------------- /07_florps/templates/button_fav.htm: -------------------------------------------------------------------------------- 1 | 2 | {% if not post.favourited %} 3 |
4 | 5 | 6 |
7 | {% else %} 8 |
9 | 10 | 11 |
12 | {% endif %} -------------------------------------------------------------------------------- /09_hellomotoot/templates/index.html: -------------------------------------------------------------------------------- 1 | {% include 'header.html' %} 2 |
3 |

This app allows you to post toots to Mastodon using SMS messages or by calling! It's like twitter back in the day, except better, because it's not twitter!

4 |

Enter your full Mastodon username (user@domain.com) to begin:

5 |
6 |
7 | 8 | 9 |
10 |
11 |
12 | {% include 'footer.html' %} 13 | -------------------------------------------------------------------------------- /09_hellomotoot/templates/manage.html: -------------------------------------------------------------------------------- 1 | {% include 'header.html' %} 2 |
3 |

You're logged in as: {{ masto_user_full }}

4 | {% if phone_number != "" %} 5 |

Current phone number: {{ phone_number }}. You're set up for posting, just send your messages or give a call to {{ phone_reg_number }}!

6 | {% endif %} 7 |

To set a phone number, send a SMS with text --register-{{ phone_reg_code }} to {{ phone_reg_number }} or scan the following QR code if you're too lazy to type:

8 |
9 |

10 |

(boring qr code for bad phones)

11 |

log off

12 | {% include 'footer.html' %} 13 | -------------------------------------------------------------------------------- /07_florps/ui_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "post_verbs": ["bop"], 3 | "post_verbs_past": ["bopped"], 4 | "emoji_left": ["ᐊ"], 5 | "emoji_right": ["ᐅ"], 6 | "client_name": ["Florps"], 7 | "boost_verbs": ["amplify"], 8 | "boost_verbs_past": ["amplified"], 9 | "fav_verbs": ["florp"], 10 | "reply_verbs": ["respond"], 11 | "logged_in_phrases": ["you are:"], 12 | "log_out_phrases": ["Revoke"], 13 | "open_verbs": ["Show"], 14 | "other_stuff_phrases": ["Other Mastodon experiments"], 15 | "next_page_phrases": ["Next page"], 16 | "prev_page_phrases": ["Previous page"], 17 | "home_page_phrases": ["Home"], 18 | "login_verbs": ["Log in"], 19 | "blurbs": ["is... honestly, you already know, right?"], 20 | "enter_url_phrases": ["Enter the address of your instance to start"], 21 | "time_formats": ["SEPTEMBER"], 22 | "unboost_verbs": ["deamplify"], 23 | "unfav_verbs": ["unflorp"] 24 | } 25 | -------------------------------------------------------------------------------- /09_hellomotoot/templates/footer.html: -------------------------------------------------------------------------------- 1 |

Made quite quickly by @halcy using Mastodon.py, a library I (still @halcy) maintain · If you actually use this and want it to not shut down because twilio trial account cash ran out, consider throwing some money at me: ko-fi.com/h4lcy.

2 |

privacy statement: I store the data I need to store to provide the service, and none besides. Data goes through twilio for SMS processing where needed, and their processing of the data is subject to their privacy statement. This app is quite hacky - don't expect security best practices. Service is best-effort. Please don't break it - I will cry. I will not look at your data except to fix things when they break. I will delete your data if you ask me to (email: halcy@halcy.de). source code

3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /08_trunkshow/templates/post_list.htm: -------------------------------------------------------------------------------- 1 | {% for post in posts %} 2 | {% for attachment in post.media_attachments %} 3 | {% if attachment.type == "image" %} 4 |
5 | 6 | {% if attachment.remote_url is not none %} 7 | {{ attachment.description }} 8 | {% else %} 9 | {{ attachment.description }} 10 | {% endif %} 11 | 12 |
13 | {% endif %} 14 | {% endfor %} 15 | {% endfor %} 16 |
17 | 18 |
19 | {% if first_load == "yes" %} 20 | 26 | {% endif %} 27 | -------------------------------------------------------------------------------- /tooling/app_data_registry.py: -------------------------------------------------------------------------------- 1 | import secret_registry 2 | import os 3 | 4 | appdata_dir = os.path.abspath(secret_registry.db_path) + "/appdata/" 5 | if not os.path.exists(appdata_dir): 6 | os.makedirs(appdata_dir) 7 | 8 | def set_flask_session_info(app, app_prefix, prefix_secret = False): 9 | """ 10 | Set up a flask session with a certain session dir, and set the secret to a value read from env variables 11 | """ 12 | session_dir = appdata_dir + "session_" + app_prefix 13 | if not os.path.exists(session_dir): 14 | os.makedirs(session_dir) 15 | if prefix_secret == True: 16 | app.secret_key = app_prefix + os.environ["FLASK_SECRET"] 17 | else: 18 | app.secret_key = os.environ["FLASK_SECRET"] 19 | app.config["SESSION_TYPE"] = "filesystem" 20 | app.config["SESSION_FILE_DIR"] = session_dir 21 | 22 | def get_db_file(app_prefix): 23 | """ 24 | Return a file name for a database file 25 | """ 26 | return appdata_dir + "db_" + app_prefix + ".db" 27 | 28 | def get_state_file(app_prefix): 29 | """ 30 | Return a file name for a database file 31 | """ 32 | return appdata_dir + "state_" + app_prefix + ".pkl" 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # Secret files (for credentials used in testing) 92 | *.secret 93 | pytooter_clientcred.txt 94 | pytooter_usercred.txtPipfile 95 | Pipfile.lock 96 | Pipfile 97 | 98 | # pytest trash 99 | .pytest_cache 100 | 101 | *.secret 102 | *.db 103 | -------------------------------------------------------------------------------- /06_mastomash/templates/post_actions.htm: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | {% if not post.favourited %} 7 |
8 | 9 | 10 |
11 | {% else %} 12 |
13 | 14 | 15 |
16 | {% endif %} 17 | {% if not post.reblogged %} 18 |
19 | 20 | 21 |
22 | {% else %} 23 |
24 | 25 | 26 |
27 | {% endif %} 28 |
29 | 30 |
-------------------------------------------------------------------------------- /07_florps/templates/post.htm: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{ post.account.acct|lower }}
{{ process_emoji(post.account.display_name, post.account.emojis)|safe }}
4 | {{ post.account.display_name }} 5 | {% if not post.reblog %} 6 |
{{ ui("post_verbs_past") }}:
7 | {% else %} 8 |
{{ ui("boost_verbs_past") }}
9 | {{ post.reblog.account.display_name }} 10 |
{{ post.reblog.account.acct|lower }}
{{ process_emoji(post.reblog.account.display_name, post.reblog.account.emojis)|safe }}
11 | {% endif %} 12 |
{{ dateformat(post.created_at) }}
13 |
14 | {% if not post.reblog %} 15 |
16 | {% with post=post %} 17 | {% include "post_content.htm" %} 18 | {% endwith %} 19 |
20 | {% else %} 21 |
22 |
23 | {% with post=post.reblog %} 24 | {% include "post_content.htm" %} 25 | {% endwith %} 26 |
27 |
28 | {% endif %} 29 | {% if not post.reblog %} 30 | {% with post=post %} 31 | {% include "post_actions.htm" %} 32 | {% endwith %} 33 | {% else %} 34 | {% with post=post.reblog %} 35 | {% include "post_actions.htm" %} 36 | {% endwith %} 37 | {% endif %} 38 |
-------------------------------------------------------------------------------- /09_hellomotoot/templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello MoToot 4 | 5 | 6 | 59 |
60 |

Hello MoToot

61 | {% with messages = get_flashed_messages(with_categories=true) %} 62 | {% if messages %} 63 |
64 | {% for category, message in messages %} 65 |
{{ message }}
66 | {% endfor %} 67 |
68 | {% endif %} 69 | {% endwith %} 70 | -------------------------------------------------------------------------------- /tooling/login_oauth.py: -------------------------------------------------------------------------------- 1 | # Basic login example 2 | # Not at all performant, but simple 3 | 4 | import secret_registry 5 | import webbrowser 6 | import time 7 | import sys 8 | from mastodon import Mastodon 9 | 10 | # Usage printer 11 | if len(sys.argv) != 4: 12 | print("Usage: " + sys.argv[0] + " ") 13 | sys.exit() 14 | 15 | # Settings 16 | SCOPES_TO_REQUEST = ['read', 'write', 'follow', 'push'] 17 | CLIENT_NAME = sys.argv[1] 18 | CRED_PREFIX = sys.argv[2] 19 | SECRET = sys.argv[3] 20 | 21 | # Read instance from user 22 | instance = input("Enter mastodon instance name: ") 23 | 24 | # Lock credential DB 25 | secret_registry.lock_credentials() 26 | 27 | # Get app credential, register new if needed 28 | client_credential = secret_registry.get_name_for(CRED_PREFIX, SECRET, instance, "client") 29 | if not secret_registry.have_credential(client_credential): 30 | Mastodon.create_app( 31 | CLIENT_NAME, 32 | api_base_url = instance, 33 | scopes = SCOPES_TO_REQUEST, 34 | to_file = client_credential 35 | ) 36 | secret_registry.update_meta_time(client_credential) 37 | 38 | # Unlock credential DB 39 | secret_registry.release_credentials() 40 | 41 | # Start oauth flow 42 | api = Mastodon(client_id = client_credential) 43 | oauth_url = api.auth_request_url(scopes = SCOPES_TO_REQUEST) 44 | print(oauth_url) 45 | webbrowser.open_new(oauth_url) 46 | time.sleep(3) 47 | print("\n\n") 48 | 49 | # Get oauth code from user and log in 50 | oauth_code = input("After logging in, enter the code you received: ") 51 | 52 | # Lock credential DB 53 | secret_registry.lock_credentials() 54 | 55 | # Log in 56 | user_credential_temp = secret_registry.get_name_for(CRED_PREFIX, SECRET, instance, "user", oauth_code) 57 | api.log_in( 58 | code = oauth_code, 59 | to_file = user_credential_temp 60 | ) 61 | secret_registry.update_meta_time(user_credential_temp) 62 | 63 | # Move to final place 64 | user_name = api.me().acct 65 | user_credential_final = secret_registry.get_name_for(CRED_PREFIX, SECRET, instance, "user", user_name) 66 | secret_registry.move_credential(user_credential_temp, user_credential_final) 67 | 68 | # Unlock credential DB 69 | secret_registry.release_credentials() 70 | -------------------------------------------------------------------------------- /01_latency_observatory/latency_web.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import datetime 3 | 4 | from latencies import LatencyWatcher 5 | 6 | # Settings 7 | accounts = [ 8 | ("halcy", "mastodon.social"), 9 | ("halcy", "glitch.social"), 10 | ("latencyobs", "icosahedron.website"), 11 | ] 12 | 13 | watcher = LatencyWatcher(accounts) 14 | watcher.start() 15 | 16 | HEAD = """ 17 | 18 | 19 | Mastodon Latency Observatory 20 | 21 | 22 | 48 |

Mastodon Latency Observatory

49 | """ 50 | 51 | FOOT = """ 52 | source code 53 | 54 | """ 55 | app = Flask(__name__) 56 | @app.route('/') 57 | def base_page(): 58 | # Get info from watcher 59 | latency_info = watcher.get_latencies() 60 | 61 | # Head 62 | response = "" 63 | for account in accounts: 64 | response += "" 65 | response += "" 66 | 67 | # Rows 68 | for account in accounts: 69 | response += "" 70 | for account2 in accounts: 71 | if account == account2: 72 | response += "" 73 | else: 74 | latency_data = latency_info[account2][account] 75 | lat_str = str(round(latency_data[0] * 1000, 2)) + "ms" 76 | lat_date = datetime.datetime.fromtimestamp(latency_data[1]) 77 | lat_date_str = lat_date.astimezone(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S %Z') 78 | response += "" 79 | response += "" 80 | 81 | # Foot 82 | response += "
to " + account[1] + "
from " + account[1] + "" + lat_str + "
at " + lat_date_str + " UTC
" 83 | return HEAD + response + FOOT 84 | 85 | if __name__ == '__main__': 86 | app.run() 87 | -------------------------------------------------------------------------------- /03_clippy/templates/authed.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 65 | 66 | 67 |
68 |

Logged in as {{account}}.

69 |

You are constantly being monitored.

70 |

71 | {% if processing %} 72 | Still processing, check back in a bit. 73 | {% else %} 74 | Observe: 75 |

    76 | {% for text, href in links %} 77 |
  • {{ text }}
  • 78 | {% endfor %} 79 |
80 | Updates are performed approximately every three hours, more frequent when less than 7 suggestions are available. Follow mode may fail to add users to the list if they have their account locked. 81 | {% endif %} 82 |

83 | 84 | 85 |
86 | 87 | 88 | -------------------------------------------------------------------------------- /07_florps/templates/login.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 73 | 74 | 75 |
76 |

{{ ui("emoji_left") }}{{ ui("emoji_right") }} | {{ ui("client_name") }}

77 | 78 | 79 |
80 |

{{ ui("client_name") }} {{ ui("blurbs") }}

81 |
82 | 83 |

84 | not-autoreplaced privacy statement: I store the data I need to store to provide the service, and none besides. 85 | I will not look at your data except to fix things when they break. 86 | You can delete your data by pressing the button in the top right. 87 | This is a hobbyist project, service is best-effort. 88 | Source code available. Other Mastodon API experiments. 89 |

90 | 91 | 92 | -------------------------------------------------------------------------------- /06_mastomash/templates/login.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 73 | 74 | 75 |
76 |

{{ ui("emoji_left") }}{{ ui("emoji_right") }} | {{ ui("client_name") }}

77 | 78 | 79 |
80 |

{{ ui("client_name") }} {{ ui("blurbs") }}

81 |
82 | 83 |

84 | not-autoreplaced privacy statement: I store the data I need to store to provide the service, and none besides. 85 | I will not look at your data except to fix things when they break. 86 | You can delete your data by pressing the button in the top right. 87 | This is a hobbyist project, service is best-effort. 88 | Source code available. Other Mastodon API experiments. 89 |

90 | 91 | 92 | -------------------------------------------------------------------------------- /08_trunkshow/templates/enter.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 73 | 74 | 75 |
76 |

🖼️🐘 | Trunkshow

77 |

instance:

78 |

tag or username:

79 | 80 |
81 |

Trunkshow shows you a gallery of images from a certain instance that are tagged with the given tag, updating dynamically as new images are posted.

82 |
83 | 84 |

85 | Privacy statement: I store the data I need to store to provide the service, and none besides. 86 | I will not look at your data except to fix things when they break. 87 | This is a hobbyist project, service is best-effort. 88 | Source code available. Other Mastodon API experiments. 89 |

90 | 91 | 92 | -------------------------------------------------------------------------------- /04_alphant/templates/login.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 73 | 74 | 75 |
76 |

α🐘 | alphant

77 | 78 | 79 |
80 |

Alphant is an experimental Mastodon client that shows you posts by the people you follow sorted alphabetically by peoples usernames. 81 | You can look at the posts, and you can write replies and post top level posts. Ascending and descending switch every hour.

82 |
83 | 84 |

85 | privacy statement: I store the data I need to store to provide the service, and none besides, which in this case is: your account name, the last post anyone on your home TL made. 86 | I will not look at your data except to fix things when they break. 87 | You can delete your data by pressing the "Revoke" button. 88 | This is a hobbyist project, service is best-effort. 89 | Source code available. Other Mastodon API experiments. 90 |

91 | 92 | 93 | -------------------------------------------------------------------------------- /03_clippy/templates/login.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 73 | 74 | 75 |
76 |

C L I P P Y

77 | 78 | 79 |
80 |

Clippy is an application that looks at your 100 newest post, calculates CLIP text embeddings for them, clusters them 81 | into three clusters (because as we all know, no one talks about more than threee things), then picks some users weighted towards those with similar embeddings and 82 | shows them to you. Optionally, it can also automatically follow these users if you aren't already (and put them in a list for easy review).

83 |
84 | 85 |

86 | privacy statement: I store the data I need to store to provide the service, and none besides. 87 | I will not look at your data except to fix things when they break. 88 | You can delete your data by pressing the "Revoke" button. 89 | This is a hobbyist project, service is best-effort. 90 | Source code available. 91 |

92 | 93 | 94 | -------------------------------------------------------------------------------- /02_nicer_latency_observatory/latency_web.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, make_response, request 2 | import datetime 3 | 4 | from latencies import LatencyWatcher 5 | 6 | # Settings 7 | accounts = [ 8 | ("halcy", "mastodon.social"), 9 | ("halcy", "glitch.social"), 10 | ("latencyobs", "icosahedron.website"), 11 | #("latencyobs", "botsin.space"), 12 | ("halcy", "hachyderm.io"), 13 | ] 14 | 15 | watcher = LatencyWatcher(accounts) 16 | watcher.start() 17 | 18 | HEAD = """ 19 | 20 | 21 | Mastodon Latency Observatory 22 | 23 | 24 | 55 |

⟴ Mastodon Latency Observatory on github

56 | """ 57 | 58 | FOOT = """ 59 | 60 | """ 61 | app = Flask(__name__) 62 | @app.route('/') 63 | def base_page(): 64 | # Get info from watcher 65 | latency_info = watcher.get_latencies() 66 | 67 | # Head 68 | response = "" 69 | for account in accounts: 70 | response += "" 71 | response += "" 72 | 73 | # Rows 74 | for id1, account in enumerate(accounts): 75 | response += "" 76 | for id2, account2 in enumerate(accounts): 77 | if account == account2: 78 | response += "" 79 | else: 80 | latency_data = latency_info[account2][account] 81 | lat_str = str(round(latency_data[0] * 1000, 2)) + "ms" 82 | lat_date = datetime.datetime.fromtimestamp(latency_data[1]) 83 | lat_date_str = lat_date.astimezone(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S %Z') 84 | alt_text = lat_str + " at " + lat_date_str 85 | response += '" 87 | 88 | # Foot 89 | response += "
to " + account[1] + "
from " + account[1] + "{}'.format(str(id1), str(id2), alt_text) 86 | response += "
" 90 | return HEAD + response + FOOT 91 | 92 | @app.route('/plot', methods=['GET']) 93 | def send_png(): 94 | args = request.args 95 | account = accounts[min(max(0, int(args.get("acc2"))), len(accounts) - 1)] 96 | account2 = accounts[min(max(0, int(args.get("acc1"))), len(accounts) - 1)] 97 | binary_data = watcher.get_latencies_graph(account, account2) 98 | resp = make_response(binary_data) 99 | resp.headers['Content-Type'] = "image/png" 100 | return resp 101 | 102 | if __name__ == '__main__': 103 | app.run("0.0.0.0") 104 | -------------------------------------------------------------------------------- /08_trunkshow/trunkshow.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import logging 5 | 6 | from flask import Flask, session, render_template, redirect, request, abort 7 | 8 | sys.path.append("../tooling/") 9 | import secret_registry 10 | import app_data_registry 11 | import validators 12 | 13 | from mastodon import Mastodon 14 | 15 | # Settings 16 | CLIENT_NAME = "Trunkshow" 17 | APP_PREFIX = "day08_trunkshow" 18 | MASTO_SECRET = os.environ["MASTODON_SECRET"] 19 | SCOPES_TO_REQUEST = ["read", "write"] 20 | APP_BASE_URL = "/day08/" 21 | 22 | # Logging setup 23 | logging.basicConfig( 24 | stream = sys.stdout, 25 | format = "%(levelname)s %(asctime)s - %(message)s", 26 | level = logging.INFO 27 | ) 28 | 29 | # Set the secret key to a random value 30 | app = Flask(__name__) 31 | app_data_registry.set_flask_session_info(app, APP_PREFIX, True) 32 | 33 | # Instance URL normalizer 34 | def norm_instance_url(instance): 35 | # Try to be permissive but also paranoid 36 | if "://" in instance: 37 | instance = "://".join(instance.split("://")[1:]) 38 | if "/" in instance: 39 | instance = instance.split("/")[0] 40 | if not validators.url("https://" + instance): 41 | abort(500) 42 | return instance 43 | 44 | # Gets a client credential, creates app if needed 45 | def get_client_credential(instance): 46 | # For registry, add https:// 47 | instance = "https://" + instance 48 | 49 | # Lock credential DB 50 | secret_registry.lock_credentials() 51 | 52 | try: 53 | # Get app credential, register new if needed 54 | client_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "client") 55 | if not secret_registry.have_credential(client_credential): 56 | Mastodon.create_app( 57 | CLIENT_NAME, 58 | api_base_url = instance, 59 | scopes = SCOPES_TO_REQUEST, 60 | to_file = client_credential, 61 | ) 62 | secret_registry.update_meta_time(client_credential) 63 | except: 64 | client_credential = None 65 | 66 | # Unlock credential DB 67 | secret_registry.release_credentials() 68 | 69 | return client_credential 70 | 71 | @app.route('/') 72 | def index(): 73 | # Get args 74 | instance = request.args.get('instance') 75 | what = request.args.get('what') 76 | 77 | # Check if the user is already logged in 78 | if instance is None or what is None: 79 | return render_template('enter.htm') 80 | else: 81 | session["instance"] = instance 82 | session["what"] = what 83 | return render_template('gallery.htm') 84 | 85 | @app.route('/posts') 86 | def posts(): 87 | # Get api 88 | instance = session["instance"] 89 | api = Mastodon(client_id = get_client_credential(instance)) 90 | 91 | # Get posts 92 | what = session["what"] 93 | is_tag = False 94 | local_tag = None 95 | if what.startswith("#"): 96 | what = what[1:] 97 | is_tag = True 98 | if what.startswith("#"): 99 | is_tag = True 100 | what = what[1:] 101 | local_tag = True 102 | elif what.startswith("@"): 103 | what = what[1:] 104 | is_tag = False 105 | else: 106 | is_tag = True 107 | if is_tag: 108 | posts = api.timeline_hashtag(what, only_media = True, local = local_tag) 109 | else: 110 | account = api.account_lookup(what) 111 | posts = api.account_statuses(account, only_media = True) 112 | return render_template( 113 | 'post_list.htm', 114 | posts = posts, 115 | first_load = request.args.get('first_load') 116 | ) 117 | 118 | if __name__ == '__main__': 119 | # Run webapp 120 | app.run("0.0.0.0", debug = True) 121 | -------------------------------------------------------------------------------- /01_latency_observatory/latencies.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import time 4 | import logging 5 | import threading 6 | import pickle 7 | from functools import partial 8 | 9 | sys.path.append("../tooling/") 10 | import secret_registry 11 | 12 | from mastodon import Mastodon, streaming 13 | 14 | # Hardcoded settings 15 | TIME_BETWEEN_PINGS = 60 * 5 16 | TIME_BEFORE_DELETE = 10 17 | SECRET = os.environ["MASTODON_SECRET"] 18 | LATENCY_TEST_FRAME = "____________LATENCYPING____________" 19 | 20 | class LatencyWatcher(): 21 | def __init__(self, accounts): 22 | # Store parameters 23 | self.accounts = accounts 24 | 25 | # Logging setup 26 | logging.basicConfig( 27 | stream = sys.stdout, 28 | format = "%(levelname)s %(asctime)s - %(message)s", 29 | level = logging.INFO 30 | ) 31 | 32 | # Storage for latencies 33 | self.latency_info = {} 34 | for account in self.accounts: 35 | self.latency_info[account] = {} 36 | for account2 in self.accounts: 37 | self.latency_info[account][account2] = (100000, 0) 38 | 39 | # Log in 40 | self.apis = {} 41 | self.mention_strings = {} 42 | for account in self.accounts: 43 | cred_file = secret_registry.get_name_for("day01latencyobs", SECRET, account[1], "user", account[0]) 44 | self.apis[account] = Mastodon(access_token = cred_file) 45 | 46 | mention_str = "" 47 | for account2 in self.accounts: 48 | if account2 != account: 49 | mention_str = mention_str + "@" + account2[0] + "@" + account2[1] + " " 50 | self.mention_strings[account] = mention_str 51 | 52 | logging.info("Logged into " + str(account) + " = " + self.apis[account].me().acct) 53 | 54 | def start(self): 55 | # Write workers 56 | def write_worker(account): 57 | api = self.apis[account] 58 | mention_str = self.mention_strings[account] 59 | while True: 60 | prev_status = None 61 | try: 62 | logging.info("Attempting post for " + str(account)) 63 | prev_status = api.status_post( 64 | status = mention_str + LATENCY_TEST_FRAME + str(time.time()) + LATENCY_TEST_FRAME, 65 | visibility = "direct" 66 | ) 67 | except Exception as e: 68 | logging.warn("Status post failed for " + str(account) + ", reason was " + str(e)) 69 | time.sleep(TIME_BEFORE_DELETE) 70 | try: 71 | api.status_delete(prev_status) 72 | except Exception as e: 73 | logging.warn("Status delete failed for " + str(account) + ", reason was " + str(e)) 74 | time.sleep(TIME_BETWEEN_PINGS) 75 | 76 | # Latency logger using streaming API 77 | def log_latency(account, notification): 78 | try: 79 | now = time.time() 80 | if notification.type == "mention": 81 | text = notification.status.content 82 | if LATENCY_TEST_FRAME in text: 83 | latency_time = float(text.split(LATENCY_TEST_FRAME)[1]) 84 | latency = now - latency_time 85 | account2 = tuple(notification.status.account.acct.split("@")) 86 | logging.info("New read for " + str(account) + " from " + str(account2) + " -> " + str(latency)) 87 | self.latency_info[account][account2] = (latency, now) 88 | except Exception as e: 89 | logging.warn("Failed to log latency for " + str(account) + ", reason was " + str(e)) 90 | 91 | # Start readers 92 | self.readers = {} 93 | for account in self.accounts: 94 | logging.info("Starting reader for " + str(account)) 95 | listener = streaming.CallbackStreamListener( 96 | notification_handler = partial(log_latency, account) 97 | ) 98 | self.readers[account] = self.apis[account].stream_user( 99 | listener, 100 | run_async = True, 101 | reconnect_async = True 102 | ) 103 | 104 | # Wait a moment 105 | time.sleep(1) 106 | 107 | # Start writers 108 | self.writers = {} 109 | for account in self.accounts: 110 | self.writers[account] = threading.Thread(target=write_worker, args=(account,)) 111 | self.writers[account].start() 112 | 113 | def get_latencies(self): 114 | return self.latency_info 115 | -------------------------------------------------------------------------------- /04_alphant/templates/authed.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 136 | 137 | 138 |
139 |

α🐘 | Logged in as {{account}}

140 |
141 | 142 |
143 | Other Mastodon API experiments. 144 |
145 | 146 |
147 |
148 |
149 | {% for post in posts %} 150 |
151 |
152 | {{ post.account.display_name }} 153 |
{{ post.account.acct|lower }}
{{ process_emoji(post.account.display_name, post.account.emojis)|safe }}
154 |
155 |
156 | {{ process_emoji(post.content, post.emojis)|safe }} 157 | {% for attachment in post.media_attachments %} 158 | 159 | 160 | 161 | {% endfor %} 162 |
163 |
164 | 165 | 166 |
167 |
168 | {% endfor %} 169 |
170 | 171 | 172 | -------------------------------------------------------------------------------- /08_trunkshow/templates/gallery.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 120 | 121 | 147 | 148 | 149 | 150 |
151 |
152 |
153 | Loading... 154 |
155 |
156 | 157 | 158 | -------------------------------------------------------------------------------- /07_florps/templates/authed.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 171 | 172 | 173 |
174 |
175 |

{{ ui("emoji_left") }}{{ ui("emoji_right") }} | {{ ui("logged_in_phrases") }} {{account}}

176 | {{ ui("other_stuff_phrases")|capitalize }} 177 |
178 | 179 |
180 |
181 |
182 | {% include "form_post.htm" %} 183 |
184 |
185 |
186 |
187 | {% with posts=posts %} 188 | {% include "post_list.htm" %} 189 | {% endwith %} 190 |
Loading more posts...
191 |
192 |
193 | 194 | 195 | -------------------------------------------------------------------------------- /tooling/secret_registry.py: -------------------------------------------------------------------------------- 1 | # Simple file based manager for mastodon secrets 2 | # Note that these are still stored in plaintext here, which is maybe not ideal if you're hosting a real service 3 | # This is more intended for experimental stuff 4 | 5 | import time 6 | import hashlib 7 | import os 8 | import logging 9 | import glob 10 | import locket 11 | 12 | from mastodon import Mastodon 13 | 14 | global_secret = os.environ["MASTODON_GLOBAL_SECRET"] 15 | if global_secret is None or len(global_secret.strip()) == 0: 16 | raise Exception("Need to have a MASTODON_GLOBAL_SECRET env var") 17 | 18 | db_path = os.path.abspath(os.path.dirname(os.path.realpath(__file__)) + "/../../") + "/secrets/" 19 | if not os.path.exists(db_path): 20 | os.makedirs(db_path) 21 | 22 | credential_lock_file = db_path + ".lock" 23 | if not os.path.exists(credential_lock_file): 24 | with open(credential_lock_file, 'w') as f: 25 | f.write("") 26 | credential_lock = locket.lock_file(credential_lock_file) 27 | 28 | def get_name_for(prefix, secret, instance, cred_type, user = None): 29 | """ 30 | Get a name for a credential file for the given object 31 | """ 32 | if instance.startswith("http://"): 33 | instance = "http://".join(instance.split("http://")[1:]) 34 | elif instance.startswith("https://"): 35 | instance = "https://".join(instance.split("https://")[1:]) 36 | 37 | if not cred_type in ["client", "user"]: 38 | raise Exception("Type must be Client or User") 39 | 40 | if cred_type == "client" and user is not None: 41 | raise Exception("Client credential must not have user") 42 | 43 | if cred_type == "user" and user is None: 44 | raise Exception("User credential must have user") 45 | 46 | if "@@" in instance or (user is not None and "@@" in user): 47 | raise Exception("No double-@ allowed in instance or user.") 48 | 49 | if len(secret) == 0 or len(instance) == 0 or (user is not None and len(user) == 0): 50 | raise Exception("No zero length strings allowed") 51 | 52 | key = global_secret + "@@@\n" + secret + "@@@\n" + cred_type + "@@@\n" + instance 53 | if cred_type == "user": 54 | key += "@@@\n" + user 55 | 56 | return db_path + prefix + "_" + cred_type + "_" + hashlib.sha256(key.encode("utf-8")).hexdigest() + ".secret" 57 | 58 | def lock_credentials(): 59 | """ 60 | Acquire database lock 61 | """ 62 | credential_lock.acquire() 63 | 64 | def release_credentials(): 65 | """ 66 | Release database lock 67 | """ 68 | credential_lock.release() 69 | 70 | def move_credential(from_name, to_name): 71 | """ 72 | Move a credential file 73 | """ 74 | if not from_name.startswith(db_path) or not from_name.endswith(".secret"): 75 | raise Exception("Invalid name") 76 | if not to_name.startswith(db_path) or not to_name.endswith(".secret"): 77 | raise Exception("Invalid name") 78 | os.rename(from_name, to_name) 79 | os.rename(from_name + "_meta.secret", to_name + "_meta.secret") 80 | 81 | def have_credential(name): 82 | """ 83 | Just test if file exists 84 | """ 85 | if not name.startswith(db_path) or not name.endswith(".secret"): 86 | raise Exception("Invalid name") 87 | return os.path.isfile(name) 88 | 89 | def revoke_credential(name): 90 | """ 91 | Log in with a credential, revoke it if possible, delete the file 92 | """ 93 | if not name.startswith(db_path) or not name.endswith(".secret"): 94 | raise Exception("Invalid name") 95 | try: 96 | api = Mastodon(access_token = name) 97 | api.revoke_access_token() 98 | except: 99 | logging.warn(f"Could not revoke token {name}") 100 | try: 101 | os.remove(name) 102 | os.remove(name + "_meta.secret") 103 | except: 104 | logging.warn(f"Error while deleting token {name}") 105 | 106 | def revoke_all_older_than(prefix, cred_type, when = None): 107 | """ 108 | Revoke all credentials of a certain type older than some time 109 | """ 110 | if not cred_type in ["client", "user"]: 111 | raise Exception("Type must be Client or User") 112 | 113 | # No time given -> revoke all before now, i.e. all 114 | if when is None: 115 | when = time.time() 116 | 117 | # Go through files and revoke 118 | names = glob.glob(db_path + prefix + "_" + cred_type + "*.secret") 119 | for name in names: 120 | if name.endswith("_meta.secret"): 121 | continue 122 | meta_name = name + "_meta.secret" 123 | mod_time = 0 124 | 125 | # Extra paranoid programming 126 | try: 127 | with open(meta_name, 'r') as f: 128 | mod_time = float(f.read().replace("\n", "").strip()) 129 | except: 130 | mod_time = 0 131 | should_revoke = True 132 | try: 133 | should_revoke = mod_time is None or mod_time < when 134 | except: 135 | pass 136 | if should_revoke: 137 | try: 138 | revoke_credential(name) 139 | except: 140 | logging.warn(f"Error when revoking credential {name}") 141 | 142 | def update_meta_time(name): 143 | """ 144 | Set "creation" time for a credential file 145 | """ 146 | with open(name + "_meta.secret", 'w') as f: 147 | f.write(str(time.time())) 148 | -------------------------------------------------------------------------------- /05_toot_it_forward/templates/login.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 133 | 134 | 135 |
136 |
137 |

🐘➠ | toot-it-forward

138 | 139 | 140 | 141 |
142 |

Submit a post to toot-it-forward and it will be posted to the next person to submit a post, while the last persons submitted post will be posted 143 | to your account at the same time. You won't know who your received post came from and where your submitted post goes to until 24 hours later, at which time posts submitted will be listed below 144 | for 24 hours. Please don't be rude.

145 |
146 |
147 |

Day-old posts:

148 | {% for post in posts %} 149 | 150 |
151 |
152 |
{{ post.post_from.acct|lower }}
{{ process_emoji(post.post_from.display_name, post.post_from.emojis)|safe }}
153 | {{ post.post_from.display_name }} 154 |
155 | {{ post.account.display_name }} 156 |
{{ post.account.acct|lower }}
{{ process_emoji(post.account.display_name, post.account.emojis)|safe }}
157 |
158 |
166 |
167 | 168 | {% endfor %} 169 |
170 | 171 |

172 | privacy statement: I store the data I need to store to provide the service, and none besides, which in this case is: your account name, the post you submitted. 173 | I will not look at your data except to fix things when they break or to investigate complaints. You can contact me at halcy@icosahedron.webiste. 174 | This is a hobbyist project, service is best-effort. 175 | Source code available. Other Mastodon API experiments. 176 |

177 | 178 | 179 | -------------------------------------------------------------------------------- /06_mastomash/templates/authed.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 157 | 158 | 159 |
160 |
161 |

{{ ui("emoji_left") }}{{ ui("emoji_right") }} | {{ ui("logged_in_phrases") }} {{account}}

162 | {{ ui("other_stuff_phrases")|capitalize }} 163 |
164 | 165 |
166 |
167 |
168 |
169 | 170 | 171 |
172 |
173 | {% if posts._pagination_prev["min_id"] %} 174 |
175 | 176 | 177 |
178 | {% endif %} 179 |
180 | 181 |
182 | {% if posts._pagination_next["max_id"] %} 183 |
184 | 185 | 186 |
187 | {% endif %} 188 |
189 |
190 |
191 | {% for post in posts %} 192 |
193 |
194 |
{{ post.account.acct|lower }}
{{ process_emoji(post.account.display_name, post.account.emojis)|safe }}
195 | {{ post.account.display_name }} 196 | {% if not post.reblog %} 197 |
{{ ui("post_verbs_past") }}:
198 | {% else %} 199 |
{{ ui("boost_verbs_past") }}
200 | {{ post.reblog.account.display_name }} 201 |
{{ post.reblog.account.acct|lower }}
{{ process_emoji(post.reblog.account.display_name, post.reblog.account.emojis)|safe }}
202 | {% endif %} 203 |
{{ dateformat(post.created_at) }}
204 |
205 | {% if not post.reblog %} 206 |
207 | {% with post=post %} 208 | {% include "post_content.htm" %} 209 | {% endwith %} 210 |
211 | {% else %} 212 |
213 |
214 | {% with post=post.reblog %} 215 | {% include "post_content.htm" %} 216 | {% endwith %} 217 |
218 |
219 | {% endif %} 220 | {% if not post.reblog %} 221 | {% with post=post %} 222 | {% include "post_actions.htm" %} 223 | {% endwith %} 224 | {% else %} 225 | {% with post=post.reblog %} 226 | {% include "post_actions.htm" %} 227 | {% endwith %} 228 | {% endif %} 229 |
230 | {% endfor %} 231 |
232 | 233 | 234 | -------------------------------------------------------------------------------- /02_nicer_latency_observatory/latencies.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import time 4 | import logging 5 | import threading 6 | import numpy as np 7 | import matplotlib.pyplot as plt 8 | from functools import partial 9 | import datetime 10 | import matplotlib as mpl 11 | import matplotlib.dates as mdates 12 | import io 13 | import copy 14 | import traceback 15 | 16 | sys.path.append("../tooling/") 17 | import secret_registry 18 | 19 | from mastodon import Mastodon, streaming 20 | 21 | # Hardcoded settings 22 | TIME_BETWEEN_PINGS = 60 * 5 23 | TIME_BEFORE_DELETE = 10 24 | SECRET = os.environ["MASTODON_SECRET"] 25 | LATENCY_TEST_FRAME = "____________LATENCYPING____________" 26 | LATENCY_CACHE_MAX = 7 * 24 * (60 * 60) // TIME_BETWEEN_PINGS 27 | LATENCY_CACHE_MEAN_OVER_LAST = 3 * (60 * 60) // TIME_BETWEEN_PINGS 28 | 29 | class LatencyWatcher(): 30 | def __init__(self, accounts): 31 | # Store parameters 32 | self.accounts = accounts 33 | 34 | # Logging setup 35 | logging.basicConfig( 36 | stream = sys.stdout, 37 | format = "%(levelname)s %(asctime)s - %(message)s", 38 | level = logging.INFO 39 | ) 40 | 41 | # Storage for latencies 42 | self.latency_info = {} 43 | self.latency_info_cached = {} 44 | self.latency_plot_cached = {} 45 | self.latency_dirty = {} 46 | self.latency_dirty_any = True 47 | for account in self.accounts: 48 | self.latency_info[account] = {} 49 | self.latency_info_cached[account] = {} 50 | self.latency_dirty[account] = {} 51 | self.latency_plot_cached[account] = {} 52 | for account2 in self.accounts: 53 | self.latency_info[account][account2] = [] 54 | self.latency_info_cached[account][account2] = (10000, 0) 55 | self.latency_dirty[account][account2] = True 56 | self.latency_plot_cached[account][account2] = None 57 | 58 | # Log in 59 | self.apis = {} 60 | self.mention_strings = {} 61 | for account in self.accounts: 62 | cred_file = secret_registry.get_name_for("day01latencyobs", SECRET, account[1], "user", account[0]) 63 | self.apis[account] = Mastodon(access_token = cred_file) 64 | 65 | mention_str = "" 66 | for account2 in self.accounts: 67 | if account2 != account: 68 | mention_str = mention_str + "@" + account2[0] + "@" + account2[1] + " " 69 | self.mention_strings[account] = mention_str 70 | 71 | logging.info("Logged into " + str(account) + " = " + self.apis[account].me().acct) 72 | 73 | def start(self): 74 | # Write workers 75 | def write_worker(account): 76 | api = self.apis[account] 77 | mention_str = self.mention_strings[account] 78 | while True: 79 | prev_status = None 80 | try: 81 | logging.info("Attempting post for " + str(account)) 82 | prev_status = api.status_post( 83 | status = mention_str + LATENCY_TEST_FRAME + str(time.time()) + LATENCY_TEST_FRAME, 84 | visibility = "direct" 85 | ) 86 | except Exception as e: 87 | logging.warn("Status post failed for " + str(account) + ", reason was " + str(e)) 88 | time.sleep(TIME_BEFORE_DELETE) 89 | try: 90 | api.status_delete(prev_status) 91 | except Exception as e: 92 | logging.warn("Status delete failed for " + str(account) + ", reason was " + str(e)) 93 | time.sleep(TIME_BETWEEN_PINGS) 94 | 95 | # Latency logger using streaming API 96 | def log_latency(account, notification): 97 | try: 98 | now = time.time() 99 | if notification.type == "mention": 100 | text = notification.status.content 101 | if LATENCY_TEST_FRAME in text: 102 | latency_time = float(text.split(LATENCY_TEST_FRAME)[1]) 103 | latency = now - latency_time 104 | account2 = tuple(notification.status.account.acct.split("@")) 105 | logging.info("New read for " + str(account) + " from " + str(account2) + " -> " + str(latency)) 106 | self.latency_info[account][account2].append((latency, now)) 107 | self.latency_info[account][account2] = self.latency_info[account][account2][-LATENCY_CACHE_MAX:] 108 | self.latency_dirty[account][account2] = True 109 | self.latency_dirty_any = True 110 | except Exception as e: 111 | logging.warn("Failed to log latency for " + str(account) + ", reason was " + str(e)) 112 | 113 | # Start readers 114 | self.readers = {} 115 | for account in self.accounts: 116 | logging.info("Starting reader for " + str(account)) 117 | listener = streaming.CallbackStreamListener( 118 | notification_handler = partial(log_latency, account) 119 | ) 120 | self.readers[account] = self.apis[account].stream_user( 121 | listener, 122 | run_async = True, 123 | reconnect_async = True 124 | ) 125 | 126 | # Wait a moment 127 | time.sleep(1) 128 | 129 | # Start writers 130 | self.writers = {} 131 | for account in self.accounts: 132 | self.writers[account] = threading.Thread(target=write_worker, args=(account,)) 133 | self.writers[account].start() 134 | 135 | def get_latencies(self): 136 | # Update cache if dirty 137 | if self.latency_dirty_any: 138 | self.latency_dirty_any = False 139 | for account in self.accounts: 140 | for account2 in self.accounts: 141 | if len(self.latency_info[account][account2]) > 0: 142 | self.latency_info_cached[account][account2] = ( 143 | np.mean(list(map(lambda x: x[0], self.latency_info[account][account2][-LATENCY_CACHE_MEAN_OVER_LAST:]))), 144 | self.latency_info[account][account2][-1][1] 145 | ) 146 | else: 147 | self.latency_info_cached[account][account2] = (10000, 0) 148 | 149 | # Return cached 150 | return self.latency_info_cached 151 | 152 | def get_latencies_graph(self, account, account2): 153 | if self.latency_dirty[account][account2] or self.latency_plot_cached[account][account2] is None: 154 | try: 155 | # Refresh cache if needed 156 | COLOR = 'white' 157 | mpl.rcParams['text.color'] = COLOR 158 | mpl.rcParams['axes.labelcolor'] = COLOR 159 | mpl.rcParams['xtick.color'] = COLOR 160 | mpl.rcParams['ytick.color'] = COLOR 161 | mpl.rcParams['axes.edgecolor'] = COLOR 162 | 163 | latency_val = self.get_latencies()[account][account2][0] 164 | self.latency_dirty[account][account2] = False 165 | latency_copy = copy.deepcopy(self.latency_info) 166 | 167 | data_x = list(map(lambda x: datetime.datetime.fromtimestamp(x[1]).astimezone(datetime.timezone.utc), latency_copy[account][account2])) 168 | data_y = list(map(lambda x: x[0] * 1000, latency_copy[account][account2])) 169 | 170 | # Basic matplotlib plot 171 | fig = plt.figure(figsize=(3.5, 3.5)) 172 | ax = fig.add_subplot(111) 173 | ax.plot(data_x, data_y, marker="o", color=COLOR) 174 | ax.set_title("{} ->\n{}\nMean[50]: {}ms".format(account[1], account2[1], str(round(latency_val * 1000, 2)) + "ms" )) 175 | ax.set_xlabel("Time") 176 | ax.set_ylabel("Latency [ms]") 177 | ax.xaxis.set_major_formatter(mdates.DateFormatter('%b%d\n%H:%M')) 178 | ax.set_xticks(list(map( 179 | lambda x: datetime.datetime.fromtimestamp(x).astimezone(datetime.timezone.utc), 180 | np.linspace(data_x[0].timestamp(), data_x[-1].timestamp(), 5) 181 | ))) 182 | ax.set_xlim(data_x[0], data_x[-1]) 183 | fig.tight_layout() 184 | 185 | # Save the figure to a PNG file in memory 186 | buf = io.BytesIO() 187 | fig.savefig(buf, format='png', transparent=True, bbox_inches=0) 188 | 189 | # Get the PNG image data from the BytesIO object 190 | self.latency_plot_cached[account][account2] = buf.getvalue() 191 | except Exception as e: 192 | traceback.print_exc() 193 | return self.latency_plot_cached[account][account2] 194 | -------------------------------------------------------------------------------- /05_toot_it_forward/tootitforward.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import json 5 | import logging 6 | import traceback 7 | import uuid 8 | import pickle 9 | 10 | from flask import Flask, session, render_template, redirect, request, abort, g 11 | import sqlite3 12 | 13 | sys.path.append("../tooling/") 14 | import secret_registry 15 | import app_data_registry 16 | import validators 17 | 18 | from mastodon import Mastodon, streaming 19 | 20 | # Settings 21 | CLIENT_NAME = "TootItForward" 22 | APP_PREFIX = "day05_tootitforward_r1" 23 | MASTO_SECRET = os.environ["MASTODON_SECRET"] 24 | SCOPES_TO_REQUEST = ["read:accounts", "read:statuses", "write:statuses"] 25 | SCOPES_FALLBACK = ["read", "write"] 26 | OAUTH_TARGET_URL = "https://mastolab.kal-tsit.halcy.de/day05/auth" 27 | APP_BASE_URL = "/day05/" 28 | 29 | # Logging setup 30 | logging.basicConfig( 31 | stream = sys.stdout, 32 | format = "%(levelname)s %(asctime)s - %(message)s", 33 | level = logging.INFO 34 | ) 35 | 36 | # Set the secret key to a random value 37 | app = Flask(__name__) 38 | app_data_registry.set_flask_session_info(app, APP_PREFIX, True) 39 | 40 | # Globals 41 | state_file = app_data_registry.get_state_file(APP_PREFIX) 42 | if os.path.exists(state_file): 43 | with open(state_file, 'rb') as f: 44 | post_register, instance_register, current_post_uuid = pickle.load(f) 45 | else: 46 | post_register = { 47 | "initial": ("halcy@icosahedron.website", "First post!", { 48 | "acct": "halcy@icosahedron.website", 49 | "display_name": "halcy", 50 | "avatar": "https://icosahedron.website/system/accounts/avatars/000/000/001/original/media.jpg" 51 | }) 52 | } 53 | instance_register = {} 54 | current_post_uuid = "initial" 55 | 56 | # DB stuff 57 | def get_db(): 58 | # Connect to DB 59 | db = getattr(g, '_database', None) 60 | if db is None: 61 | db = sqlite3.connect(app_data_registry.get_db_file(APP_PREFIX)) 62 | db.row_factory = sqlite3.Row 63 | g._database = db 64 | return db 65 | 66 | def query_db(query, args=(), single = False): 67 | with app.app_context(): 68 | # SQL query function 69 | db = get_db() 70 | cursor = db.execute(query, args) 71 | data = cursor.fetchall() 72 | cursor.close() 73 | db.commit() 74 | return (data[0] if data else None) if single else data 75 | 76 | @app.teardown_appcontext 77 | def close_connection(exception): 78 | # Close DB connection on teardown 79 | db = getattr(g, '_database', None) 80 | if db is not None: 81 | db.close() 82 | 83 | # Set up the DB initially, if empty 84 | table_exists = query_db("SELECT name FROM sqlite_master WHERE type='table' AND name='posts'", single = True) is not None 85 | if not table_exists: 86 | query = """ 87 | CREATE TABLE posts ( 88 | account_from TEXT, 89 | account_to TEXT, 90 | post TEXT, 91 | time NUMERIC 92 | ) 93 | """ 94 | query_db(query) 95 | 96 | # Update post in the DB 97 | def db_add_post(account, other_account, post): 98 | # Convert arguments to DB form 99 | post_json = json.dumps(post, default=str) 100 | other_account = other_account.lower() 101 | 102 | # Insert or update, depending 103 | query = """ 104 | INSERT INTO posts (account_from, account_to, post, time) 105 | VALUES (?, ?, ?, ?) 106 | """ 107 | query_db(query, (account, other_account, post_json, time.time())) 108 | 109 | # Get current posts from DB 110 | def db_get_posts(): 111 | query = """ 112 | SELECT post 113 | FROM posts 114 | WHERE time > ? AND time < ? 115 | ORDER BY time 116 | DESC 117 | """ 118 | posts_json = query_db(query, (time.time() - 60 * 60 * 24 * 2, time.time() - 60 * 60 * 24)) 119 | posts = [json.loads(x[0], object_hook=Mastodon._Mastodon__json_hooks) for x in posts_json] 120 | return posts 121 | 122 | # Instance URL normalizer 123 | def norm_instance_url(instance): 124 | # Try to be permissive but also paranoid 125 | if "://" in instance: 126 | instance = "://".join(instance.split("://")[1:]) 127 | if "/" in instance: 128 | instance = instance.split("/")[0] 129 | if not validators.url("https://" + instance): 130 | abort(500) 131 | return instance 132 | 133 | # Gets a client credential, creates app if needed 134 | def get_client_credential(instance): 135 | # For registry, add https:// 136 | instance = "https://" + instance 137 | 138 | # Lock credential DB 139 | secret_registry.lock_credentials() 140 | 141 | fallback_scopes = False 142 | try: 143 | # Get app credential, register new if needed 144 | client_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "client") 145 | if not secret_registry.have_credential(client_credential): 146 | try: 147 | Mastodon.create_app( 148 | CLIENT_NAME, 149 | api_base_url = instance, 150 | scopes = SCOPES_TO_REQUEST, 151 | to_file = client_credential, 152 | redirect_uris = [OAUTH_TARGET_URL] 153 | ) 154 | except: 155 | # Special akkoma fallback 156 | Mastodon.create_app( 157 | CLIENT_NAME, 158 | api_base_url = instance, 159 | scopes = SCOPES_FALLBACK, 160 | to_file = client_credential, 161 | redirect_uris = [OAUTH_TARGET_URL] 162 | ) 163 | fallback_scopes = True 164 | secret_registry.update_meta_time(client_credential) 165 | except: 166 | client_credential = None 167 | 168 | # Unlock credential DB 169 | secret_registry.release_credentials() 170 | 171 | return client_credential, fallback_scopes 172 | 173 | def process_emoji(text, emojis): 174 | for emoji in emojis: 175 | text = text.replace(":{}:".format(emoji.shortcode), '{}'.format(emoji.url, emoji.shortcode)) 176 | return text 177 | app.jinja_env.globals.update(process_emoji = process_emoji) 178 | 179 | @app.route('/') 180 | def index(): 181 | # Get the posts 182 | posts = db_get_posts() 183 | 184 | # Render the login page if the user is not logged in 185 | return render_template('login.htm', posts = posts) 186 | 187 | @app.route('/auth') 188 | def auth(): 189 | # Global vars 190 | global post_register 191 | global instance_register 192 | global current_post_uuid 193 | global state_file 194 | 195 | # Get the oauth code 196 | uuid_str = request.args.get('state') 197 | oauth_code = request.args.get('code') 198 | instance = norm_instance_url(instance_register[uuid_str]) 199 | del instance_register[uuid_str] 200 | 201 | # Get client credential and create API 202 | client_credential, _ = get_client_credential(instance) 203 | api = Mastodon(client_id = client_credential) 204 | 205 | # Lock credential DB (we also use this lock for everything else) 206 | secret_registry.lock_credentials() 207 | 208 | # Log in 209 | try: 210 | # Authenticate 211 | try: 212 | api.log_in( 213 | code = oauth_code, 214 | scopes = SCOPES_TO_REQUEST, 215 | redirect_uri = OAUTH_TARGET_URL, 216 | ) 217 | except: 218 | # Special akkoma fallback 219 | api.log_in( 220 | code = oauth_code, 221 | scopes = SCOPES_FALLBACK, 222 | redirect_uri = OAUTH_TARGET_URL, 223 | ) 224 | account = api.me().acct 225 | 226 | # Send the post 227 | current_post_user, current_post, current_post_account = post_register[current_post_uuid] 228 | post = api.status_post(current_post) 229 | post["account"]["acct"] += instance 230 | del post_register[current_post_uuid] 231 | post_register[uuid_str] = (account, post_register[uuid_str][1], post.account) 232 | current_post_uuid = uuid_str 233 | 234 | # Dump state 235 | with open(state_file, 'wb') as f: 236 | pickle.dump((post_register, instance_register, current_post_uuid), f) 237 | 238 | # Store in DB 239 | post["post_from"] = current_post_account 240 | db_add_post(current_post_user, account, post) 241 | 242 | # Revoke credential immediately 243 | api.revoke_access_token() 244 | except Exception as e: 245 | # Error handling (showing the user that something went wrong) is future work 246 | logging.warning("Auth error" + str(e)) 247 | 248 | # Unlock credential DB 249 | secret_registry.release_credentials() 250 | 251 | return redirect(APP_BASE_URL) 252 | 253 | @app.route('/login', methods=['POST']) 254 | def login(): 255 | # Sanity check post 256 | new_post = request.form['new_post'].strip() 257 | new_post.replace("@", " ").strip() 258 | new_post.replace("http://", "").strip() 259 | new_post.replace("https://", "").strip() 260 | if len(new_post) >= 499 or len(new_post) == 0: 261 | return redirect(APP_BASE_URL) 262 | 263 | try: 264 | # Get a client 265 | instance = norm_instance_url(request.form['instance']) 266 | client_credential, fallback_scopes = get_client_credential(instance) 267 | 268 | # Post store UUID 269 | uuid_str = str(uuid.uuid1()) 270 | 271 | # Create a Mastodon api instance and generate oauth URL 272 | api = Mastodon(client_id = client_credential) 273 | if not fallback_scopes: 274 | login_url = api.auth_request_url( 275 | scopes = SCOPES_TO_REQUEST, 276 | redirect_uris = OAUTH_TARGET_URL, 277 | state = uuid_str 278 | ) 279 | else: 280 | # Special akkoma fallback 281 | login_url = api.auth_request_url( 282 | scopes = SCOPES_FALLBACK, 283 | redirect_uris = OAUTH_TARGET_URL, 284 | state = uuid_str 285 | ) 286 | 287 | # Store data 288 | post_register[uuid_str] = (None, new_post) 289 | instance_register[uuid_str] = instance 290 | 291 | # Redirect the user to the OAuth login URL 292 | return redirect(login_url) 293 | except: 294 | traceback.print_exc() 295 | return redirect(APP_BASE_URL) 296 | 297 | if __name__ == '__main__': 298 | # Run webapp 299 | app.run("0.0.0.0", debug = True) 300 | -------------------------------------------------------------------------------- /06_mastomash/mastomash.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import logging 5 | import traceback 6 | import random 7 | import datetime 8 | 9 | from flask import Flask, session, render_template, redirect, request, abort, g 10 | 11 | sys.path.append("../tooling/") 12 | import secret_registry 13 | import app_data_registry 14 | import validators 15 | 16 | from mastodon import Mastodon, streaming 17 | 18 | # Settings 19 | CLIENT_NAME = "Mastomash" 20 | APP_PREFIX = "day06_mastomash" 21 | MASTO_SECRET = os.environ["MASTODON_SECRET"] 22 | SCOPES_TO_REQUEST = ["read", "write"] 23 | OAUTH_TARGET_URL = "https://mastolab.kal-tsit.halcy.de/day06/auth" 24 | APP_BASE_URL = "/day06/" 25 | 26 | with open("ui_data.json", "rb") as f: 27 | ui_strings = json.load(f) 28 | 29 | # Logging setup 30 | logging.basicConfig( 31 | stream = sys.stdout, 32 | format = "%(levelname)s %(asctime)s - %(message)s", 33 | level = logging.INFO 34 | ) 35 | 36 | # Set the secret key to a random value 37 | app = Flask(__name__) 38 | app_data_registry.set_flask_session_info(app, APP_PREFIX, True) 39 | 40 | # Instance URL normalizer 41 | def norm_instance_url(instance): 42 | # Try to be permissive but also paranoid 43 | if "://" in instance: 44 | instance = "://".join(instance.split("://")[1:]) 45 | if "/" in instance: 46 | instance = instance.split("/")[0] 47 | if not validators.url("https://" + instance): 48 | abort(500) 49 | return instance 50 | 51 | # Grabs data from session 52 | def get_session_user(): 53 | user_name = session["user_name"] 54 | instance = norm_instance_url(session["instance"]) 55 | account = "{}@{}".format(user_name, instance) 56 | return user_name, instance, account 57 | 58 | # Cleans out the session 59 | def log_off_session(): 60 | session["logged_in"] = False 61 | try: 62 | del session["user_name"] 63 | except: 64 | pass 65 | try: 66 | del session["instance"] 67 | except: 68 | pass 69 | 70 | # Gets a client credential, creates app if needed 71 | def get_client_credential(instance): 72 | # For registry, add https:// 73 | instance = "https://" + instance 74 | 75 | # Lock credential DB 76 | secret_registry.lock_credentials() 77 | 78 | try: 79 | # Get app credential, register new if needed 80 | client_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "client") 81 | if not secret_registry.have_credential(client_credential): 82 | Mastodon.create_app( 83 | CLIENT_NAME, 84 | api_base_url = instance, 85 | scopes = SCOPES_TO_REQUEST, 86 | to_file = client_credential, 87 | redirect_uris = [OAUTH_TARGET_URL] 88 | ) 89 | secret_registry.update_meta_time(client_credential) 90 | except: 91 | client_credential = None 92 | 93 | # Unlock credential DB 94 | secret_registry.release_credentials() 95 | 96 | return client_credential 97 | 98 | def process_emoji(text, emojis): 99 | for emoji in emojis: 100 | text = text.replace(":{}:".format(emoji.shortcode), '{}'.format(emoji.url, emoji.shortcode)) 101 | return text 102 | app.jinja_env.globals.update(process_emoji = process_emoji) 103 | 104 | def ui(string): 105 | return random.choice(ui_strings[string]) 106 | app.jinja_env.globals.update(ui = ui) 107 | 108 | def dateformat(datetime_object): 109 | format = random.choice(ui_strings["time_formats"]) 110 | if format == "SEPTEMBER": 111 | sept1st1993 = datetime.datetime(1993, 9, 1, tzinfo = datetime.timezone.utc) 112 | delta = datetime_object - sept1st1993 113 | return "September {}, 1993, {}:{}".format(delta.days, datetime_object.hour, datetime_object.minute) 114 | else: 115 | return datetime_object.strftime(format) 116 | app.jinja_env.globals.update(dateformat = dateformat) 117 | 118 | @app.route('/') 119 | def index(): 120 | # Check if the user is already logged in 121 | if not "logged_in" in session or session["logged_in"] == False: 122 | # Render the login page if the user is not logged in 123 | return render_template('login.htm') 124 | else: 125 | # Get user data from session 126 | user_name, instance, account = get_session_user() 127 | 128 | # Get args 129 | since_id = request.args.get('since_id') 130 | max_id = request.args.get('max_id') 131 | 132 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 133 | api = Mastodon(access_token = user_credential, request_timeout = 10) 134 | posts = api.timeline_home( 135 | limit = 40, 136 | since_id = since_id, 137 | max_id = max_id 138 | ) 139 | 140 | # Render 141 | return render_template( 142 | 'authed.htm', 143 | account = account, 144 | posts = posts, 145 | ) 146 | 147 | @app.route('/post_status', methods=["POST"]) 148 | def post_status(): 149 | # Get user info 150 | user_name, instance, _ = get_session_user() 151 | 152 | # Send post 153 | post_text = request.form['text'] 154 | try: 155 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 156 | api = Mastodon(access_token = user_credential, request_timeout = 10) 157 | api.status_post(post_text) 158 | except: 159 | pass 160 | 161 | # Back to root 162 | return redirect(APP_BASE_URL) 163 | 164 | @app.route('/reply', methods=["POST"]) 165 | def reply(): 166 | # Get user info 167 | user_name, instance, account = get_session_user() 168 | 169 | # Get params 170 | reply_to = int(request.form['to']) 171 | reply_text = request.form['text'] 172 | 173 | # Send reply 174 | try: 175 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 176 | api = Mastodon(access_token = user_credential, request_timeout = 10) 177 | reply_to_post = api.status(reply_to) 178 | api.status_reply(reply_to_post, reply_text) 179 | except: 180 | pass 181 | 182 | # Back to root 183 | return redirect(APP_BASE_URL) 184 | 185 | @app.route('/boost', methods=["POST"]) 186 | def boost(): 187 | # Get user info 188 | user_name, instance, account = get_session_user() 189 | 190 | # Get params 191 | which = int(request.form['which']) 192 | 193 | # Send reply 194 | try: 195 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 196 | api = Mastodon(access_token = user_credential, request_timeout = 10) 197 | api.status_reblog(which) 198 | except: 199 | pass 200 | 201 | # Back to root 202 | return redirect(APP_BASE_URL) 203 | 204 | @app.route('/unboost', methods=["POST"]) 205 | def unboost(): 206 | # Get user info 207 | user_name, instance, account = get_session_user() 208 | 209 | # Get params 210 | which = int(request.form['which']) 211 | 212 | # Send reply 213 | try: 214 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 215 | api = Mastodon(access_token = user_credential, request_timeout = 10) 216 | api.status_unreblog(which) 217 | except: 218 | pass 219 | 220 | # Back to root 221 | return redirect(APP_BASE_URL) 222 | 223 | @app.route('/fav', methods=["POST"]) 224 | def fav(): 225 | # Get user info 226 | user_name, instance, account = get_session_user() 227 | 228 | # Get params 229 | which = int(request.form['which']) 230 | 231 | # Send reply 232 | try: 233 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 234 | api = Mastodon(access_token = user_credential, request_timeout = 10) 235 | api.status_favourite(which) 236 | except: 237 | pass 238 | 239 | # Back to root 240 | return redirect(APP_BASE_URL) 241 | 242 | @app.route('/unfav', methods=["POST"]) 243 | def unfav(): 244 | # Get user info 245 | user_name, instance, account = get_session_user() 246 | 247 | # Get params 248 | which = int(request.form['which']) 249 | 250 | # Send reply 251 | try: 252 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 253 | api = Mastodon(access_token = user_credential, request_timeout = 10) 254 | api.status_unfavourite(which) 255 | except: 256 | pass 257 | 258 | # Back to root 259 | return redirect(APP_BASE_URL) 260 | 261 | @app.route('/revoke') 262 | def revoke(): 263 | # Get user info 264 | user_name, instance, account = get_session_user() 265 | 266 | # Lock credential DB 267 | secret_registry.lock_credentials() 268 | 269 | # Revoke token 270 | try: 271 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 272 | secret_registry.revoke_credential(user_credential) 273 | except: 274 | pass 275 | 276 | # Also clear the session 277 | log_off_session() 278 | 279 | # Unlock credential DB 280 | secret_registry.release_credentials() 281 | 282 | # Back to root 283 | return redirect(APP_BASE_URL) 284 | 285 | @app.route('/auth') 286 | def auth(): 287 | # Get the oauth code 288 | oauth_code = request.args.get('code') 289 | instance = norm_instance_url(request.args.get('state')) 290 | 291 | # Get client credential and create API 292 | client_credential = get_client_credential(instance) 293 | api = Mastodon(client_id = client_credential) 294 | 295 | # Lock credential DB 296 | secret_registry.lock_credentials() 297 | 298 | # Log in 299 | try: 300 | user_credential_temp = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", oauth_code) 301 | api.log_in( 302 | code = oauth_code, 303 | to_file = user_credential_temp, 304 | scopes = SCOPES_TO_REQUEST, 305 | redirect_uri = OAUTH_TARGET_URL, 306 | ) 307 | secret_registry.update_meta_time(user_credential_temp) 308 | 309 | # Move to final place 310 | user_name = api.me().acct.split("@")[0] 311 | user_credential_final = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 312 | secret_registry.move_credential(user_credential_temp, user_credential_final) 313 | 314 | # Store session data 315 | session["logged_in"] = True 316 | session["user_name"] = user_name 317 | session["instance"] = instance 318 | except Exception as e: 319 | # Error handling (showing the user that something went wrong) is future work 320 | logging.warning("Auth error" + str(e)) 321 | 322 | # Unlock credential DB 323 | secret_registry.release_credentials() 324 | 325 | return redirect(APP_BASE_URL) 326 | 327 | @app.route('/login', methods=['POST']) 328 | def login(): 329 | try: 330 | # Get a client 331 | instance = norm_instance_url(request.form['instance']) 332 | client_credential = get_client_credential(instance) 333 | print(client_credential) 334 | 335 | # Try to be permissive 336 | if "://" in instance: 337 | instance = "://".join(instance.split("://")[1:]) 338 | if "/" in instance: 339 | instance = instance.split("/")[0] 340 | 341 | # Create a Mastodon api instance and generate oauth URL 342 | api = Mastodon(client_id = client_credential) 343 | login_url = api.auth_request_url( 344 | scopes = SCOPES_TO_REQUEST, 345 | redirect_uris = OAUTH_TARGET_URL, 346 | state = instance 347 | ) 348 | 349 | # Redirect the user to the OAuth login URL 350 | return redirect(login_url) 351 | except: 352 | traceback.print_exc() 353 | log_off_session() 354 | return redirect(APP_BASE_URL) 355 | 356 | if __name__ == '__main__': 357 | # Run webapp 358 | app.run("0.0.0.0", debug = True) 359 | -------------------------------------------------------------------------------- /07_florps/florps.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import logging 5 | import traceback 6 | import random 7 | import datetime 8 | 9 | from flask import Flask, session, render_template, redirect, request, abort 10 | 11 | sys.path.append("../tooling/") 12 | import secret_registry 13 | import app_data_registry 14 | import validators 15 | 16 | from mastodon import Mastodon, streaming 17 | 18 | # Settings 19 | CLIENT_NAME = "Florps" 20 | APP_PREFIX = "day07_florps" 21 | MASTO_SECRET = os.environ["MASTODON_SECRET"] 22 | SCOPES_TO_REQUEST = ["read", "write"] 23 | OAUTH_TARGET_URL = "https://mastolab.kal-tsit.halcy.de/day07/auth" 24 | APP_BASE_URL = "/day07/" 25 | 26 | with open("ui_data.json", "rb") as f: 27 | ui_strings = json.load(f) 28 | 29 | # Logging setup 30 | logging.basicConfig( 31 | stream = sys.stdout, 32 | format = "%(levelname)s %(asctime)s - %(message)s", 33 | level = logging.INFO 34 | ) 35 | 36 | # Set the secret key to a random value 37 | app = Flask(__name__) 38 | app_data_registry.set_flask_session_info(app, APP_PREFIX, True) 39 | 40 | # Instance URL normalizer 41 | def norm_instance_url(instance): 42 | # Try to be permissive but also paranoid 43 | if "://" in instance: 44 | instance = "://".join(instance.split("://")[1:]) 45 | if "/" in instance: 46 | instance = instance.split("/")[0] 47 | if not validators.url("https://" + instance): 48 | abort(500) 49 | return instance 50 | 51 | # Grabs data from session 52 | def get_session_user(): 53 | user_name = session["user_name"] 54 | instance = norm_instance_url(session["instance"]) 55 | account = "{}@{}".format(user_name, instance) 56 | return user_name, instance, account 57 | 58 | # Cleans out the session 59 | def log_off_session(): 60 | session["logged_in"] = False 61 | try: 62 | del session["user_name"] 63 | except: 64 | pass 65 | try: 66 | del session["instance"] 67 | except: 68 | pass 69 | 70 | # Gets a client credential, creates app if needed 71 | def get_client_credential(instance): 72 | # For registry, add https:// 73 | instance = "https://" + instance 74 | 75 | # Lock credential DB 76 | secret_registry.lock_credentials() 77 | 78 | try: 79 | # Get app credential, register new if needed 80 | client_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "client") 81 | if not secret_registry.have_credential(client_credential): 82 | Mastodon.create_app( 83 | CLIENT_NAME, 84 | api_base_url = instance, 85 | scopes = SCOPES_TO_REQUEST, 86 | to_file = client_credential, 87 | redirect_uris = [OAUTH_TARGET_URL] 88 | ) 89 | secret_registry.update_meta_time(client_credential) 90 | except: 91 | client_credential = None 92 | 93 | # Unlock credential DB 94 | secret_registry.release_credentials() 95 | 96 | return client_credential 97 | 98 | def process_emoji(text, emojis): 99 | for emoji in emojis: 100 | text = text.replace(":{}:".format(emoji.shortcode), '{}'.format(emoji.url, emoji.shortcode)) 101 | return text 102 | app.jinja_env.globals.update(process_emoji = process_emoji) 103 | 104 | def ui(string): 105 | return random.choice(ui_strings[string]) 106 | app.jinja_env.globals.update(ui = ui) 107 | 108 | def dateformat(datetime_object): 109 | format = random.choice(ui_strings["time_formats"]) 110 | if format == "SEPTEMBER": 111 | sept1st1993 = datetime.datetime(1993, 9, 1, tzinfo = datetime.timezone.utc) 112 | delta = datetime_object - sept1st1993 113 | return "September {}, 1993, {}:{}".format(delta.days, datetime_object.hour, datetime_object.minute) 114 | else: 115 | return datetime_object.strftime(format) 116 | app.jinja_env.globals.update(dateformat = dateformat) 117 | 118 | @app.route('/') 119 | def index(): 120 | try: 121 | # Check if the user is already logged in 122 | if not "logged_in" in session or session["logged_in"] == False: 123 | # Render the login page if the user is not logged in 124 | return render_template('login.htm') 125 | else: 126 | # Get user data from session 127 | user_name, instance, account = get_session_user() 128 | 129 | # Get args 130 | min_id = request.args.get('min_id') 131 | max_id = request.args.get('max_id') 132 | 133 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 134 | api = Mastodon(access_token = user_credential, request_timeout = 10) 135 | posts = api.timeline_home( 136 | limit = 40, 137 | min_id = min_id, 138 | max_id = max_id 139 | ) 140 | 141 | # Render 142 | next_id = posts._pagination_prev["min_id"] + 1 143 | return render_template( 144 | 'authed.htm', 145 | account = account, 146 | posts = posts, 147 | next_id = next_id 148 | ) 149 | except: 150 | # clear the session 151 | log_off_session() 152 | return render_template('login.htm') 153 | 154 | @app.route('/posts') 155 | def posts(): 156 | # Check if the user is logged in 157 | if not "logged_in" in session or session["logged_in"] == False: 158 | # Back to root 159 | return redirect(APP_BASE_URL) 160 | else: 161 | # Get user data from session 162 | user_name, instance, account = get_session_user() 163 | 164 | # Get args 165 | min_id = request.args.get('min_id') 166 | max_id = request.args.get('max_id') 167 | 168 | # Get posts 169 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 170 | api = Mastodon(access_token = user_credential, request_timeout = 10) 171 | posts = api.timeline_home( 172 | limit = 40, 173 | min_id = min_id, 174 | max_id = max_id 175 | ) 176 | 177 | # Render 178 | if len(posts) == 0: 179 | next_id = min_id 180 | else: 181 | next_id = posts._pagination_prev["min_id"] + 1 182 | if max_id is not None: 183 | next_id = 0 184 | 185 | return render_template( 186 | 'post_list.htm', 187 | account = account, 188 | posts = posts, 189 | next_id = next_id 190 | ) 191 | 192 | @app.route('/post_status', methods=["POST"]) 193 | def post_status(): 194 | # Get user info 195 | user_name, instance, _ = get_session_user() 196 | 197 | # Send post 198 | post_text = request.form['text'] 199 | try: 200 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 201 | api = Mastodon(access_token = user_credential, request_timeout = 10) 202 | api.status_post(post_text) 203 | except: 204 | pass 205 | 206 | # Back to root 207 | return render_template("form_post.htm") 208 | 209 | @app.route('/reply', methods=["POST"]) 210 | def reply(): 211 | # Get user info 212 | user_name, instance, account = get_session_user() 213 | 214 | # Get params 215 | reply_to = int(request.form['to']) 216 | reply_text = request.form['text'] 217 | 218 | # Send reply 219 | try: 220 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 221 | api = Mastodon(access_token = user_credential, request_timeout = 10) 222 | reply_to_post = api.status(reply_to) 223 | api.status_reply(reply_to_post, reply_text) 224 | except: 225 | pass 226 | 227 | # Back to root 228 | return "" 229 | 230 | @app.route('/boost', methods=["POST"]) 231 | def boost(): 232 | # Get user info 233 | user_name, instance, account = get_session_user() 234 | 235 | # Get params 236 | which = int(request.form['which']) 237 | 238 | # Send reply 239 | post_updated = None 240 | try: 241 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 242 | api = Mastodon(access_token = user_credential, request_timeout = 10) 243 | post_updated = api.status_reblog(which) 244 | except: 245 | pass 246 | 247 | # Back to root 248 | return render_template('button_boost.htm', post = post_updated) 249 | 250 | @app.route('/unboost', methods=["POST"]) 251 | def unboost(): 252 | # Get user info 253 | user_name, instance, account = get_session_user() 254 | 255 | # Get params 256 | which = int(request.form['which']) 257 | 258 | # Send reply 259 | post_updated = None 260 | try: 261 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 262 | api = Mastodon(access_token = user_credential, request_timeout = 10) 263 | post_updated = api.status_unreblog(which) 264 | except: 265 | pass 266 | 267 | # Back to root 268 | return render_template('button_boost.htm', post = post_updated) 269 | 270 | @app.route('/fav', methods=["POST"]) 271 | def fav(): 272 | # Get user info 273 | user_name, instance, account = get_session_user() 274 | 275 | # Get params 276 | which = int(request.form['which']) 277 | 278 | # Send reply 279 | post_updated = None 280 | try: 281 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 282 | api = Mastodon(access_token = user_credential, request_timeout = 10) 283 | post_updated = api.status_favourite(which) 284 | except: 285 | pass 286 | 287 | # Back to root 288 | return render_template('button_fav.htm', post = post_updated) 289 | 290 | @app.route('/unfav', methods=["POST"]) 291 | def unfav(): 292 | # Get user info 293 | user_name, instance, account = get_session_user() 294 | 295 | # Get params 296 | which = int(request.form['which']) 297 | 298 | # Send reply 299 | post_updated = None 300 | try: 301 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 302 | api = Mastodon(access_token = user_credential, request_timeout = 10) 303 | post_updated = api.status_unfavourite(which) 304 | except: 305 | pass 306 | 307 | # Back to root 308 | return render_template('button_fav.htm', post = post_updated) 309 | 310 | @app.route('/revoke') 311 | def revoke(): 312 | # Get user info 313 | user_name, instance, account = get_session_user() 314 | 315 | # Lock credential DB 316 | secret_registry.lock_credentials() 317 | 318 | # Revoke token 319 | try: 320 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 321 | secret_registry.revoke_credential(user_credential) 322 | except: 323 | pass 324 | 325 | # Also clear the session 326 | log_off_session() 327 | 328 | # Unlock credential DB 329 | secret_registry.release_credentials() 330 | 331 | # Back to root 332 | return redirect(APP_BASE_URL) 333 | 334 | @app.route('/auth') 335 | def auth(): 336 | # Get the oauth code 337 | oauth_code = request.args.get('code') 338 | instance = norm_instance_url(request.args.get('state')) 339 | 340 | # Get client credential and create API 341 | client_credential = get_client_credential(instance) 342 | api = Mastodon(client_id = client_credential) 343 | 344 | # Lock credential DB 345 | secret_registry.lock_credentials() 346 | 347 | # Log in 348 | try: 349 | user_credential_temp = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", oauth_code) 350 | api.log_in( 351 | code = oauth_code, 352 | to_file = user_credential_temp, 353 | scopes = SCOPES_TO_REQUEST, 354 | redirect_uri = OAUTH_TARGET_URL, 355 | ) 356 | secret_registry.update_meta_time(user_credential_temp) 357 | 358 | # Move to final place 359 | user_name = api.me().acct.split("@")[0] 360 | logging.info("New login: " + user_name + " from " + instance) 361 | 362 | user_credential_final = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 363 | secret_registry.move_credential(user_credential_temp, user_credential_final) 364 | 365 | # Store session data 366 | session["logged_in"] = True 367 | session["user_name"] = user_name 368 | session["instance"] = instance 369 | except Exception as e: 370 | # Error handling (showing the user that something went wrong) is future work 371 | logging.warning("Auth error" + str(e)) 372 | 373 | # Unlock credential DB 374 | secret_registry.release_credentials() 375 | 376 | return redirect(APP_BASE_URL) 377 | 378 | @app.route('/login', methods=['POST']) 379 | def login(): 380 | try: 381 | # Get a client 382 | instance = norm_instance_url(request.form['instance']) 383 | client_credential = get_client_credential(instance) 384 | 385 | # Try to be permissive 386 | if "://" in instance: 387 | instance = "://".join(instance.split("://")[1:]) 388 | if "/" in instance: 389 | instance = instance.split("/")[0] 390 | 391 | # Create a Mastodon api instance and generate oauth URL 392 | api = Mastodon(client_id = client_credential) 393 | login_url = api.auth_request_url( 394 | scopes = SCOPES_TO_REQUEST, 395 | redirect_uris = OAUTH_TARGET_URL, 396 | state = instance 397 | ) 398 | 399 | # Redirect the user to the OAuth login URL 400 | return redirect(login_url) 401 | except: 402 | traceback.print_exc() 403 | log_off_session() 404 | return redirect(APP_BASE_URL) 405 | 406 | if __name__ == '__main__': 407 | # Run webapp 408 | app.run("0.0.0.0", debug = True) 409 | -------------------------------------------------------------------------------- /04_alphant/alphant.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import json 5 | import logging 6 | import threading 7 | import traceback 8 | 9 | from flask import Flask, session, render_template, redirect, request, abort, g 10 | import sqlite3 11 | 12 | sys.path.append("../tooling/") 13 | import secret_registry 14 | import app_data_registry 15 | import validators 16 | 17 | from mastodon import Mastodon, streaming 18 | 19 | # Settings 20 | CLIENT_NAME = "Alphant" 21 | APP_PREFIX = "day04_alphant" 22 | MASTO_SECRET = os.environ["MASTODON_SECRET"] 23 | SCOPES_TO_REQUEST = ["read:accounts", "read:statuses", "read:follows", "write:follows", "write:statuses"] 24 | OAUTH_TARGET_URL = "https://mastolab.kal-tsit.halcy.de/day04/auth" 25 | APP_BASE_URL = "/day04/" 26 | 27 | # Logging setup 28 | logging.basicConfig( 29 | stream = sys.stdout, 30 | format = "%(levelname)s %(asctime)s - %(message)s", 31 | level = logging.INFO 32 | ) 33 | 34 | # Set the secret key to a random value 35 | app = Flask(__name__) 36 | app_data_registry.set_flask_session_info(app, APP_PREFIX, True) 37 | 38 | # DB stuff 39 | def get_db(): 40 | # Connect to DB 41 | db = getattr(g, '_database', None) 42 | if db is None: 43 | db = sqlite3.connect(app_data_registry.get_db_file(APP_PREFIX)) 44 | db.row_factory = sqlite3.Row 45 | g._database = db 46 | return db 47 | 48 | def query_db(query, args=(), single = False): 49 | with app.app_context(): 50 | # SQL query function 51 | db = get_db() 52 | cursor = db.execute(query, args) 53 | data = cursor.fetchall() 54 | cursor.close() 55 | db.commit() 56 | return (data[0] if data else None) if single else data 57 | 58 | @app.teardown_appcontext 59 | def close_connection(exception): 60 | # Close DB connection on teardown 61 | db = getattr(g, '_database', None) 62 | if db is not None: 63 | db.close() 64 | 65 | # Set up the DB initially, if empty 66 | table_exists = query_db("SELECT name FROM sqlite_master WHERE type='table' AND name='posts'", single = True) is not None 67 | if not table_exists: 68 | query = """ 69 | CREATE TABLE posts ( 70 | account TEXT, 71 | other_account TEXT, 72 | post TEXT, 73 | post_id NUMERIC 74 | ) 75 | """ 76 | query_db(query) 77 | 78 | # Update post in the DB 79 | def db_update_post(account, other_account, post): 80 | # Don't want reblogs 81 | if post.reblog is not None: 82 | return 83 | 84 | # Convert arguments to DB form 85 | post_json = json.dumps(post, default=str) 86 | other_account = other_account.lower() 87 | 88 | # Check if a post exists that would match here 89 | query = """ 90 | SELECT post FROM posts 91 | WHERE account = ? AND other_account = ? 92 | """ 93 | post_exists = query_db(query, (account, other_account), single = True) is not None 94 | 95 | # Insert or update, depending 96 | if not post_exists: 97 | query = """ 98 | INSERT INTO posts (account, other_account, post, post_id) 99 | VALUES (?, ?, ?, ?) 100 | """ 101 | query_db(query, (account, other_account, post_json, post.id)) 102 | else: 103 | query = """ 104 | UPDATE posts 105 | SET post = ?, post_id = ? 106 | WHERE account = ? AND other_account = ? 107 | """ 108 | query_db(query, (post_json, post.id, account, other_account)) 109 | 110 | # Get current posts from DB 111 | def db_get_posts(account): 112 | ascdesc = "ASC" 113 | if int(time.time() / 60 * 60) % 2 == 0: 114 | ascdesc = "DESC" 115 | query = """ 116 | SELECT post 117 | FROM posts 118 | WHERE account = ? 119 | ORDER BY other_account 120 | """ 121 | query = query + " " + ascdesc 122 | posts_json = query_db(query, (account, )) 123 | posts = [json.loads(x[0], object_hook=Mastodon._Mastodon__json_hooks) for x in posts_json] 124 | return posts 125 | 126 | # Get user list 127 | def db_get_accounts(): 128 | query = """ 129 | SELECT DISTINCT account 130 | FROM posts 131 | """ 132 | return [x[0] for x in query_db(query)] 133 | 134 | # Delete all posts associated with a user 135 | def db_delete_posts(account): 136 | query = "DELETE FROM posts WHERE account = ?" 137 | query_db(query, (account,)) 138 | 139 | # Delete a single post 140 | def db_delete_one_post(id): 141 | query = "DELETE FROM posts WHERE post_id = ?" 142 | query_db(query, (id,)) 143 | 144 | # Instance URL normalizer 145 | def norm_instance_url(instance): 146 | # Try to be permissive but also paranoid 147 | if "://" in instance: 148 | instance = "://".join(instance.split("://")[1:]) 149 | if "/" in instance: 150 | instance = instance.split("/")[0] 151 | if not validators.url("https://" + instance): 152 | abort(500) 153 | return instance 154 | 155 | # Grabs data from session 156 | def get_session_user(): 157 | user_name = session["user_name"] 158 | instance = norm_instance_url(session["instance"]) 159 | account = "{}@{}".format(user_name, instance) 160 | return user_name, instance, account 161 | 162 | # Cleans out the session 163 | def log_off_session(): 164 | session["logged_in"] = False 165 | try: 166 | del session["user_name"] 167 | except: 168 | pass 169 | try: 170 | del session["instance"] 171 | except: 172 | pass 173 | 174 | # Gets a client credential, creates app if needed 175 | def get_client_credential(instance): 176 | # For registry, add https:// 177 | instance = "https://" + instance 178 | 179 | # Lock credential DB 180 | secret_registry.lock_credentials() 181 | 182 | try: 183 | # Get app credential, register new if needed 184 | client_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "client") 185 | if not secret_registry.have_credential(client_credential): 186 | Mastodon.create_app( 187 | CLIENT_NAME, 188 | api_base_url = instance, 189 | scopes = SCOPES_TO_REQUEST, 190 | to_file = client_credential, 191 | redirect_uris = [OAUTH_TARGET_URL] 192 | ) 193 | secret_registry.update_meta_time(client_credential) 194 | except: 195 | client_credential = None 196 | 197 | # Unlock credential DB 198 | secret_registry.release_credentials() 199 | 200 | return client_credential 201 | 202 | # Background processing 203 | def post_to_db_resilient(account, post): 204 | try: 205 | db_update_post(account, post.account.acct, post) 206 | except Exception as e: 207 | logging.warning("Could not store post:", str(e)) 208 | 209 | def delete_from_db_resilient(id): 210 | try: 211 | db_delete_one_post(id) 212 | except Exception as e: 213 | logging.warning("Could not delete post:", str(e)) 214 | 215 | streams = {} 216 | def refresh_worker(): 217 | while True: 218 | try: 219 | # Start new streams where needed 220 | accounts = db_get_accounts() 221 | for account in accounts: 222 | if not account in streams: 223 | try: 224 | # Get login 225 | logging.info("Starting stream for " + account) 226 | user_name, instance = account.split("@") 227 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 228 | api = Mastodon(access_token = user_credential, request_timeout = 10) 229 | 230 | # Stream 231 | listener = streaming.CallbackStreamListener( 232 | update_handler = lambda status, account_bind = account: post_to_db_resilient(account_bind, status), 233 | status_update_handler = lambda status, account_bind = account: post_to_db_resilient(account_bind, status), 234 | delete_handler = delete_from_db_resilient 235 | ) 236 | streams[account] = api.stream_user(listener, run_async = True, reconnect_async = True) 237 | except Exception as e: 238 | logging.warning("Could not start stream: " + str(e)) 239 | 240 | # Reap old streams where not 241 | keys = list(streams.keys()) 242 | for stream in keys: 243 | if not stream in accounts: 244 | try: 245 | # Kill stream (should already be dead, but be extra paraoid) 246 | logging.info("Reaping stream for " + stream) 247 | try: 248 | streams[stream].close() 249 | except: 250 | pass 251 | del streams[stream] 252 | except Exception as e: 253 | logging.warning("Could not reap stream:" + str(e)) 254 | except Exception as e: 255 | logging.warning("General error in stream manager:" + str(e)) 256 | time.sleep(10) 257 | 258 | def process_emoji(text, emojis): 259 | for emoji in emojis: 260 | text = text.replace(":{}:".format(emoji.shortcode), '{}'.format(emoji.url, emoji.shortcode)) 261 | return text 262 | app.jinja_env.globals.update(process_emoji = process_emoji) 263 | 264 | @app.route('/') 265 | def index(): 266 | # Check if the user is already logged in 267 | if not "logged_in" in session or session["logged_in"] == False: 268 | # Render the login page if the user is not logged in 269 | return render_template('login.htm') 270 | else: 271 | # Get user data from session 272 | user_name, instance, account = get_session_user() 273 | 274 | # If there are no posts in DB: Get posts, insert 275 | posts = db_get_posts(account) 276 | if len(posts) == 0: 277 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 278 | api = Mastodon(access_token = user_credential, request_timeout = 10) 279 | posts_new = api.timeline_home() 280 | for post_new in posts_new: 281 | db_update_post(account, post_new.account.acct, post_new) 282 | posts = db_get_posts(account) 283 | 284 | # Render 285 | return render_template( 286 | 'authed.htm', 287 | account="{}@{}".format(user_name, instance), 288 | posts = posts 289 | ) 290 | 291 | @app.route('/post_status', methods=["POST"]) 292 | def post_status(): 293 | # Get user info 294 | user_name, instance, account = get_session_user() 295 | 296 | # Send post 297 | post_text = request.form['text'] 298 | try: 299 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 300 | api = Mastodon(access_token = user_credential, request_timeout = 10) 301 | api.status_post(post_text) 302 | except: 303 | pass 304 | 305 | # Back to root 306 | return redirect(APP_BASE_URL) 307 | 308 | @app.route('/reply', methods=["POST"]) 309 | def reply(): 310 | # Get user info 311 | user_name, instance, account = get_session_user() 312 | 313 | # Get params 314 | reply_to = int(request.form['to']) 315 | reply_text = request.form['text'] 316 | 317 | # Send reply 318 | try: 319 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 320 | api = Mastodon(access_token = user_credential, request_timeout = 10) 321 | reply_to_post = api.status(reply_to) 322 | api.status_reply(reply_to_post, reply_text) 323 | except: 324 | pass 325 | 326 | # Back to root 327 | return redirect(APP_BASE_URL) 328 | 329 | @app.route('/revoke') 330 | def revoke(): 331 | # Get user info 332 | user_name, instance, account = get_session_user() 333 | 334 | # Lock credential DB 335 | secret_registry.lock_credentials() 336 | 337 | # Revoke token 338 | try: 339 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 340 | secret_registry.revoke_credential(user_credential) 341 | except: 342 | pass 343 | 344 | # Kill stream, if exists 345 | try: 346 | streams[account].close() 347 | except: 348 | pass 349 | 350 | # Remove from DB 351 | db_delete_posts(user_name + "@" + instance) 352 | 353 | # Also clear the session 354 | log_off_session() 355 | 356 | # Unlock credential DB 357 | secret_registry.release_credentials() 358 | 359 | # Back to root 360 | return redirect(APP_BASE_URL) 361 | 362 | @app.route('/auth') 363 | def auth(): 364 | # Get the oauth code 365 | oauth_code = request.args.get('code') 366 | instance = norm_instance_url(request.args.get('state')) 367 | 368 | # Get client credential and create API 369 | client_credential = get_client_credential(instance) 370 | api = Mastodon(client_id = client_credential) 371 | 372 | # Lock credential DB 373 | secret_registry.lock_credentials() 374 | 375 | # Log in 376 | try: 377 | user_credential_temp = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", oauth_code) 378 | api.log_in( 379 | code = oauth_code, 380 | to_file = user_credential_temp, 381 | scopes = SCOPES_TO_REQUEST, 382 | redirect_uri = OAUTH_TARGET_URL, 383 | ) 384 | secret_registry.update_meta_time(user_credential_temp) 385 | 386 | # Move to final place 387 | user_name = api.me().acct.split("@")[0] 388 | user_credential_final = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 389 | secret_registry.move_credential(user_credential_temp, user_credential_final) 390 | 391 | # Store session data 392 | session["logged_in"] = True 393 | session["user_name"] = user_name 394 | session["instance"] = instance 395 | except Exception as e: 396 | # Error handling (showing the user that something went wrong) is future work 397 | logging.warning("Auth error" + str(e)) 398 | 399 | # Unlock credential DB 400 | secret_registry.release_credentials() 401 | 402 | return redirect(APP_BASE_URL) 403 | 404 | @app.route('/login', methods=['POST']) 405 | def login(): 406 | try: 407 | # Get a client 408 | instance = norm_instance_url(request.form['instance']) 409 | client_credential = get_client_credential(instance) 410 | 411 | # Try to be permissive 412 | if "://" in instance: 413 | instance = "://".join(instance.split("://")[1:]) 414 | if "/" in instance: 415 | instance = instance.split("/")[0] 416 | 417 | # Create a Mastodon api instance and generate oauth URL 418 | api = Mastodon(client_id = client_credential) 419 | login_url = api.auth_request_url( 420 | scopes = SCOPES_TO_REQUEST, 421 | redirect_uris = OAUTH_TARGET_URL, 422 | state = instance 423 | ) 424 | 425 | # Redirect the user to the OAuth login URL 426 | return redirect(login_url) 427 | except: 428 | traceback.print_exc() 429 | log_off_session() 430 | return redirect(APP_BASE_URL) 431 | 432 | # Run update worker 433 | worker_thread = threading.Thread(target=refresh_worker) 434 | worker_thread.start() 435 | 436 | if __name__ == '__main__': 437 | # Run webapp 438 | app.run("0.0.0.0", debug = True) 439 | -------------------------------------------------------------------------------- /06_mastomash/ui_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "post_verbs": [ 3 | "twitter", 4 | "clack", 5 | "oink", 6 | "woof", 7 | "munch", 8 | "chitter", 9 | "baa", 10 | "jingle", 11 | "cluck", 12 | "snort", 13 | "caw", 14 | "blubber", 15 | "shout", 16 | "mew", 17 | "gibber", 18 | "gargle", 19 | "bark", 20 | "quacking", 21 | "coo", 22 | "howl", 23 | "yip", 24 | "zip", 25 | "roaring", 26 | "yelp", 27 | "roar", 28 | "cricket", 29 | "cuckoo", 30 | "tweeting", 31 | "woofing", 32 | "bray", 33 | "sigh", 34 | "squawk", 35 | "buzz", 36 | "meow", 37 | "yipping", 38 | "mooing", 39 | "cackle", 40 | "whistle", 41 | "moo", 42 | "mumble", 43 | "click", 44 | "bleat", 45 | "chirrup", 46 | "peep", 47 | "chirp", 48 | "gabble", 49 | "sizzle", 50 | "whinnying", 51 | "honk", 52 | "quack", 53 | "burble", 54 | "yap", 55 | "whinny", 56 | "growl", 57 | "zing", 58 | "neighing", 59 | "whistling", 60 | "croak", 61 | "hoot", 62 | "hiss", 63 | "squeal", 64 | "gobble", 65 | "grunt", 66 | "yell", 67 | "chirr", 68 | "crowing", 69 | "zinging", 70 | "hooting", 71 | "snarl", 72 | "purr", 73 | "scream", 74 | "zoom", 75 | "yowl", 76 | "squeak", 77 | "yammer", 78 | "neigh", 79 | "murmur", 80 | "doodle", 81 | "snarling", 82 | "tweet", 83 | "holler", 84 | "ribbit", 85 | "warble", 86 | "hum" 87 | ], 88 | "post_verbs_past": [ 89 | "squealed", 90 | "roared", 91 | "mumbled", 92 | "clacked", 93 | "yipped", 94 | "warbled", 95 | "roaring", 96 | "jingled", 97 | "tweeting", 98 | "woofing", 99 | "gargled", 100 | "yipping", 101 | "mooing", 102 | "yapped", 103 | "zinged", 104 | "yowled", 105 | "yelled", 106 | "woofed", 107 | "yammered", 108 | "whinnying", 109 | "cackled", 110 | "brayed", 111 | "doodled", 112 | "yelped", 113 | "howled", 114 | "barked", 115 | "baaed", 116 | "cooed", 117 | "mooed", 118 | "whistled", 119 | "twittered", 120 | "hooted", 121 | "neighing", 122 | "clucked", 123 | "zinging", 124 | "chirred", 125 | "munched", 126 | "snarled", 127 | "squawked", 128 | "sizzled", 129 | "snarling", 130 | "snorted", 131 | "purred", 132 | "zoomed", 133 | "hissed", 134 | "hummed", 135 | "bleated", 136 | "quacking", 137 | "gibbered", 138 | "cricketed", 139 | "neighed", 140 | "gobbled", 141 | "whinnied", 142 | "murmured", 143 | "mewed", 144 | "tweeted", 145 | "screamed", 146 | "burbled", 147 | "ribbited", 148 | "peeped", 149 | "gabbled", 150 | "chirped", 151 | "zipped", 152 | "squeaked", 153 | "oinked", 154 | "croaked", 155 | "buzzed", 156 | "shouted", 157 | "grunted", 158 | "honked", 159 | "meowed", 160 | "cawed", 161 | "chittered", 162 | "crowed", 163 | "whistling", 164 | "quacked", 165 | "hooting", 166 | "chirruped", 167 | "blubbered", 168 | "sighed", 169 | "cuckooed", 170 | "clicked", 171 | "hollered", 172 | "growled" 173 | ], 174 | "emoji_left": [ 175 | "👼", 176 | "🐋", 177 | "🕊", 178 | "🐅", 179 | "😜", 180 | "🍂", 181 | "🐔", 182 | "🐥", 183 | "👉", 184 | "☄️", 185 | "🐀", 186 | "🐈", 187 | "🎍", 188 | "😏", 189 | "🐖", 190 | "👽", 191 | "🦍", 192 | "👾", 193 | "✋", 194 | "🌑", 195 | "🌘", 196 | "👎", 197 | "🦋", 198 | "👰", 199 | "💆", 200 | "😳", 201 | "🐸", 202 | "🐶", 203 | "🌎", 204 | "💇", 205 | "🦁", 206 | "🌛", 207 | "🐭", 208 | "🐯", 209 | "☀️", 210 | "✨", 211 | "😎", 212 | "🕸", 213 | "⚡️", 214 | "✍️", 215 | "🌦", 216 | "⛅️", 217 | "🦑", 218 | "✊", 219 | "😂", 220 | "🌟", 221 | "🐬", 222 | "🐉", 223 | "👮", 224 | "👲", 225 | "🐘", 226 | "🌧", 227 | "🙈", 228 | "🦉", 229 | "🐌", 230 | "💪", 231 | "🐾", 232 | "😩", 233 | "🌺", 234 | "👴", 235 | "🌍", 236 | "🌗", 237 | "🦅", 238 | "🐙", 239 | "⭐️", 240 | "👹", 241 | "👇", 242 | "🙏", 243 | "💅", 244 | "👿", 245 | "😍", 246 | "🌒", 247 | "🐇", 248 | "👱", 249 | "👷", 250 | "🦕", 251 | "🦇", 252 | "🐩", 253 | "🌱", 254 | "🐡", 255 | "🌖", 256 | "🦐", 257 | "🌸", 258 | "🌕", 259 | "🦈", 260 | "🥀", 261 | "🤔", 262 | "🤓", 263 | "🕷", 264 | "🦓", 265 | "👻", 266 | "😞", 267 | "🐱", 268 | "🦖", 269 | "👵", 270 | "🦄", 271 | "🐆", 272 | "🌲", 273 | "🍁", 274 | "🐹", 275 | "✌️", 276 | "🐎", 277 | "☁️", 278 | "👂", 279 | "😘", 280 | "🍀", 281 | "🌾", 282 | "👨", 283 | "🦊", 284 | "👍", 285 | "👌", 286 | "🦂", 287 | "🐪", 288 | "🐽", 289 | "🌏", 290 | "🌝", 291 | "🌜", 292 | "🐲", 293 | "🐊", 294 | "🐑", 295 | "💐", 296 | "🌹", 297 | "🐁", 298 | "💤", 299 | "😤", 300 | "😧", 301 | "👺", 302 | "👊", 303 | "🐷", 304 | "🌩", 305 | "😵", 306 | "🐨", 307 | "🍄", 308 | "🌚", 309 | "🐻", 310 | "🌔", 311 | "☝️", 312 | "🐞", 313 | "😰", 314 | "🐕", 315 | "🐧", 316 | "🦆", 317 | "⛈", 318 | "🌙", 319 | "🐚", 320 | "😔", 321 | "👐", 322 | "🐓", 323 | "👸", 324 | "👳", 325 | "👆", 326 | "😢", 327 | "🐵", 328 | "🐃", 329 | "☘️", 330 | "💨", 331 | "😱", 332 | "👈", 333 | "🎅", 334 | "🌷", 335 | "😡", 336 | "🐜", 337 | "🐗", 338 | "🦏", 339 | "🌿", 340 | "🌵", 341 | "😬", 342 | "👋", 343 | "💑", 344 | "🐟", 345 | "🌞", 346 | "🌥", 347 | "😟", 348 | "😦", 349 | "😒", 350 | "💧", 351 | "💩", 352 | "🐰", 353 | "🤖", 354 | "💂", 355 | "🐴", 356 | "🦎", 357 | "🍃", 358 | "😨", 359 | "🐢", 360 | "🦃", 361 | "🦒", 362 | "👦", 363 | "🐼", 364 | "🌴", 365 | "🐮", 366 | "🙉", 367 | "👪", 368 | "🐦", 369 | "💫", 370 | "🌻", 371 | "🐏", 372 | "🐤", 373 | "🐂", 374 | "🎄", 375 | "🌤", 376 | "👶", 377 | "🙊", 378 | "🐍", 379 | "🐺", 380 | "🦌", 381 | "🌼", 382 | "🦛", 383 | "😅", 384 | "👏", 385 | "😭", 386 | "🦀", 387 | "🐄", 388 | "🐿", 389 | "🐛", 390 | "👧", 391 | "🐒", 392 | "🌳", 393 | "🐝", 394 | "🐣", 395 | "🎋", 396 | "💦", 397 | "😝", 398 | "🐠", 399 | "🐐", 400 | "🦔", 401 | "💀", 402 | "😠", 403 | "👃", 404 | "🐳", 405 | "🐫", 406 | "🦙", 407 | "🌓", 408 | "👩" 409 | ], 410 | "emoji_right": [ 411 | "🎵", 412 | "♣️", 413 | "🔠", 414 | "🃏", 415 | "↩️", 416 | "♌️", 417 | "🔤", 418 | "◀️", 419 | "⏹", 420 | "⏏️", 421 | "♓️", 422 | "🔞", 423 | "🚸", 424 | "💲", 425 | "🚯", 426 | "🚭", 427 | "🔚", 428 | "📵", 429 | "💢", 430 | "↪️", 431 | "♏️", 432 | "⏰", 433 | "♠️", 434 | "⛎", 435 | "➗", 436 | "🔡", 437 | "⏭", 438 | "❕", 439 | "⭕️", 440 | "🔼", 441 | "❓", 442 | "🔻", 443 | "🔽", 444 | "💭", 445 | "🔝", 446 | "❔", 447 | "🔙", 448 | "⏪", 449 | "♦️", 450 | "➖", 451 | "♋️", 452 | "🚳", 453 | "↖️", 454 | "🔅", 455 | "🚱", 456 | "♎️", 457 | "⏯", 458 | "↔️", 459 | "📴", 460 | "🚷", 461 | "♾", 462 | "©️", 463 | "⏸", 464 | "🔛", 465 | "♨️", 466 | "⬇️", 467 | "✖️", 468 | "⁉️", 469 | "♑️", 470 | "🔁", 471 | "♊️", 472 | "⬅️", 473 | "™️", 474 | "↘️", 475 | "🔆", 476 | "❌", 477 | "🗯", 478 | "♈️", 479 | "〽️", 480 | "®️", 481 | "🔀", 482 | "♐️", 483 | "⏬", 484 | "➡️", 485 | "🔕", 486 | "🎶", 487 | "⚠️", 488 | "📛", 489 | "🔺", 490 | "📳", 491 | "🚫", 492 | "⬆️", 493 | "⤴️", 494 | "⤵️", 495 | "⏳", 496 | "♉️", 497 | "♍️", 498 | "🔣", 499 | "⏺", 500 | "↕️", 501 | "↗️", 502 | "🎦", 503 | "💱", 504 | "🔂", 505 | "‼️", 506 | "📶", 507 | "▶️", 508 | "♒️", 509 | "❗️", 510 | "➕", 511 | "⏮", 512 | "↙️", 513 | "🔜", 514 | "⏩", 515 | "➿", 516 | "♥️", 517 | "〰️", 518 | "➰", 519 | "⏫", 520 | "⌛️" 521 | ], 522 | "client_name": [ 523 | "MastoTastico", 524 | "MastoTootville", 525 | "Tootyville Magic", 526 | "MastoLando", 527 | "MastoTootyville", 528 | "Tootyville World", 529 | "Tootastic", 530 | "MastoParty", 531 | "Tootyville USA Heaven", 532 | "MastoNation", 533 | "Tootville USA", 534 | "MastoCity", 535 | "Tootyville Paradise", 536 | "Tooty McTootface", 537 | "MastoVille", 538 | "Tootyland", 539 | "MastoLandia", 540 | "Tootyville Universe", 541 | "MastoTastic", 542 | "Tootyville USA Land", 543 | "TootyFruity", 544 | "MastoTootopia", 545 | "Tootville", 546 | "MastoMania", 547 | "MastoTropolis", 548 | "MastoTootopia USA", 549 | "Tootyville Paradise USA", 550 | "MastoWorld", 551 | "MastoTootastic", 552 | "Tootyville USA World Land", 553 | "MastoLand", 554 | "Tootalicious", 555 | "Tootyville USA", 556 | "Tootyville", 557 | "Tootastico", 558 | "MastoMagic", 559 | "MastoTootalicious", 560 | "Tootopia", 561 | "MastoTootville USA", 562 | "MastoTootyland", 563 | "Mastodonator", 564 | "Tootyville USA World", 565 | "Tootyville USA Magic", 566 | "Tootyville Heaven", 567 | "MastoMash", 568 | "MastoToot", 569 | "Tootyville USA Paradise", 570 | "Tootyville USA Universe" 571 | ], 572 | "boost_verbs": [ 573 | "rebroadcast", 574 | "spread the word", 575 | "boost", 576 | "revitalize", 577 | "reinvigorate", 578 | "reanimate", 579 | "promote", 580 | "propagate", 581 | "resurrect", 582 | "retweet", 583 | "forward", 584 | "amplify", 585 | "share", 586 | "repost", 587 | "echo", 588 | "revive", 589 | "rekindle", 590 | "refresh", 591 | "pass it on", 592 | "resend" 593 | ], 594 | "boost_verbs_past": [ 595 | "rekindled", 596 | "promoted", 597 | "resurrected", 598 | "shared", 599 | "reposted", 600 | "spread the word", 601 | "boosted", 602 | "refreshed", 603 | "amplified", 604 | "echoed", 605 | "passed it on", 606 | "reinvigorated", 607 | "retweeted", 608 | "forwarded", 609 | "revitalized", 610 | "resent", 611 | "rebroadcasted", 612 | "revived", 613 | "reanimated", 614 | "propagated" 615 | ], 616 | "fav_verbs": [ 617 | "love", 618 | "adore", 619 | "delight in", 620 | "reverence", 621 | "swoon over", 622 | "appreciate", 623 | "like", 624 | "venerate", 625 | "idolize", 626 | "treasure", 627 | "dote on", 628 | "esteem", 629 | "cherish", 630 | "admire", 631 | "relish", 632 | "yeah", 633 | "favorite", 634 | "revere", 635 | "worship", 636 | "enjoy" 637 | ], 638 | "reply_verbs": [ 639 | "reiterate", 640 | "acknowledge", 641 | "come back at", 642 | "rejoin", 643 | "reverberate", 644 | "respond", 645 | "echo", 646 | "resound", 647 | "answer", 648 | "ring out", 649 | "refute", 650 | "retort", 651 | "rebut", 652 | "repeat", 653 | "echo back", 654 | "restate", 655 | "counter" 656 | ], 657 | "logged_in_phrases": [ 658 | "Granted access as", 659 | "Verified as", 660 | "Identified as", 661 | "Permitted as", 662 | "Authorized as", 663 | "Authenticated as", 664 | "Logged on as", 665 | "Entered as", 666 | "Recognized as", 667 | "Signed in as", 668 | "Access granted as", 669 | "Confirmed as", 670 | "Checked in as" 671 | ], 672 | "log_out_phrases": [ 673 | "abolish", 674 | "reconsider", 675 | "refuse", 676 | "terminate", 677 | "withdraw", 678 | "quash", 679 | "rescind", 680 | "void", 681 | "negate", 682 | "cancel", 683 | "nullify", 684 | "repeal", 685 | "reject", 686 | "deny", 687 | "annul", 688 | "revoke", 689 | "retract" 690 | ], 691 | "open_verbs": [ 692 | "unwrap", 693 | "unfurl", 694 | "release", 695 | "uncloak", 696 | "uncover", 697 | "open", 698 | "unfold", 699 | "unhide", 700 | "unroll", 701 | "disclose", 702 | "unveil", 703 | "unlock", 704 | "unseal", 705 | "reveal", 706 | "expose", 707 | "unmask" 708 | ], 709 | "other_stuff_phrases": [ 710 | "Further Fediverse frolics", 711 | "Other Mastodon tomfoolery", 712 | "Further Fediverse revelry", 713 | "Further Fediverse escapades", 714 | "Further Fediverse pranks", 715 | "Other Mastodon shenanigans", 716 | "Other Mastodon fun", 717 | "Other Mastodon mischief", 718 | "Further Fediverse capers", 719 | "Other Mastodon escapades", 720 | "Further Fediverse japes", 721 | "Other Mastodon frolics", 722 | "Other Mastodon pranks", 723 | "Further Fediverse shenanigans", 724 | "Other Mastodon revelry", 725 | "Other Mastodon capers", 726 | "Other Mastodon adventures", 727 | "Further Fediverse tomfoolery", 728 | "Other Mastodon japes", 729 | "Further Fediverse hijinks", 730 | "Further Fediverse exploits", 731 | "Further Fediverse fun", 732 | "Further Fediverse mischief", 733 | "Other Mastodon hijinks", 734 | "Other Mastodon exploits", 735 | "Other Mastodon tricks", 736 | "Further Fediverse tricks", 737 | "Further Fediverse adventures" 738 | ], 739 | "next_page_phrases": [ 740 | "Forward ho!", 741 | "Step lively!", 742 | "Onward!", 743 | "Next stop!", 744 | "Come on!", 745 | "To infinity and beyond!", 746 | "Move along!", 747 | "Let's get moving!", 748 | "Keep going!", 749 | "To the future!", 750 | "Follow me!", 751 | "Let's go!", 752 | "Let's go, go, go!" 753 | ], 754 | "prev_page_phrases": [ 755 | "Reverse course!", 756 | "Retreat!", 757 | "Let's go backwards!", 758 | "Return!", 759 | "Reverse!", 760 | "Back to the past!", 761 | "Recede!", 762 | "Let's go back!", 763 | "Turn back!", 764 | "Back we go!", 765 | "Regress!", 766 | "Backtrack!" 767 | ], 768 | "home_page_phrases": [ 769 | "To the heart of the matter!", 770 | "Back to the roost!", 771 | "Back to base!", 772 | "Home, James!", 773 | "To the hearth!", 774 | "Back to the nest!", 775 | "To the nest egg!", 776 | "To the bosom of the family!", 777 | "To the center of the universe!", 778 | "Home sweet home!" 779 | ], 780 | "login_verbs": [ 781 | "enter", 782 | "sign in", 783 | "check in", 784 | "authorize", 785 | "permit", 786 | "recognize", 787 | "identify", 788 | "authenticate", 789 | "grant access", 790 | "verify", 791 | "confirm" 792 | ], 793 | "blurbs": [ 794 | "is a whimsical Mastodon client that replaces all UI strings with words and phrases selected at random from a list.", 795 | "is a quirky Mastodon client that uses words and phrases randomly chosen from a list in place of all UI strings.", 796 | "is a zany and delightful Mastodon client that substitutes all UI strings with words and phrases randomly picked from a list of the most absurd and hilarious words and phrases known to man.", 797 | "is a wacky Mastodon client that replaces all UI strings with words and phrases randomly selected from a list.", 798 | "is a quirky and delightful Mastodon client that uses words and phrases randomly chosen from a list of the most ridiculous and hilarious words and phrases in existence in place of all UI strings.", 799 | "is a strange and wonderful Mastodon client that replaces all UI strings with words and phrases picked randomly from a list of the most ridiculous and absurd words and phrases known to man.", 800 | "is an experimental Mastodon client that is mostly a relatively basic static-page HTML client but all UI strings are replaced by words and phrases randomly picked from a list.", 801 | "is a bizarre Mastodon client that swaps out all UI strings for words and phrases picked at random from a list.", 802 | "is a zany Mastodon client that substitutes all UI strings with words and phrases randomly picked from a list.", 803 | "is a whimsical and delightful Mastodon client that replaces all UI strings with words and phrases selected at random from a list of the most absurd and hilarious words and phrases ever imagined.", 804 | "is a bizarre and surreal Mastodon client that swaps out all UI strings for words and phrases picked at random from a list of the most absurd and nonsensical words and phrases ever conceived." 805 | ], 806 | "enter_url_phrases": [ 807 | "Enter the web address of your instance to begin", 808 | "Enter the internet address of your instance to start", 809 | "Enter the URL of your instance to start", 810 | "Enter your instance's location on the web to begin", 811 | "Enter the location of your instance on the web to start", 812 | "Enter your instance's web address to begin", 813 | "Enter your instance's internet address to begin", 814 | "Enter the address of your instance to start" 815 | ], 816 | "time_formats": [ 817 | "%A %d %B %Y %H:%M:%S", 818 | "%a %d %B %Y %H:%M:%S", 819 | "%Y/%m/%d %H:%M:%S.%f", 820 | "%B %d %Y %H:%M:%S.%f", 821 | "%Y-%m-%dT%H:%M:%S.%f%z", 822 | "%Y-%m-%dT%H:%M:%S.%fZ", 823 | "%B %d %Y %H:%M:%S %Z", 824 | "%B %d %Y %I:%M %p %Z", 825 | "%d %B %Y %I:%M:%S.%f %p", 826 | "%B %d %Y %I:%M:%S %p", 827 | "%d %B %Y %I:%M%p", 828 | "%m/%d/%Y %I:%M %p", 829 | "%Y-%m-%d %H:%M:%S", 830 | "%B %d %Y %I:%M:%S.%f %p", 831 | "%d %B %Y %H:%M:%S %Z", 832 | "SEPTEMBER", 833 | "%d %B %Y %I:%M:%S %p", 834 | "%A %B %d %Y %H:%M:%S.%f %Z", 835 | "%A %d %B %Y %H:%M:%S %Z" 836 | ], 837 | "unboost_verbs": [ 838 | "unrepost", 839 | "silence", 840 | "reduce", 841 | "fade", 842 | "drain", 843 | "demote", 844 | "backward", 845 | "deaden", 846 | "don't pass it on", 847 | "unrebroadcast", 848 | "wilt", 849 | "decrease", 850 | "wither", 851 | "bury", 852 | "spread the opposite", 853 | "kill", 854 | "unretweet", 855 | "unshare", 856 | "extinguish", 857 | "suppress", 858 | "unresend" 859 | ], 860 | "unfav_verbs": [ 861 | "disregard", 862 | "despise", 863 | "disappreciate", 864 | "disenjoy", 865 | "disrelish", 866 | "contempt", 867 | "deify", 868 | "abhor", 869 | "disdote on", 870 | "dislike", 871 | "unyeah", 872 | "frown upon", 873 | "disrevere", 874 | "disesteem", 875 | "detest", 876 | "desecrate", 877 | "hate", 878 | "disfavorite" 879 | ] 880 | } 881 | -------------------------------------------------------------------------------- /09_hellomotoot/hellomotoot.py: -------------------------------------------------------------------------------- 1 | ## 2 | # HelloMoToot - A PSTN Mastodon Client 3 | ## 4 | 5 | from flask import abort, Flask, request, render_template, flash, redirect, session, g, send_file, send_from_directory 6 | from functools import wraps 7 | from twilio.request_validator import RequestValidator 8 | from twilio.twiml.messaging_response import MessagingResponse 9 | from twilio.twiml.voice_response import Gather, VoiceResponse, Say, Play 10 | from mastodon import Mastodon 11 | from io import BytesIO 12 | import urllib.parse 13 | import os 14 | import sqlite3 15 | import random 16 | import string 17 | import qrcode 18 | import re 19 | 20 | ## 21 | # Config 22 | ## 23 | 24 | # Super secret values 25 | TWILIO_NUMBER = "+12318668464" 26 | TWILIO_NUMBER_DISP = "+1 (231) TOOTING / +1 (231) 866 8464" 27 | DB_FILE = "users.db" 28 | BASE_URL = "http://mastolab.kal-tsit.halcy.de/day09/" 29 | 30 | # load secret values from a file excluded from git 31 | with open("twilio.secret", 'r') as f: 32 | lines = f.readlines() 33 | TWILIO_AUTH_TOKEN = lines[0].strip("\n") 34 | SECRET_KEY = lines[1] 35 | 36 | ## 37 | # Basic tooling 38 | ## 39 | 40 | app = Flask(__name__) 41 | app.config['SECRET_KEY'] = SECRET_KEY 42 | 43 | # ensure DB exists 44 | if not os.path.exists(DB_FILE): 45 | connection = sqlite3.connect(DB_FILE) 46 | cur = connection.cursor() 47 | cur.execute(""" 48 | CREATE TABLE users ( 49 | id INTEGER PRIMARY KEY AUTOINCREMENT, 50 | masto_username TEXT, 51 | masto_token TEXT, 52 | masto_app_url TEXT, 53 | phone_number TEXT, 54 | phone_reg_code TEXT 55 | ); 56 | """) 57 | cur.execute(""" 58 | CREATE TABLE app_creds ( 59 | id INTEGER PRIMARY KEY AUTOINCREMENT, 60 | masto_app_url TEXT, 61 | masto_client_id TEXT, 62 | masto_client_secret TEXT 63 | ); 64 | """); 65 | connection.commit() 66 | connection.close() 67 | 68 | # flask session-global DB getter 69 | def db(): 70 | conn = getattr(g, '_database', None) 71 | if conn is None: 72 | conn = g._database = sqlite3.connect(DB_FILE) 73 | conn.row_factory = sqlite3.Row 74 | return conn 75 | 76 | @app.teardown_appcontext 77 | def close_connection(exception): 78 | conn = getattr(g, '_database', None) 79 | if conn is not None: 80 | conn.close() 81 | 82 | # oauth callback URL generator 83 | def get_oauth_callback_url(masto_app_url): 84 | return BASE_URL + "oauth?for=" + urllib.parse.quote(masto_app_url, safe="") 85 | 86 | # masto unauthed API getter, creates app if needed 87 | def get_masto_api_unauthed(masto_app_url): 88 | # get db 89 | conn = db() 90 | cur = conn.cursor() 91 | 92 | # okay, see if we need to make an app 93 | app_data = cur.execute("SELECT masto_client_id, masto_client_secret FROM app_creds WHERE masto_app_url = ?", (masto_app_url, )).fetchall() 94 | if len(app_data) == 0: 95 | masto_client_id, masto_client_secret = Mastodon.create_app("HelloMoToot", api_base_url=masto_app_url, redirect_uris = get_oauth_callback_url(masto_app_url)) 96 | cur.execute("INSERT INTO app_creds (masto_app_url, masto_client_id, masto_client_secret) VALUES (?, ?, ?)", (masto_app_url, masto_client_id, masto_client_secret)) 97 | conn.commit() 98 | else: 99 | masto_client_id = app_data[0]["masto_client_id"] 100 | masto_client_secret = app_data[0]["masto_client_secret"] 101 | 102 | # return unauthed API instance 103 | return Mastodon( 104 | api_base_url = masto_app_url, 105 | client_id = masto_client_id, 106 | client_secret = masto_client_secret 107 | ) 108 | 109 | # user info getter / updater given masto data 110 | def get_user_phone_info(masto_user, masto_app_url, access_token): 111 | # get db 112 | conn = db() 113 | cur = conn.cursor() 114 | 115 | # check if user record exists 116 | user_data = cur.execute("SELECT masto_token, phone_number, phone_reg_code FROM users WHERE masto_username = ? AND masto_app_url = ?", (masto_user, masto_app_url)).fetchall() 117 | if len(user_data) == 0: 118 | # no? create it 119 | phone_number = "" 120 | phone_reg_code = masto_user + "-" + masto_app_url + "-" + ''.join(random.sample(string.ascii_letters+string.digits, 20)).lower() 121 | cur.execute("INSERT INTO users (masto_username, masto_token, masto_app_url, phone_number, phone_reg_code) VALUES (?, ?, ?, ?, ?)", (masto_user, access_token, masto_app_url, "", phone_reg_code)) 122 | conn.commit() 123 | else: 124 | # yes? update if needed and return 125 | access_token_stored = user_data[0]["masto_token"] 126 | phone_number = user_data[0]["phone_number"] 127 | phone_reg_code = user_data[0]["phone_reg_code"] 128 | 129 | if access_token != access_token_stored: 130 | cur.execute("UPDATE users SET masto_token = ? WHERE masto_username = ? AND masto_app_url = ?", (access_token, masto_user, masto_app_url)) 131 | conn.commit() 132 | 133 | return phone_number, phone_reg_code 134 | 135 | # literally yoinked from twilio tutorial 136 | def validate_twilio_request(f): 137 | """Validates that incoming requests genuinely originated from Twilio""" 138 | @wraps(f) 139 | def decorated_function(*args, **kwargs): 140 | # Create an instance of the RequestValidator class 141 | validator = RequestValidator(TWILIO_AUTH_TOKEN) 142 | 143 | # Validate the request using its URL, POST data, 144 | # and X-TWILIO-SIGNATURE header 145 | request_valid = validator.validate( 146 | request.url.replace("mastolab.kal-tsit.halcy.de,mastolab.kal-tsit.halcy.de", "mastolab.kal-tsit.halcy.de/day09"), 147 | request.form, 148 | request.headers.get('X-TWILIO-SIGNATURE', '')) 149 | 150 | # Continue processing the request if it's valid, return a 403 error if 151 | # it's not 152 | if request_valid: 153 | print("IS VALID") 154 | return f(*args, **kwargs) 155 | else: 156 | return abort(403) 157 | return decorated_function 158 | 159 | # Error helper 160 | def error(text): 161 | flash(text) 162 | return redirect(BASE_URL) 163 | 164 | ## 165 | # Web UI 166 | ## 167 | 168 | # logged auth user landing 169 | @app.route("/") 170 | def index(): 171 | if "masto_app_url" in session and "access_token" in session: 172 | return redirect(BASE_URL + "manage") 173 | return(render_template('index.html')) 174 | 175 | # Oauth flow start 176 | @app.route("/login", methods=['POST']) 177 | def login(): 178 | try: 179 | # get db 180 | conn = db() 181 | cur = conn.cursor() 182 | 183 | # get request params 184 | masto_fulluser = request.form['username'] 185 | masto_user = "@".join(masto_fulluser.split("@")[:-1]) 186 | masto_domain = masto_fulluser.split("@")[-1] 187 | masto_app_url = masto_domain 188 | 189 | # check some basic things 190 | if (not "@" in masto_fulluser) or (len(masto_user) == 0) or (len(masto_domain) == 0) or (len(masto_fulluser) > 255): 191 | return error("Please enter something sensible.") 192 | 193 | api_unauthed = get_masto_api_unauthed(masto_app_url) 194 | oauth_redirect = api_unauthed.auth_request_url(redirect_uris = get_oauth_callback_url(masto_app_url)) 195 | 196 | return redirect(oauth_redirect) 197 | except: 198 | return error("Something broke in oauth flow start, it is unclear what.") 199 | 200 | # Oauth flow end 201 | @app.route("/oauth", methods=['GET']) 202 | def oauth(): 203 | try: 204 | masto_app_url = request.args.get('for') 205 | code = request.args.get('code') 206 | api_unauthed = get_masto_api_unauthed(masto_app_url) 207 | access_token = api_unauthed.log_in(code = code, redirect_uri = get_oauth_callback_url(masto_app_url)) 208 | session["masto_app_url"] = masto_app_url 209 | session["access_token"] = access_token 210 | return redirect(BASE_URL) 211 | except: 212 | return error("Something broke in oauth flow end, it is unclear what.") 213 | 214 | 215 | # qr code generator for registration SMS 216 | @app.route("/qr") 217 | def qr(): 218 | try: 219 | phone_reg_code = request.args.get('phone_reg_code') 220 | boring = request.args.get('boring', 0, type=int) 221 | qr_code_data = "SMSTO:" + TWILIO_NUMBER + ":--register-" + phone_reg_code 222 | qr_code = qrcode.QRCode() 223 | qr_code.add_data(qr_code_data) 224 | qr_code.make() 225 | if boring != 1: 226 | qr_img = qr_code.make_image(fill_color=(254, 254, 254), back_color=(15, 6, 41)) 227 | else: 228 | qr_img = qr_code.make_image(fill_color=(0, 0, 0), back_color=(255, 255, 255)) 229 | img_io = BytesIO() 230 | qr_img.save(img_io, 'PNG') 231 | img_io.seek(0) 232 | return send_file(img_io, mimetype='image/png') 233 | except Exception as e: 234 | print(e) 235 | return error("Something broke in QR code gen, it is unclear what.") 236 | 237 | # authed user landing 238 | @app.route("/manage") 239 | def manage(): 240 | try: 241 | masto_app_url = session["masto_app_url"] 242 | access_token = session["access_token"] 243 | api = Mastodon( 244 | api_base_url = masto_app_url, 245 | access_token = access_token, 246 | ) 247 | masto_user = api.me().acct 248 | 249 | # get phone info 250 | phone_number, phone_reg_code = get_user_phone_info(masto_user, masto_app_url, access_token) 251 | return(render_template('manage.html', 252 | masto_user_full = masto_user + "@" + masto_app_url, 253 | phone_number = phone_number, 254 | phone_reg_code = phone_reg_code, 255 | phone_reg_number = TWILIO_NUMBER_DISP 256 | )) 257 | except: 258 | return error("Something broke, it is unclear what.") 259 | 260 | # session emptier 261 | @app.route("/logout") 262 | def logout(): 263 | session.clear() 264 | return redirect("/") 265 | 266 | ### 267 | # Messaging 268 | ### 269 | # twilio webhook landing for SMS 270 | @app.route('/message', methods=['POST']) 271 | @validate_twilio_request 272 | def incoming_message(): 273 | try: 274 | message = request.values["Body"] 275 | phone_number = request.values["From"] 276 | if message.startswith("--register-"): 277 | handle_sms_reg(phone_number, message[11:]) 278 | resp = MessagingResponse() 279 | resp.message("Thank you for registering - you are now ready to post. Simply send your messages to this phone number!") 280 | return str(resp) 281 | else: 282 | success = handle_sms_post(phone_number, message) 283 | if not success: 284 | resp = MessagingResponse() 285 | resp.message("Something went wrong - did you register properly? You can check on " + BASE_URL) 286 | return resp 287 | else: 288 | return "" 289 | except: 290 | resp = MessagingResponse() 291 | resp.message("Something went wrong, it is unclear what.") 292 | return resp 293 | 294 | # phone number reg handler 295 | def handle_sms_reg(phone_number, code): 296 | # get db 297 | conn = db() 298 | cur = conn.cursor() 299 | 300 | # update user from code 301 | cur.execute("UPDATE users SET phone_number = ? WHERE phone_reg_code = ?", (phone_number, code)) 302 | conn.commit() 303 | 304 | # posting handler 305 | def handle_sms_post(phone_number, message): 306 | # get db 307 | conn = db() 308 | cur = conn.cursor() 309 | 310 | # get token given phone number 311 | user_data = cur.execute("SELECT masto_token, masto_app_url FROM users WHERE phone_number = ?", (phone_number,)).fetchall() 312 | if len(user_data) != 0: 313 | access_token = user_data[0]["masto_token"] 314 | masto_app_url = user_data[0]["masto_app_url"] 315 | api = Mastodon( 316 | api_base_url = masto_app_url, 317 | access_token = access_token, 318 | ) 319 | api.toot(message) 320 | return True 321 | else: 322 | return False 323 | 324 | ## 325 | # Voice 326 | ## 327 | 328 | # audio files server 329 | @app.route('/audio/') 330 | def audio(path): 331 | if ".." in path or not os.path.exists("audio/" + path): # Bonus paranoia 332 | return abort(403) 333 | else: 334 | return send_from_directory('audio', path) 335 | 336 | # twilio webhook landing for incoming call 337 | @app.route("/call", methods=['GET', 'POST']) 338 | @validate_twilio_request 339 | def call(): 340 | resp = VoiceResponse() 341 | 342 | # get db 343 | conn = db() 344 | cur = conn.cursor() 345 | 346 | # get token given phone number 347 | phone_number = request.values["From"] 348 | user_data = cur.execute("SELECT masto_token, masto_app_url FROM users WHERE phone_number = ?", (phone_number,)).fetchall() 349 | if len(user_data) == 0: 350 | # play signup reminder and stop 351 | resp.play(BASE_URL + "/audio/pleaseregister.mp3", loop=1) 352 | resp.hangup() 353 | else: 354 | # gather either normally, or with the (fake) waiting queue (30% chance) 355 | if random.random() < 0.3: 356 | gather = Gather(num_digits=1, action='/menu_option', timeout=1) 357 | gather.play(BASE_URL + "/audio/pleasehold.mp3", loop=1) 358 | else: 359 | gather = Gather(num_digits=1, action='/menu_option', timeout=1) 360 | gather.play(BASE_URL + "/audio/intro.mp3", loop=1) 361 | resp.append(gather) 362 | 363 | # if the user doesn't select an option, go directly to the questionaire 364 | resp.redirect('/questionaire') 365 | 366 | return str(resp) 367 | 368 | # twilio voice menu selection 369 | @app.route('/menu_option', methods=['GET', 'POST']) 370 | @validate_twilio_request 371 | def menu_option(): 372 | try: 373 | resp = VoiceResponse() 374 | 375 | if 'Digits' in request.values: 376 | choice = request.values['Digits'] 377 | 378 | if choice == '1': 379 | resp.play(BASE_URL + "/audio/1_recordmessage.mp3") 380 | resp.play(BASE_URL + "/audio/beep.mp3") 381 | resp.gather(input="speech", action="/speechpost", speech_timeout = 3, profanity_filter = False) 382 | elif choice == '2': 383 | resp.play(BASE_URL + "/audio/2_mentions.mp3") 384 | 385 | # get db 386 | conn = db() 387 | cur = conn.cursor() 388 | 389 | # get token given phone number 390 | phone_number = request.values["From"] 391 | user_data = cur.execute("SELECT masto_token, masto_app_url FROM users WHERE phone_number = ?", (phone_number,)).fetchall() 392 | 393 | # fetch mentions 394 | access_token = user_data[0]["masto_token"] 395 | masto_app_url = user_data[0]["masto_app_url"] 396 | api = Mastodon( 397 | api_base_url = masto_app_url, 398 | access_token = access_token, 399 | ) 400 | mentions = api.notifications(limit=10, exclude_types = ["follow", "favourite", "reblog", "poll", "follow_request"]) 401 | 402 | # Say mentions 403 | for mention in mentions: 404 | text_content = re.sub('<[^<]+?>', '', mention.status.content) 405 | resp.say("From {} at {}: {}.".format(mention.status.account.acct, mention.status.created_at.strftime("%B %d, %H:%M o'clock"), text_content)) 406 | resp.pause(length=2) 407 | 408 | elif choice == '3': 409 | resp.play(BASE_URL + "/audio/3_boopsound.mp3") 410 | resp.play(BASE_URL + "/audio/boop.mp3") 411 | else: 412 | resp.play(BASE_URL + "/audio/error.mp3") 413 | resp.hangup() 414 | return str(resp) 415 | 416 | # go to questionaire after unless we're now in a different handler 417 | resp.redirect('/questionaire') 418 | 419 | return str(resp) 420 | except: 421 | resp = VoiceResponse() 422 | resp.play(BASE_URL + "/audio/error.mp3") 423 | resp.hangup() 424 | return str(resp) 425 | 426 | # twilio speech rec post 427 | @app.route('/speechpost', methods=['GET', 'POST']) 428 | @validate_twilio_request 429 | def speechpost(): 430 | try: 431 | resp = VoiceResponse() 432 | post_text = request.values["SpeechResult"] 433 | 434 | # get db 435 | conn = db() 436 | cur = conn.cursor() 437 | 438 | # get token given phone number 439 | phone_number = request.values["From"] 440 | user_data = cur.execute("SELECT masto_token, masto_app_url FROM users WHERE phone_number = ?", (phone_number,)).fetchall() 441 | 442 | access_token = user_data[0]["masto_token"] 443 | masto_app_url = user_data[0]["masto_app_url"] 444 | api = Mastodon( 445 | api_base_url = masto_app_url, 446 | access_token = access_token, 447 | ) 448 | api.toot(post_text) 449 | 450 | resp = VoiceResponse() 451 | resp.redirect('/questionaire') 452 | return str(resp) 453 | except: 454 | resp = VoiceResponse() 455 | resp.play(BASE_URL + "/audio/error.mp3") 456 | resp.hangup() 457 | return str(resp) 458 | 459 | # twilio questionaire 460 | @app.route('/questionaire', methods=['GET', 'POST']) 461 | @validate_twilio_request 462 | def questionaire(): 463 | try: 464 | resp = VoiceResponse() 465 | 466 | gather = Gather(num_digits=1, action='/questionaire_resp', timeout=1) 467 | gather.play(BASE_URL + "/audio/pleaserate.mp3", loop=1) 468 | resp.append(gather) 469 | 470 | # no answer? go to response anyways 471 | resp.redirect('/questionaire_resp') 472 | return str(resp) 473 | except: 474 | resp = VoiceResponse() 475 | resp.play(BASE_URL + "/audio/error.mp3") 476 | resp.hangup() 477 | return str(resp) 478 | 479 | # twilio questionaire response 480 | @app.route('/questionaire_resp', methods=['GET', 'POST']) 481 | @validate_twilio_request 482 | def questionaire_resp(): 483 | try: 484 | resp = VoiceResponse() 485 | 486 | if 'Digits' in request.values: 487 | choice = request.values['Digits'] 488 | 489 | if choice in ['1', '2', '3', '4', '5']: 490 | # regular goodbye. could in theory save ratings but, you know. 491 | resp.play(BASE_URL + "/audio/correctrating.mp3") 492 | else: 493 | # annoyed goodbye 494 | resp.play(BASE_URL + "/audio/wrongrating.mp3") 495 | else: 496 | # no answer? annoyed goodbye. 497 | resp.play(BASE_URL + "/audio/wrongrating.mp3") 498 | 499 | resp.hangup() 500 | return str(resp) 501 | except: 502 | resp = VoiceResponse() 503 | resp.play(BASE_URL + "/audio/error.mp3") 504 | resp.hangup() 505 | return str(resp) 506 | 507 | # favicon 508 | @app.route('/favicon.ico') 509 | def favicon(): 510 | return send_from_directory(os.path.join(app.root_path), 'favicon.ico', mimetype='image/vnd.microsoft.icon') 511 | 512 | if __name__ == '__main__': 513 | app.run(port=5555, host="0.0.0.0", debug=True) 514 | 515 | -------------------------------------------------------------------------------- /03_clippy/clippy.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import re 5 | import random 6 | import logging 7 | import threading 8 | import traceback 9 | 10 | from flask import Flask, session, render_template, redirect, request, abort, g 11 | import sqlite3 12 | 13 | import numpy as np 14 | import torch 15 | from torch.nn.functional import cosine_similarity 16 | from fast_pytorch_kmeans import KMeans 17 | from transformers import CLIPProcessor, CLIPModel 18 | 19 | sys.path.append("../tooling/") 20 | import secret_registry 21 | import app_data_registry 22 | import validators 23 | 24 | from mastodon import Mastodon 25 | 26 | # Settings 27 | CLIENT_NAME = "Clippy" 28 | APP_PREFIX = "day03_clippy" 29 | MASTO_SECRET = os.environ["MASTODON_SECRET"] 30 | SCOPES_TO_REQUEST = ["read:accounts", "read:statuses", "read:lists", "write:lists", "read:follows", "write:follows", "read:search"] 31 | OAUTH_TARGET_URL = "https://mastolab.kal-tsit.halcy.de/day03/auth" 32 | APP_BASE_URL = "/day03/" 33 | SIMILAR_USERS_COUNT = 7 34 | SECONDS_BETWEEN_REFRESH = 60 * 60 * 3 35 | 36 | # Logging setup 37 | logging.basicConfig( 38 | stream = sys.stdout, 39 | format = "%(levelname)s %(asctime)s - %(message)s", 40 | level = logging.INFO 41 | ) 42 | 43 | # Set the secret key to a random value 44 | app = Flask(__name__) 45 | app_data_registry.set_flask_session_info(app, APP_PREFIX) 46 | 47 | clip_model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32") 48 | clip_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32") 49 | 50 | # DB stuff 51 | def get_db(): 52 | # Connect to DB 53 | db = getattr(g, '_database', None) 54 | if db is None: 55 | db = sqlite3.connect(app_data_registry.get_db_file(APP_PREFIX)) 56 | db.row_factory = sqlite3.Row 57 | g._database = db 58 | return db 59 | 60 | def query_db(query, args=(), single = False): 61 | with app.app_context(): 62 | # SQL query function 63 | db = get_db() 64 | cursor = db.execute(query, args) 65 | data = cursor.fetchall() 66 | cursor.close() 67 | db.commit() 68 | return (data[0] if data else None) if single else data 69 | 70 | @app.teardown_appcontext 71 | def close_connection(exception): 72 | # Close DB connection on teardown 73 | db = getattr(g, '_database', None) 74 | if db is not None: 75 | db.close() 76 | 77 | # Set up the DB initially, if empty 78 | table_exists = query_db("SELECT name FROM sqlite_master WHERE type='table' AND name='users'", single = True) is not None 79 | if not table_exists: 80 | query = """ 81 | CREATE TABLE users ( 82 | account TEXT NOT NULL UNIQUE, 83 | last_update NUMERIC, 84 | embed1 TEXT, 85 | embed2 TEXT, 86 | embed3 TEXT 87 | ) 88 | """ 89 | query_db(query) 90 | 91 | table_exists = query_db("SELECT name FROM sqlite_master WHERE type='table' AND name='suggestions'", single = True) is not None 92 | if not table_exists: 93 | query = """ 94 | CREATE TABLE suggestions ( 95 | account TEXT NOT NULL UNIQUE, 96 | suggestions TEXT, 97 | mode TEXT 98 | ) 99 | """ 100 | query_db(query) 101 | 102 | # User insert / update / delete 103 | def db_insert_user(account): 104 | query = """ 105 | INSERT OR IGNORE INTO users (account, last_update, embed1, embed2, embed3) 106 | VALUES (?, 0, "", "", "") 107 | """ 108 | query_db(query, (account,)) 109 | query = """ 110 | INSERT OR IGNORE INTO suggestions (account, suggestions, mode) 111 | VALUES (?, "", "show") 112 | """ 113 | query_db(query, (account,)) 114 | 115 | def db_update_user(account, embed1, embed2, embed3, reset=False): 116 | query = """ 117 | UPDATE users 118 | SET last_update = ?, embed1 = ?, embed2 = ?, embed3 = ? 119 | WHERE account = ? 120 | """ 121 | embed1 = " ".join(map(str, embed1)) 122 | embed2 = " ".join(map(str, embed2)) 123 | embed3 = " ".join(map(str, embed3)) 124 | update_time = int(time.time()) 125 | if reset == True: 126 | update_time = 0 127 | query_db(query, (update_time, embed1, embed2, embed3, account)) 128 | 129 | def db_delete_user(account): 130 | query = "DELETE FROM users WHERE account = ?" 131 | query_db(query, (account,)) 132 | query = "DELETE FROM suggestions WHERE account = ?" 133 | query_db(query, (account,)) 134 | 135 | def db_update_suggestions(account, suggestions): 136 | query = """ 137 | UPDATE suggestions 138 | SET suggestions = ? 139 | WHERE account = ? 140 | """ 141 | query_db(query, (" ".join(suggestions), account)) 142 | 143 | # Select a user for update 144 | def db_next_update_user(): 145 | query = """ 146 | SELECT account 147 | FROM users 148 | WHERE last_update < ? 149 | ORDER BY RANDOM() 150 | LIMIT 1 151 | """ 152 | the_past = int(time.time()) - SECONDS_BETWEEN_REFRESH 153 | update_user = query_db(query, (the_past,), single = True) 154 | if update_user is None: 155 | return None 156 | else: 157 | return update_user[0] 158 | 159 | # Get embeds, denormalized 160 | def db_get_other_users_embeds(account): 161 | query = """ 162 | SELECT account, embed1 AS embed 163 | FROM users 164 | WHERE account != ? AND embed1 != '' 165 | UNION ALL 166 | SELECT account, embed2 167 | FROM users 168 | WHERE account != ? AND embed1 != '' 169 | UNION ALL 170 | SELECT account, embed3 171 | FROM users 172 | WHERE account != ? AND embed1 != '' 173 | """ 174 | results = query_db(query, (account, account, account)) 175 | users = [] 176 | embeds = [] 177 | for result in results: 178 | users.append(result[0]) 179 | embeds.append(result[1]) 180 | return users, np.array(list(map(lambda x: list(map(float, x.split(" "))), embeds))) 181 | 182 | # Get current suggestions 183 | def db_current_suggestions(account): 184 | query = """ 185 | SELECT suggestions 186 | FROM suggestions 187 | WHERE account = ? 188 | LIMIT 1 189 | """ 190 | suggestions = query_db(query, (account,), single = True) 191 | if suggestions is None: 192 | return None 193 | suggestions = suggestions[0].strip() 194 | if len(suggestions) == 0: 195 | return None 196 | suggestions = suggestions.split(" ") 197 | if len(suggestions) == 0: 198 | return None 199 | return suggestions 200 | 201 | # "show" or "follow" mode 202 | def db_get_follow_mode(account): 203 | query = """ 204 | SELECT mode 205 | FROM suggestions 206 | WHERE account = ? 207 | LIMIT 1 208 | """ 209 | mode = query_db(query, (account,), single = True) 210 | return mode[0] 211 | 212 | def db_set_follow_mode(account, mode): 213 | query = """ 214 | UPDATE suggestions 215 | SET mode = ? 216 | WHERE account = ? 217 | """ 218 | mode = query_db(query, (mode, account, )) 219 | 220 | # Embed cosine similarity calculator 221 | def cosine_sim(user_embed, other_embeds): 222 | with torch.no_grad(): 223 | user_embed = torch.tensor(user_embed) 224 | other_embeds = torch.tensor(other_embeds) 225 | cosine_similarity_data = cosine_similarity(user_embed, other_embeds) 226 | return cosine_similarity_data.squeeze().cpu().numpy() 227 | 228 | # Pytorch stuff 229 | def get_model(): 230 | global clip_model 231 | global clip_processor 232 | return clip_model, clip_processor 233 | 234 | def get_clustered_clip_encodings(text_list): 235 | with app.app_context(): 236 | model, processor = get_model() 237 | with torch.no_grad(): 238 | inputs = processor(text = text_list, return_tensors="pt", padding=True, truncation=True) 239 | clip_data = model.get_text_features(**inputs) 240 | clustering = KMeans(n_clusters=8, mode='cosine', max_iter = 100) 241 | clustering.fit(clip_data) 242 | clusters = clustering.centroids.cpu().numpy() 243 | return clusters 244 | 245 | def get_client_credential(instance): 246 | # Try to be permissive 247 | if "://" in instance: 248 | instance = "://".join(instance.split("://")[1:]) 249 | if "/" in instance: 250 | instance = instance.split("/")[0] 251 | 252 | # Validate 253 | if not instance.startswith("http://") or instance.startswith("https://"): 254 | instance = "https://" + instance 255 | if not validators.url(instance): 256 | abort(500) 257 | 258 | # Lock credential DB 259 | secret_registry.lock_credentials() 260 | 261 | try: 262 | # Get app credential, register new if needed 263 | client_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "client") 264 | if not secret_registry.have_credential(client_credential): 265 | Mastodon.create_app( 266 | CLIENT_NAME, 267 | api_base_url = instance, 268 | scopes = SCOPES_TO_REQUEST, 269 | to_file = client_credential, 270 | redirect_uris = [OAUTH_TARGET_URL] 271 | ) 272 | secret_registry.update_meta_time(client_credential) 273 | except: 274 | client_credential = None 275 | 276 | # Unlock credential DB 277 | secret_registry.release_credentials() 278 | 279 | return client_credential 280 | 281 | # User updater code 282 | def insecure_strip_html(html): 283 | return re.sub('<[^<]+?>', '', html) 284 | 285 | def update_account(username, instance): 286 | # Get login 287 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", username) 288 | api = Mastodon(access_token = user_credential, request_timeout = 10) 289 | 290 | # Fetch statuses 291 | me = api.me() 292 | statuses_fetch = api.account_statuses(me, limit = 40) 293 | statuses = [] 294 | for i in range(10): 295 | if statuses_fetch is None: 296 | break 297 | for status in statuses_fetch: 298 | status_text = insecure_strip_html(status.content).strip() 299 | if len(status_text) > 0: 300 | statuses.append(status.content) 301 | if len(statuses) >= 100: 302 | break 303 | if len(statuses) < 100: 304 | statuses_fetch = api.fetch_next(statuses_fetch) 305 | else: 306 | break 307 | if len(statuses) < 3: 308 | return 309 | logging.info("Updating based on " + str(len(statuses)) + " statuses.") 310 | 311 | # Embed and cluster 312 | embeds = get_clustered_clip_encodings(statuses) 313 | account = "{}@{}".format(username, instance) 314 | 315 | # Now, calculate cosine similarity with all other embeds 316 | other_users, other_embeds = db_get_other_users_embeds(account) 317 | if len(other_users) == 0: 318 | db_update_user(account, embeds[0, :], embeds[1, :], embeds[2, :]) 319 | return 320 | 321 | user_list = other_users + other_users + other_users 322 | distance_list = [] 323 | for i in range(3): 324 | distance_list.extend(cosine_sim(embeds[i, :], other_embeds)) 325 | 326 | # Select n random users 327 | similar_user_list = set() 328 | user_choices = random.choices(user_list, weights = distance_list, k = SIMILAR_USERS_COUNT * 9 * 2) 329 | for user in user_choices: 330 | similar_user_list.add(user) 331 | if len(similar_user_list) >= SIMILAR_USERS_COUNT: 332 | break 333 | similar_user_list = list(similar_user_list) 334 | 335 | # To db 336 | db_update_suggestions(account, similar_user_list) 337 | reset = False 338 | if len(similar_user_list) < SIMILAR_USERS_COUNT: 339 | reset = True 340 | db_update_user(account, embeds[0, :], embeds[1, :], embeds[2, :], reset) 341 | 342 | # Try following, if requested 343 | follow_mode = db_get_follow_mode(account) 344 | if follow_mode == "follow": 345 | masto_list_id = None 346 | for masto_list in api.lists(): 347 | if masto_list.title == "clippy users": 348 | masto_list_id = masto_list.id 349 | break 350 | if masto_list_id is None: 351 | masto_list_id = api.list_create("clippy users") 352 | for user in similar_user_list: 353 | try: 354 | user = api.search("@" + user, result_type="accounts").accounts[0] 355 | except Exception as e: 356 | logging.warning("Could not find user: " + str(e)) 357 | try: 358 | api.account_follow(user) 359 | except Exception as e: 360 | logging.warning("Could not follow user: " + str(e)) 361 | try: 362 | api.list_accounts_add(masto_list_id, user) 363 | except Exception as e: 364 | if not "Account has already been taken" in str(e): 365 | logging.warning("Could not list user: " + str(e)) 366 | 367 | def refresh_worker(): 368 | # Background user refresh worker 369 | while True: 370 | next_user = db_next_update_user() 371 | 372 | if next_user is not None: 373 | try: 374 | logging.info("Running update for " + next_user) 375 | username, instance = next_user.split("@") 376 | update_account(username, instance) 377 | time.sleep(5) 378 | except Exception as e: 379 | _, _, exc_tb = sys.exc_info() 380 | logging.warning("Error on user update for " + next_user + ": " + str(e)) 381 | traceback.print_exc() 382 | 383 | @app.route('/switchfollow') 384 | def switchfollow(): 385 | # See if we have suggestions available 386 | user_name = session["user_name"] 387 | instance = session["instance"] 388 | 389 | # Try to be permissive but also paranoid 390 | if "://" in instance: 391 | instance = "://".join(instance.split("://")[1:]) 392 | if "/" in instance: 393 | instance = instance.split("/")[0] 394 | 395 | account = "{}@{}".format(user_name, instance) 396 | follow_mode = db_get_follow_mode(account) 397 | if follow_mode == "show": 398 | db_set_follow_mode(account, "follow") 399 | else: 400 | db_set_follow_mode(account, "show") 401 | return redirect(APP_BASE_URL) 402 | 403 | @app.route('/') 404 | def index(): 405 | # Check if the user is already logged in 406 | if not "logged_in" in session or session["logged_in"] == False: 407 | # Render the login page if the user is not logged in 408 | return render_template('login.htm') 409 | else: 410 | # See if we have suggestions available 411 | user_name = session["user_name"] 412 | instance = session["instance"] 413 | 414 | # Try to be permissive but also paranoid 415 | if "://" in instance: 416 | instance = "://".join(instance.split("://")[1:]) 417 | if "/" in instance: 418 | instance = instance.split("/")[0] 419 | 420 | account = "{}@{}".format(user_name, instance) 421 | suggestions = db_current_suggestions(account) 422 | 423 | # Show suggestions or processing indicator 424 | follow_mode = db_get_follow_mode(account) 425 | follow_mode_switch = "on" 426 | if follow_mode == "follow": 427 | follow_mode_switch = "off" 428 | if suggestions is None: 429 | return render_template( 430 | 'authed.htm', 431 | account=account, 432 | processing = True, 433 | followmode = follow_mode_switch 434 | ) 435 | else: 436 | account_links = ["https://" + instance + "/@" + x for x in suggestions] 437 | links = list(zip(suggestions, account_links)) 438 | return render_template( 439 | 'authed.htm', 440 | account="{}@{}".format(user_name, instance), 441 | processing = False, 442 | links = links, 443 | followmode = follow_mode_switch 444 | ) 445 | 446 | @app.route('/revoke') 447 | def revoke(): 448 | # See if we have suggestions available 449 | user_name = session["user_name"] 450 | instance = session["instance"] 451 | 452 | # Try to be permissive but also paranoid 453 | if "://" in instance: 454 | instance = "://".join(instance.split("://")[1:]) 455 | if "/" in instance: 456 | instance = instance.split("/")[0] 457 | 458 | # Lock credential DB 459 | secret_registry.lock_credentials() 460 | 461 | # Revoke token 462 | try: 463 | user_credential = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 464 | secret_registry.revoke_credential(user_credential) 465 | except: 466 | pass 467 | 468 | # Remove from DB 469 | db_delete_user(user_name + "@" + instance) 470 | 471 | # Also clear the session 472 | session["logged_in"] = False 473 | try: 474 | del session["user_name"] 475 | except: 476 | pass 477 | try: 478 | del session["instance"] 479 | except: 480 | pass 481 | 482 | # Unlock credential DB 483 | secret_registry.release_credentials() 484 | 485 | # Back to root 486 | return redirect(APP_BASE_URL) 487 | 488 | @app.route('/auth') 489 | def auth(): 490 | # Get the oauth code 491 | oauth_code = request.args.get('code') 492 | instance = request.args.get('state') 493 | 494 | # Try to be permissive 495 | if "://" in instance: 496 | instance = "://".join(instance.split("://")[1:]) 497 | if "/" in instance: 498 | instance = instance.split("/")[0] 499 | 500 | # Get client credential and create API 501 | client_credential = get_client_credential(instance) 502 | api = Mastodon(client_id = client_credential) 503 | 504 | # Lock credential DB 505 | secret_registry.lock_credentials() 506 | 507 | # Log in 508 | try: 509 | user_credential_temp = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", oauth_code) 510 | api.log_in( 511 | code = oauth_code, 512 | to_file = user_credential_temp, 513 | scopes = SCOPES_TO_REQUEST, 514 | redirect_uri = OAUTH_TARGET_URL, 515 | ) 516 | secret_registry.update_meta_time(user_credential_temp) 517 | 518 | # Move to final place 519 | user_name = api.me().acct.split("@")[0] 520 | user_credential_final = secret_registry.get_name_for(APP_PREFIX, MASTO_SECRET, instance, "user", user_name) 521 | secret_registry.move_credential(user_credential_temp, user_credential_final) 522 | 523 | # Add to DB 524 | db_insert_user(user_name + "@" + instance) 525 | 526 | # Store session data 527 | session["logged_in"] = True 528 | session["user_name"] = user_name 529 | session["instance"] = instance 530 | except Exception as e: 531 | # Error handling (showing the user that something went wrong) is future work 532 | logging.warning("Auth error" + str(e)) 533 | 534 | # Unlock credential DB 535 | secret_registry.release_credentials() 536 | 537 | return redirect(APP_BASE_URL) 538 | 539 | @app.route('/login', methods=['POST']) 540 | def login(): 541 | try: 542 | # Get a client 543 | instance = request.form['instance'] 544 | client_credential = get_client_credential(instance) 545 | 546 | # Try to be permissive 547 | if "://" in instance: 548 | instance = "://".join(instance.split("://")[1:]) 549 | if "/" in instance: 550 | instance = instance.split("/")[0] 551 | 552 | # Create a Mastodon api instance and generate oauth URL 553 | api = Mastodon(client_id = client_credential) 554 | login_url = api.auth_request_url( 555 | scopes = SCOPES_TO_REQUEST, 556 | redirect_uris = OAUTH_TARGET_URL, 557 | state = instance 558 | ) 559 | 560 | # Redirect the user to the OAuth login URL 561 | return redirect(login_url) 562 | except: 563 | traceback.print_exc() 564 | 565 | # Just clear credentials and redirect to root 566 | session["logged_in"] = False 567 | try: 568 | del session["user_name"] 569 | except: 570 | pass 571 | try: 572 | del session["instance"] 573 | except: 574 | pass 575 | return redirect(APP_BASE_URL) 576 | 577 | # Run update worker 578 | worker_thread = threading.Thread(target=refresh_worker) 579 | worker_thread.start() 580 | 581 | 582 | if __name__ == '__main__': 583 | # Run webapp 584 | app.run() 585 | --------------------------------------------------------------------------------