defmodule Core.Syndication do alias __MODULE__ ## mastodon @mastodon_instance Application.compile_env!(:sloanely_but_surely, [ Core.Syndication, :mastodon_instance ]) def get_mastodon_account(user) do Core.Repo.get_by(Schema.MastodonAccount, user_id: user.id) end def save_mastodon_account(user, attrs) do user |> Ecto.build_assoc(:mastodon_account) |> Syndication.MastodonAccount.changeset(attrs) |> Core.Repo.insert() end def syndicate_to_mastodon(%Schema.Post{} = post) do conn = build_mastodon_client_conn() {:ok, resp} = MastodonClient.post(conn, "/api/v1/statuses", %{status: post.body}) post |> Ecto.build_assoc(:mastodon_post) |> Syndication.MastodonPost.changeset(%{ status_id: resp.body["id"], url: resp.body["url"] }) |> Core.Repo.insert() {:ok, post} end defp get_mastodon_access_token! do mastodon_account = Core.Repo.one!(Schema.MastodonAccount) mastodon_account.access_token end defp build_mastodon_client_conn do %MastodonClient.Conn{ instance: @mastodon_instance, access_token: get_mastodon_access_token!() } end ## bluesky def get_bluesky_account(%Schema.User{} = user) do Core.Repo.get_by(Schema.BlueskyAccount, user_id: user.id) end def save_bluesky_account(user, identifier, password) do with {:ok, session_resp} <- Syndication.BlueskyClient.create_session(identifier, password), {:ok, attrs} <- parse_bluesky_token_resp(session_resp) do user |> Ecto.build_assoc(:bluesky_account) |> Syndication.BlueskyAccount.create_changeset(attrs) |> Core.Repo.insert( conflict_target: [:user_id], on_conflict: {:replace_all_except, [:id, :inserted_at]} ) end end def refresh_bluesky_account(%Schema.BlueskyAccount{} = bluesky_account) do if DateTime.compare(DateTime.utc_now(), bluesky_account.access_jwt_exp) == :lt do {:ok, bluesky_account} else with {:ok, refresh_resp} <- Syndication.BlueskyClient.refresh_session(bluesky_account), {:ok, attrs} <- parse_bluesky_token_resp(refresh_resp) do %{"bluesky_account_id" => bluesky_account.id} |> Core.Syndication.BlueskyRefreshWorker.new( scheduled_at: DateTime.add(bluesky_account.refresh_jwt_exp, -1, :day) ) |> Oban.insert() bluesky_account |> Syndication.BlueskyAccount.refresh_changeset(attrs) |> Core.Repo.update() end end end def syndicate_to_bluesky(%Schema.Post{} = post) do {:ok, bluesky_account} = get_bluesky_account!() |> refresh_bluesky_account() with {:ok, resp} <- Syndication.BlueskyClient.post_status(bluesky_account, post.body) do post |> Ecto.build_assoc(:bluesky_post) |> Syndication.BlueskyPost.changeset(resp.body) |> Core.Repo.insert() {:ok, post} end end def bsky_app_url(%Schema.BlueskyPost{uri: uri}) do [did, id] = Regex.run(~r|at://(did:.*)/app.bsky.feed.post/(.*)|, uri, capture: :all_but_first) "https://bsky.app/profile/#{did}/post/#{id}" end defp parse_bluesky_token_resp(resp) do with {:ok, %{iat: access_jwt_iat, exp: access_jwt_exp}} <- parse_bluesky_jwt(resp.body["accessJwt"]), {:ok, %{iat: refresh_jwt_iat, exp: refresh_jwt_exp}} <- parse_bluesky_jwt(resp.body["refreshJwt"]) do {:ok, %{ handle: resp.body["handle"], did: resp.body["did"], access_jwt: resp.body["accessJwt"], access_jwt_iat: access_jwt_iat, access_jwt_exp: access_jwt_exp, refresh_jwt: resp.body["refreshJwt"], refresh_jwt_iat: refresh_jwt_iat, refresh_jwt_exp: refresh_jwt_exp }} end end defp parse_bluesky_jwt(jwt) do with {:ok, claims} <- Joken.peek_claims(jwt), {:ok, iat} <- DateTime.from_unix(claims["iat"]), {:ok, exp} <- DateTime.from_unix(claims["exp"]) do {:ok, %{iat: iat, exp: exp}} end end defp get_bluesky_account! do Core.Repo.one!(Schema.BlueskyAccount) end end