From 659bc24d1f60e3784b4e0dea539ac8c05d897b0f Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Wed, 19 Mar 2025 18:34:56 -0400 Subject: [PATCH 01/10] Change API calls from Twitter to Bluesky --- bluto/markov.py | 78 ++++++++++++++----------------------------- bluto/public/views.py | 4 +-- 2 files changed, 27 insertions(+), 55 deletions(-) diff --git a/bluto/markov.py b/bluto/markov.py index 1217e1e..612e7d3 100644 --- a/bluto/markov.py +++ b/bluto/markov.py @@ -4,32 +4,34 @@ import re import markovify -import twitter +from atproto import Client, IdResolver -TWEET_LIMIT = 200 +def get_all_posts(username): + """Returns list of text entries for 's last 100 posts'""" + client = Client() -def generate(text_model, size, bound): - """Makes 140 character tweets""" - return [text_model.make_short_sentence(size) for i in range(bound)] + user_identifier = IdResolver().handle.resolve(username) + posts = list(client.app.bsky.feed.post.list(user_identifier, limit=100).records.values()) -def get_all_tweets(username): - """Spawns api and gets last 2000 tweets""" - api = new_api() - tweets = get_tweets(api, username) + return [post.text for post in posts] - for _ in range(20): - tweets += get_tweets(api, username, since=tweets[len(tweets) - 1].id) - return tweets +def get_avatar_url(username): + """Get the URL of 's avatar""" + client = Client() + # Technically the get_profile endpoint shouldn't require authentication + # but right now this works + # if we do end up needing to authenticate for this we should + # login using an exported session string instead of creating a new session every time + client.login(os.getenv("BLUESKY_USERNAME"), os.getenv("BLUESKY_PASSWORD")) -def get_profile_url(api, username): - """Get a big version of the profile image""" - user = (api.GetUser(screen_name=username),) + user_identifier = IdResolver().handle.resolve(username) + profile = client.app.bsky.actor.get_profile({"actor": user_identifier}) - return user[0].profile_image_url.replace("normal", "400x400") + return profile.avatar def remove_twitlonger(tweet_list): @@ -37,43 +39,13 @@ def remove_twitlonger(tweet_list): return [re.sub(r" \S*…[^']*", "", tweet) for tweet in tweet_list] -def make_tweets(username, num_tweets): - """Produce an array of generated tweets""" - api = new_api() - data = remove_twitlonger([tweet.text for tweet in get_all_tweets(username)]) - model = make_markov_model(data) +def make_posts(username, num_posts): + """Produce an array of generated posts""" + data = remove_twitlonger(get_all_posts(username)) + model = markovify.Text(" ".join(data)) return { "username": username, - "profile_url": get_profile_url(api, username), - "tweets": generate(model, 140, num_tweets), - "long": generate(model, 240, 2), - } - - -# Utility -def new_api(): - """Wrapper around spawning twitter api""" - return twitter.Api( - consumer_key=os.environ["TWITTER_API_KEY"], - consumer_secret=os.environ["TWITTER_API_SECRET"], - access_token_key=os.environ["TWITTER_ACCESS_TOKEN"], - access_token_secret=os.environ["TWITTER_ACCESS_SECRET"], - ) - - -def get_tweets(api, username, since=None): - """Wrapper around api request""" - return api.GetUserTimeline( - screen_name=username, - count=TWEET_LIMIT, - include_rts=False, - trim_user=False, - exclude_replies=True, - max_id=since, - ) - - -def make_markov_model(tweets): - """Wrapper around making Markov Chain""" - return markovify.Text(" ".join(tweets)) + "profile_url": get_avatar_url(username), + "tweets": [model.make_short_sentence(140) for i in range(num_posts)], + "long": [model.make_short_sentence(240) for i in range(2)]} diff --git a/bluto/public/views.py b/bluto/public/views.py index e938e75..49b2a20 100644 --- a/bluto/public/views.py +++ b/bluto/public/views.py @@ -17,7 +17,7 @@ def index(): def get_tweets(): """Makes tweets for requested user and return rendered template""" twitter_handle = request.args["twitter_handle"] - tweets = mkv.make_tweets(twitter_handle, 30) + tweets = mkv.make_posts(twitter_handle, 30) return render_template( "public/results.html", @@ -29,7 +29,7 @@ def get_tweets(): @blueprint.route("/api/", methods=["GET"]) def get_api_tweets(twitter_handle): """Makes tweets for requested user and return as json""" - tweets = mkv.make_tweets(twitter_handle, 30) + tweets = mkv.make_posts(twitter_handle, 30) return jsonify(tweets) @blueprint.route("/api/ping", methods=["GET"]) From 861e9bdc19179ae85261434de8996c32aa44b793 Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Wed, 19 Mar 2025 18:34:56 -0400 Subject: [PATCH 02/10] Change API calls from Twitter to Bluesky --- bluto/markov.py | 78 ++++++++++++++----------------------------- bluto/public/views.py | 4 +-- 2 files changed, 27 insertions(+), 55 deletions(-) diff --git a/bluto/markov.py b/bluto/markov.py index 1217e1e..612e7d3 100644 --- a/bluto/markov.py +++ b/bluto/markov.py @@ -4,32 +4,34 @@ import re import markovify -import twitter +from atproto import Client, IdResolver -TWEET_LIMIT = 200 +def get_all_posts(username): + """Returns list of text entries for 's last 100 posts'""" + client = Client() -def generate(text_model, size, bound): - """Makes 140 character tweets""" - return [text_model.make_short_sentence(size) for i in range(bound)] + user_identifier = IdResolver().handle.resolve(username) + posts = list(client.app.bsky.feed.post.list(user_identifier, limit=100).records.values()) -def get_all_tweets(username): - """Spawns api and gets last 2000 tweets""" - api = new_api() - tweets = get_tweets(api, username) + return [post.text for post in posts] - for _ in range(20): - tweets += get_tweets(api, username, since=tweets[len(tweets) - 1].id) - return tweets +def get_avatar_url(username): + """Get the URL of 's avatar""" + client = Client() + # Technically the get_profile endpoint shouldn't require authentication + # but right now this works + # if we do end up needing to authenticate for this we should + # login using an exported session string instead of creating a new session every time + client.login(os.getenv("BLUESKY_USERNAME"), os.getenv("BLUESKY_PASSWORD")) -def get_profile_url(api, username): - """Get a big version of the profile image""" - user = (api.GetUser(screen_name=username),) + user_identifier = IdResolver().handle.resolve(username) + profile = client.app.bsky.actor.get_profile({"actor": user_identifier}) - return user[0].profile_image_url.replace("normal", "400x400") + return profile.avatar def remove_twitlonger(tweet_list): @@ -37,43 +39,13 @@ def remove_twitlonger(tweet_list): return [re.sub(r" \S*…[^']*", "", tweet) for tweet in tweet_list] -def make_tweets(username, num_tweets): - """Produce an array of generated tweets""" - api = new_api() - data = remove_twitlonger([tweet.text for tweet in get_all_tweets(username)]) - model = make_markov_model(data) +def make_posts(username, num_posts): + """Produce an array of generated posts""" + data = remove_twitlonger(get_all_posts(username)) + model = markovify.Text(" ".join(data)) return { "username": username, - "profile_url": get_profile_url(api, username), - "tweets": generate(model, 140, num_tweets), - "long": generate(model, 240, 2), - } - - -# Utility -def new_api(): - """Wrapper around spawning twitter api""" - return twitter.Api( - consumer_key=os.environ["TWITTER_API_KEY"], - consumer_secret=os.environ["TWITTER_API_SECRET"], - access_token_key=os.environ["TWITTER_ACCESS_TOKEN"], - access_token_secret=os.environ["TWITTER_ACCESS_SECRET"], - ) - - -def get_tweets(api, username, since=None): - """Wrapper around api request""" - return api.GetUserTimeline( - screen_name=username, - count=TWEET_LIMIT, - include_rts=False, - trim_user=False, - exclude_replies=True, - max_id=since, - ) - - -def make_markov_model(tweets): - """Wrapper around making Markov Chain""" - return markovify.Text(" ".join(tweets)) + "profile_url": get_avatar_url(username), + "tweets": [model.make_short_sentence(140) for i in range(num_posts)], + "long": [model.make_short_sentence(240) for i in range(2)]} diff --git a/bluto/public/views.py b/bluto/public/views.py index e938e75..49b2a20 100644 --- a/bluto/public/views.py +++ b/bluto/public/views.py @@ -17,7 +17,7 @@ def index(): def get_tweets(): """Makes tweets for requested user and return rendered template""" twitter_handle = request.args["twitter_handle"] - tweets = mkv.make_tweets(twitter_handle, 30) + tweets = mkv.make_posts(twitter_handle, 30) return render_template( "public/results.html", @@ -29,7 +29,7 @@ def get_tweets(): @blueprint.route("/api/", methods=["GET"]) def get_api_tweets(twitter_handle): """Makes tweets for requested user and return as json""" - tweets = mkv.make_tweets(twitter_handle, 30) + tweets = mkv.make_posts(twitter_handle, 30) return jsonify(tweets) @blueprint.route("/api/ping", methods=["GET"]) From c3ed5aaa962a7225276c003fec3dd5715b086864 Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Thu, 20 Mar 2025 12:40:52 -0400 Subject: [PATCH 03/10] Move Behave features directory --- {features => bluto/features}/app_library.feature | 0 {features => bluto/features}/steps/steps.py | 2 +- {features => bluto/features}/test_tweets.txt | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename {features => bluto/features}/app_library.feature (100%) rename {features => bluto/features}/steps/steps.py (96%) rename {features => bluto/features}/test_tweets.txt (100%) diff --git a/features/app_library.feature b/bluto/features/app_library.feature similarity index 100% rename from features/app_library.feature rename to bluto/features/app_library.feature diff --git a/features/steps/steps.py b/bluto/features/steps/steps.py similarity index 96% rename from features/steps/steps.py rename to bluto/features/steps/steps.py index c414b4b..850b71a 100644 --- a/features/steps/steps.py +++ b/bluto/features/steps/steps.py @@ -1,5 +1,5 @@ import behave -import markov_app as mk +import markov as mk # Given @given('a list of tweets') diff --git a/features/test_tweets.txt b/bluto/features/test_tweets.txt similarity index 100% rename from features/test_tweets.txt rename to bluto/features/test_tweets.txt From f0e57dcb630d175f0d1aeb1a956804c16db3db3d Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Thu, 20 Mar 2025 12:57:44 -0400 Subject: [PATCH 04/10] Behave tests pass again --- bluto/features/steps/steps.py | 2 +- bluto/markov.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bluto/features/steps/steps.py b/bluto/features/steps/steps.py index 850b71a..7dd4eaf 100644 --- a/bluto/features/steps/steps.py +++ b/bluto/features/steps/steps.py @@ -13,7 +13,7 @@ def step_impl(context): @when('we generate a {number:d} of new {length:d} character tweets') def step_impl(context, number, length): data = mk.make_markov_model(context.tweet_list) - context.new_tweet_list = mk.generate(data, length, number) + context.new_tweet_list = [data.make_short_sentence(length) for i in range(number)] assert (not(None in context.new_tweet_list)) # Then diff --git a/bluto/markov.py b/bluto/markov.py index 612e7d3..2545ebb 100644 --- a/bluto/markov.py +++ b/bluto/markov.py @@ -4,7 +4,8 @@ import re import markovify -from atproto import Client, IdResolver +from atproto import Client +from atproto import IdResolver def get_all_posts(username): @@ -42,10 +43,15 @@ def remove_twitlonger(tweet_list): def make_posts(username, num_posts): """Produce an array of generated posts""" data = remove_twitlonger(get_all_posts(username)) - model = markovify.Text(" ".join(data)) + model = make_markov_model(data) return { "username": username, "profile_url": get_avatar_url(username), "tweets": [model.make_short_sentence(140) for i in range(num_posts)], "long": [model.make_short_sentence(240) for i in range(2)]} + +# Useful for Behave testing +def make_markov_model(data): + """Wrapper around Markovify call""" + return markovify.Text(" ".join(data)) From 57b208e37bd0728760619d764baef8f951397166 Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Thu, 20 Mar 2025 13:40:18 -0400 Subject: [PATCH 05/10] Add html templates for 401 and 500 error codes (see app.py) --- bluto/templates/401.html | 14 ++++++++++++++ bluto/templates/500.html | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 bluto/templates/401.html create mode 100644 bluto/templates/500.html diff --git a/bluto/templates/401.html b/bluto/templates/401.html new file mode 100644 index 0000000..03fb487 --- /dev/null +++ b/bluto/templates/401.html @@ -0,0 +1,14 @@ +{% extends "layout.html" %} + +{% block page_title %}Unauthorized{% endblock %} +{% block header %}Unauthorized{% endblock %} + +{% block content %} +
+
+

404

+

Sorry, this request is unauthorized.

+

Want to go home instead?

+
+
+{% endblock %} diff --git a/bluto/templates/500.html b/bluto/templates/500.html new file mode 100644 index 0000000..ad73a06 --- /dev/null +++ b/bluto/templates/500.html @@ -0,0 +1,14 @@ +{% extends "layout.html" %} + +{% block page_title %}Internal Service Error{% endblock %} +{% block header %}Internal Service Error{% endblock %} + +{% block content %} +
+
+

500

+

Sorry, there has been an error and the request could not be completed.

+

Want to go home instead?

+
+
+{% endblock %} From 4ac0ee1749c2d6ffbb8e9e4e10675f165918af93 Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Thu, 20 Mar 2025 15:59:25 -0400 Subject: [PATCH 06/10] Fix results page not loading correctly and add shiny new favicon --- bluto/static/favicon.ico | Bin 0 -> 318 bytes bluto/templates/public/results.html | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100755 bluto/static/favicon.ico diff --git a/bluto/static/favicon.ico b/bluto/static/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..4d634e8c6c4516f8fd0beb4c7bd58cb907dc9756 GIT binary patch literal 318 zcmdtY$qj%o3`Ef%i2{7$%rRX;XDNXOkWxa#84)vPX#!hsBrlNEW~Lm-2}U4`$t1Cl gTJC%zZht6u2z`dVK&!GsZKC}qr*!##{ebe$7i~%(O8@`> literal 0 HcmV?d00001 diff --git a/bluto/templates/public/results.html b/bluto/templates/public/results.html index 334566a..ef3532e 100644 --- a/bluto/templates/public/results.html +++ b/bluto/templates/public/results.html @@ -1,5 +1,5 @@ {% extends "layout.html" %} -{% block body %} +{% block content %}
From 8a5433a06aa8076b0c17b6044bf9745dae7e87f0 Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Thu, 20 Mar 2025 18:27:34 -0400 Subject: [PATCH 07/10] Ruff format and lint --- autoapp.py | 1 + bluto/app.py | 4 ++++ bluto/extensions.py | 1 + bluto/features/steps/steps.py | 20 ++++++++++++-------- bluto/markov.py | 14 ++++++++------ bluto/public/__init__.py | 1 + bluto/public/views.py | 8 +++++++- bluto/settings.py | 3 ++- pyproject.toml | 2 +- 9 files changed, 37 insertions(+), 17 deletions(-) diff --git a/autoapp.py b/autoapp.py index 1ab2d2a..36ef795 100644 --- a/autoapp.py +++ b/autoapp.py @@ -1,4 +1,5 @@ """Create an application instance.""" + from bluto.app import create_app app = create_app() diff --git a/bluto/app.py b/bluto/app.py index 90313e8..369c20b 100644 --- a/bluto/app.py +++ b/bluto/app.py @@ -14,6 +14,7 @@ # Use cdn if in production STATIC_URL_PATH = "/static" + def create_app(config_object="bluto.settings"): """Create application factory, as explained here: http://flask.pocoo.org/docs/patterns/appfactories/. @@ -34,11 +35,13 @@ def register_extensions(app): cache.init_app(app) debug_toolbar.init_app(app) + def register_security_headers(app): """Register a bunch of sec.""" if app.config["ENV"] == "production": Talisman(app, force_https=False) + def register_blueprints(app): """Register Flask blueprints.""" app.register_blueprint(public.views.blueprint) @@ -63,4 +66,5 @@ def configure_logger(app): if not app.logger.handlers: app.logger.addHandler(handler) + app = Flask(__name__, static_url_path=STATIC_URL_PATH) diff --git a/bluto/extensions.py b/bluto/extensions.py index ec76fa9..ca6a2f5 100644 --- a/bluto/extensions.py +++ b/bluto/extensions.py @@ -1,5 +1,6 @@ """Extensions module. Each extension is initialized in the app factory located in app.py.""" + from flask_caching import Cache from flask_debugtoolbar import DebugToolbarExtension diff --git a/bluto/features/steps/steps.py b/bluto/features/steps/steps.py index 7dd4eaf..b34ce17 100644 --- a/bluto/features/steps/steps.py +++ b/bluto/features/steps/steps.py @@ -1,24 +1,28 @@ import behave import markov as mk +from pathlib import Path + + # Given -@given('a list of tweets') +@given("a list of tweets") def step_impl(context): - with open("features/test_tweets.txt", "r") as f: + with Path.open("features/test_tweets.txt") as f: content = f.readlines() tweets = [x.strip() for x in content] context.tweet_list = tweets + # When -@when('we generate a {number:d} of new {length:d} character tweets') +@when("we generate a {number:d} of new {length:d} character tweets") def step_impl(context, number, length): data = mk.make_markov_model(context.tweet_list) context.new_tweet_list = [data.make_short_sentence(length) for i in range(number)] - assert (not(None in context.new_tweet_list)) + assert not (None in context.new_tweet_list) + # Then -@then('the {new_number:d} and {new_length:d} of tweets is correct') +@then("the {new_number:d} and {new_length:d} of tweets is correct") def step_impl(context, new_number, new_length): - assert (len(context.new_tweet_list) == new_number) - assert (len(max(context.new_tweet_list, key=len)) <= new_length) - + assert len(context.new_tweet_list) == new_number + assert len(max(context.new_tweet_list, key=len)) <= new_length diff --git a/bluto/markov.py b/bluto/markov.py index 2545ebb..5fe8275 100644 --- a/bluto/markov.py +++ b/bluto/markov.py @@ -12,9 +12,9 @@ def get_all_posts(username): """Returns list of text entries for 's last 100 posts'""" client = Client() - user_identifier = IdResolver().handle.resolve(username) + user_id = IdResolver().handle.resolve(username) - posts = list(client.app.bsky.feed.post.list(user_identifier, limit=100).records.values()) + posts = list(client.app.bsky.feed.post.list(user_id, limit=100).records.values()) return [post.text for post in posts] @@ -26,11 +26,11 @@ def get_avatar_url(username): # Technically the get_profile endpoint shouldn't require authentication # but right now this works # if we do end up needing to authenticate for this we should - # login using an exported session string instead of creating a new session every time + # login using an exported session string instead of creating a new session client.login(os.getenv("BLUESKY_USERNAME"), os.getenv("BLUESKY_PASSWORD")) - user_identifier = IdResolver().handle.resolve(username) - profile = client.app.bsky.actor.get_profile({"actor": user_identifier}) + user_id = IdResolver().handle.resolve(username) + profile = client.app.bsky.actor.get_profile({"actor": user_id}) return profile.avatar @@ -49,7 +49,9 @@ def make_posts(username, num_posts): "username": username, "profile_url": get_avatar_url(username), "tweets": [model.make_short_sentence(140) for i in range(num_posts)], - "long": [model.make_short_sentence(240) for i in range(2)]} + "long": [model.make_short_sentence(240) for i in range(2)], + } + # Useful for Behave testing def make_markov_model(data): diff --git a/bluto/public/__init__.py b/bluto/public/__init__.py index e876f23..cd88abc 100644 --- a/bluto/public/__init__.py +++ b/bluto/public/__init__.py @@ -1,2 +1,3 @@ """The public module, including the homepage and user auth.""" + from . import views # noqa: F401 diff --git a/bluto/public/views.py b/bluto/public/views.py index 49b2a20..82d7ae6 100644 --- a/bluto/public/views.py +++ b/bluto/public/views.py @@ -1,4 +1,5 @@ """Public section, including homepage and signup.""" + from flask import Blueprint from flask import jsonify from flask import render_template @@ -8,11 +9,13 @@ blueprint = Blueprint("public", __name__, static_folder="../../static") + @blueprint.route("/") def index(): """Main page""" return render_template("public/landing.html") + @blueprint.route("/tweets", methods=["GET"]) def get_tweets(): """Makes tweets for requested user and return rendered template""" @@ -24,7 +27,9 @@ def get_tweets(): username=twitter_handle, tweets=tweets["tweets"], long_tweets=tweets["long"], - profile_url=tweets["profile_url"]) + profile_url=tweets["profile_url"], + ) + @blueprint.route("/api/", methods=["GET"]) def get_api_tweets(twitter_handle): @@ -32,6 +37,7 @@ def get_api_tweets(twitter_handle): tweets = mkv.make_posts(twitter_handle, 30) return jsonify(tweets) + @blueprint.route("/api/ping", methods=["GET"]) def ping(): """Simple health check""" diff --git a/bluto/settings.py b/bluto/settings.py index eaa953a..6e92118 100644 --- a/bluto/settings.py +++ b/bluto/settings.py @@ -5,6 +5,7 @@ For local development, use a .env file to set environment variables. """ + from environs import Env env = Env() @@ -16,4 +17,4 @@ SEND_FILE_MAX_AGE_DEFAULT = env.int("SEND_FILE_MAX_AGE_DEFAULT", default=1000000) DEBUG_TB_ENABLED = DEBUG DEBUG_TB_INTERCEPT_REDIRECTS = False -CACHE_TYPE = ("flask_caching.backends.SimpleCache") +CACHE_TYPE = "flask_caching.backends.SimpleCache" diff --git a/pyproject.toml b/pyproject.toml index c8c9a34..b5dcb0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ python_files = [ target-version = "py312" # Exclude a variety of commonly ignored directories. extend-exclude = [ - "features/*" + "bluto/features/*" ] [tool.ruff.lint] From 2d62679c0d714cd3cac3fe1010ce667024613218 Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Thu, 20 Mar 2025 18:31:01 -0400 Subject: [PATCH 08/10] Update requirements --- requirements/prod.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index 117ba5b..599c6a6 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -20,4 +20,4 @@ environs==14.1.1 # Markov markovify==0.7.1 -python-twitter==3.4.1 +atproto==0.0.59 From a84c91dac56c110cecdb4f1ff9754c7604fc45aa Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Thu, 20 Mar 2025 19:10:30 -0400 Subject: [PATCH 09/10] Add Behave testing to CI --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02b9813..966cd52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,3 +21,7 @@ jobs: - name: Check format/lint run: ruff check + + - name: Behave testing + working-directory: ./bluto + run: behave From 909d59c228522bfa211fda3a252c6217f22c5dc8 Mon Sep 17 00:00:00 2001 From: Brian Sort Date: Thu, 20 Mar 2025 20:30:12 -0400 Subject: [PATCH 10/10] Persist the API client session using an exported session string --- .gitignore | 3 ++- bluto/markov.py | 44 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 76f9a9a..808cbdb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ flaskr.egg-info/ .env venv *.swp -.ruff_cache \ No newline at end of file +.ruff_cache +session.txt diff --git a/bluto/markov.py b/bluto/markov.py index 5fe8275..d642c2f 100644 --- a/bluto/markov.py +++ b/bluto/markov.py @@ -2,14 +2,17 @@ import os import re +from pathlib import Path import markovify from atproto import Client from atproto import IdResolver +from atproto import SessionEvent def get_all_posts(username): """Returns list of text entries for 's last 100 posts'""" + # We don't need to authenticate this request client = Client() user_id = IdResolver().handle.resolve(username) @@ -21,15 +24,10 @@ def get_all_posts(username): def get_avatar_url(username): """Get the URL of 's avatar""" - client = Client() - - # Technically the get_profile endpoint shouldn't require authentication - # but right now this works - # if we do end up needing to authenticate for this we should - # login using an exported session string instead of creating a new session - client.login(os.getenv("BLUESKY_USERNAME"), os.getenv("BLUESKY_PASSWORD")) + client = get_client() user_id = IdResolver().handle.resolve(username) + profile = client.app.bsky.actor.get_profile({"actor": user_id}) return profile.avatar @@ -57,3 +55,35 @@ def make_posts(username, num_posts): def make_markov_model(data): """Wrapper around Markovify call""" return markovify.Text(" ".join(data)) + + +# Client handling +def get_session(): + try: + with Path.open("session.txt") as f: + return f.read() + except FileNotFoundError: + return None + + +def save_session(session_string): + with Path.open("session.txt", "w") as f: + f.write(session_string) + + +def on_session_change(event, session): + if event in (SessionEvent.CREATE, SessionEvent.REFRESH): + save_session(session.export()) + + +def get_client(): + client = Client() + client.on_session_change(on_session_change) + + session_string = get_session() + if session_string: + client.login(session_string=session_string) + else: + client.login(os.getenv("BLUESKY_USERNAME"), os.getenv("BLUESKY_PASSWORD")) + + return client