add support for bluesky syndication

This commit is contained in:
sloane 2025-05-02 07:32:02 -04:00
parent 36a1cdfab4
commit 8e3b231465
Signed by: sloanelybutsurely
SSH key fingerprint: SHA256:8SBnwhl+RY3oEyQxy1a9wByPzxWM0x+/Ejc+sIlY5qQ
19 changed files with 342 additions and 17 deletions

View file

@ -1,5 +1,5 @@
[
import_deps: [:oban, :ecto, :ecto_sql, :phoenix],
import_deps: [:oban, :ecto, :ecto_sql, :phoenix, :tesla],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]

View file

@ -88,9 +88,9 @@ defmodule Core.Posts do
with {:ok, post} <-
post
|> Post.publish_changeset(published_at)
|> Core.Repo.update() do
Core.Syndication.syndicate_to_mastodon(post)
|> Core.Repo.update(),
{:ok, post} <- Core.Syndication.syndicate_to_mastodon(post),
{:ok, post} <- Core.Syndication.syndicate_to_bluesky(post) do
{:ok, post}
end
end

View file

@ -14,7 +14,8 @@ defmodule Core.Posts.Post do
:body,
:deleted_at,
:published_at,
:syndicate_to_mastodon
:syndicate_to_mastodon,
:syndicate_to_bluesky
])
|> validate_required([:kind], message: "must have a kind")
|> validate_required([:body], message: "must have a body")
@ -90,7 +91,9 @@ defmodule Core.Posts.Post do
as: :posts,
left_join: mp in assoc(p, :mastodon_post),
as: :mastodon_posts,
preload: [mastodon_post: mp]
left_join: bp in assoc(p, :bluesky_post),
as: :bluesky_posts,
preload: [mastodon_post: mp, bluesky_post: bp]
end
def current(query \\ base()) do

View file

@ -1,6 +1,8 @@
defmodule Core.Syndication do
alias __MODULE__
## mastodon
@mastodon_instance Application.compile_env!(:sloanely_but_surely, [
Core.Syndication,
:mastodon_instance
@ -33,13 +35,13 @@ defmodule Core.Syndication do
})
|> Core.Repo.insert()
post
{:ok, post}
else
post
{:ok, post}
end
end
defp get_mastodon_access_token do
defp get_mastodon_access_token! do
mastodon_account = Core.Repo.one!(Schema.MastodonAccount)
mastodon_account.access_token
end
@ -47,7 +49,93 @@ defmodule Core.Syndication do
defp build_mastodon_client_conn do
%MastodonClient.Conn{
instance: @mastodon_instance,
access_token: get_mastodon_access_token()
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
with {:ok, refresh_resp} <-
Syndication.BlueskyClient.refresh_session(bluesky_account),
{:ok, attrs} <- parse_bluesky_token_resp(refresh_resp) do
bluesky_account
|> Syndication.BlueskyAccount.refresh_changeset(attrs)
|> Core.Repo.update()
end
end
def syndicate_to_bluesky(%Schema.Post{} = post) do
post = Core.Repo.preload(post, [:bluesky_post])
if post.syndicate_to_bluesky && is_nil(post.bluesky_post) do
bluesky_account = get_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
else
{: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

View file

@ -0,0 +1,47 @@
defmodule Core.Syndication.BlueskyAccount do
import Ecto.Changeset
def create_changeset(%Schema.BlueskyAccount{} = bluesky_account, attrs) do
bluesky_account
|> cast(attrs, [
:handle,
:did,
:access_jwt,
:access_jwt_iat,
:access_jwt_exp,
:refresh_jwt,
:refresh_jwt_iat,
:refresh_jwt_exp
])
|> validate_required([
:handle,
:did,
:access_jwt,
:access_jwt_iat,
:access_jwt_exp,
:refresh_jwt,
:refresh_jwt_iat,
:refresh_jwt_exp
])
end
def refresh_changeset(%Schema.BlueskyAccount{} = bluesky_account, attrs) do
bluesky_account
|> cast(attrs, [
:access_jwt,
:access_jwt_iat,
:access_jwt_exp,
:refresh_jwt,
:refresh_jwt_iat,
:refresh_jwt_exp
])
|> validate_required([
:access_jwt,
:access_jwt_iat,
:access_jwt_exp,
:refresh_jwt,
:refresh_jwt_iat,
:refresh_jwt_exp
])
end
end

View file

@ -0,0 +1,50 @@
defmodule Core.Syndication.BlueskyClient do
@middleware [
Tesla.Middleware.Logger,
{Tesla.Middleware.BaseUrl, "https://bsky.social/xrpc"},
Tesla.Middleware.JSON
]
def new(opts \\ []) do
middleware = @middleware
middleware =
case Keyword.fetch(opts, :token) do
{:ok, token} ->
middleware ++ [{Tesla.Middleware.BearerAuth, token: token}]
:error ->
middleware
end
Tesla.client(middleware)
end
def create_session(identifier, password) do
client = new()
Tesla.post(client, "/com.atproto.server.createSession", %{
identifier: identifier,
password: password
})
end
def refresh_session(%Schema.BlueskyAccount{refresh_jwt: token}) do
client = new(token: token)
Tesla.post(client, "/com.atproto.server.refreshSession", nil)
end
def post_status(%Schema.BlueskyAccount{} = bluesky_account, text) do
client = new(token: bluesky_account.access_jwt)
Tesla.post(client, "/com.atproto.repo.createRecord", %{
repo: bluesky_account.did,
collection: "app.bsky.feed.post",
record: %{
text: text,
createdAt: DateTime.utc_now() |> DateTime.to_iso8601()
}
})
end
end

View file

@ -0,0 +1,9 @@
defmodule Core.Syndication.BlueskyPost do
import Ecto.Changeset
def changeset(%Schema.BlueskyPost{} = bluesky_post, attrs) do
bluesky_post
|> cast(attrs, [:cid, :uri, :commit])
|> validate_required([:cid, :uri, :commit])
end
end

View file

@ -1,6 +1,8 @@
defmodule Schema do
@moduledoc false
use Boundary, deps: [], exports: [Post, User, UserToken, MastodonAccount]
use Boundary,
deps: [],
exports: [Post, User, UserToken, MastodonAccount, BlueskyAccount, BlueskyPost]
defmacro __using__(_) do
quote do

View file

@ -0,0 +1,20 @@
defmodule Schema.BlueskyAccount do
use Schema
schema "bluesky_accounts" do
field :handle, :string
field :did, :string
field :access_jwt, :string, redact: true
field :access_jwt_iat, :utc_datetime
field :access_jwt_exp, :utc_datetime
field :refresh_jwt, :string, redact: true
field :refresh_jwt_iat, :utc_datetime
field :refresh_jwt_exp, :utc_datetime
belongs_to :user, Schema.User
timestamps()
end
end

View file

@ -0,0 +1,13 @@
defmodule Schema.BlueskyPost do
use Schema
schema "bluesky_posts" do
field :cid, :string
field :uri, :string
field :commit, :map
belongs_to :post, Schema.Post
timestamps()
end
end

View file

@ -29,8 +29,10 @@ defmodule Schema.Post do
field :deleted_at, :utc_datetime_usec
field :syndicate_to_mastodon, :boolean
field :syndicate_to_bluesky, :boolean
has_one :mastodon_post, Schema.MastodonPost
has_one :bluesky_post, Schema.BlueskyPost
timestamps()
end

View file

@ -9,6 +9,7 @@ defmodule Schema.User do
field :current_password, :string, virtual: true, redact: true
has_one :mastodon_account, Schema.MastodonAccount
has_one :bluesky_account, Schema.BlueskyAccount
timestamps(type: :utc_datetime_usec)
end

View file

@ -55,11 +55,22 @@
<% end %>
</div>
<div class="flex flex-row gap-x-2">
<%= if @status.mastodon_post do %>
<.link href={@status.mastodon_post.url} target="_blank" class="u-syndication">
tech.lgbt
</.link>
<% end %>
<.link
:if={@status.mastodon_post}
href={@status.mastodon_post.url}
target="_blank"
class="u-syndication"
>
tech.lgbt
</.link>
<.link
:if={@status.bluesky_post}
href={Core.Syndication.bsky_app_url(@status.bluesky_post)}
target="_blank"
class="u-syndication"
>
bsky.app
</.link>
</div>
</footer>
</article>

View file

@ -31,6 +31,12 @@
class="self-end"
field={@form[:syndicate_to_mastodon]}
/>
<.input
type="checkbox"
label="syndicate to bluesky"
class="self-end"
field={@form[:syndicate_to_bluesky]}
/>
<%= if @post.deleted_at do %>
<.button phx-click="undelete" class="self-end">undelete</.button>
<% else %>

View file

@ -3,11 +3,26 @@ defmodule Web.AdminSyndicationLive do
def mount(_params, _session, socket) do
mastodon_account = Core.Syndication.get_mastodon_account(socket.assigns.current_user)
bluesky_account = Core.Syndication.get_bluesky_account(socket.assigns.current_user)
socket =
socket
|> assign(:mastodon_account, mastodon_account)
|> assign(:bluesky_account, bluesky_account)
{:ok, socket}
end
def handle_event(
"save_bluesky_account",
%{"bluesky_account" => %{"identifier" => identifier, "password" => password}},
socket
) do
{:ok, bluesky_account} =
Core.Syndication.save_bluesky_account(socket.assigns.current_user, identifier, password)
socket = assign(socket, bluesky_account: bluesky_account)
{:noreply, socket}
end
end

View file

@ -31,7 +31,18 @@
<% end %>
</div>
<div>
<strong>Bluesky: </strong>Coming soon!
<strong>Bluesky: </strong>
<%= if @bluesky_account do %>
<.link href={"https://bsky.app/profile/#{@bluesky_account.handle}"} target="_blank">
@{@bluesky_account.handle}
</.link>
<% else %>
<.form :let={f} for={%{}} as={:bluesky_account} phx-submit="save_bluesky_account">
<.input field={f[:identifier]} label="email" />
<.input field={f[:password]} type="password" />
<.button type="submit">Connect account</.button>
</.form>
<% end %>
</div>
</main>
</div>

View file

@ -70,6 +70,7 @@ defmodule SlaonelyButSurely.MixProject do
{:ueberauth_mastodon, "~> 0.3.0"},
{:tesla, "~> 1.14"},
{:mint, "~> 1.7"},
{:joken, "~> 2.6"},
# Added dev and/or test dependencies
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},

View file

@ -31,6 +31,8 @@
"igniter": {:hex, :igniter, "0.5.47", "7a1041d5e38303e526fa6b6de37c9e78013f5cb573833ed51183d18e3a152f10", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "53a900909e20f217a25d15a34fef629c562b4822c1fb39cfa5d6999bc72992ed"},
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
"mastodon_client": {:hex, :mastodon_client, "0.1.0", "7f1a9e54367d0e126c76d0bb1097de346fb9c6da6d682a6b57bc936c92d643eb", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "3daa37fc7a95430eb0b77cdb168af58b8db84efea6e0f3244d15d45f39a87160"},
"mdex": {:hex, :mdex, "0.5.0", "252c83cebc6a089801dfc1e142b4d98c9c358378ec7096a94796bce8bd13b0fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "73e3ddee03130267e3be6aaf47a7f423c6f86add4bb5c62b352465cd9fb87d95"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},

View file

@ -0,0 +1,44 @@
defmodule Core.Repo.Migrations.AddBlueskyTables do
use Ecto.Migration
def change do
create table(:bluesky_accounts, primary_key: false) do
add :id, :uuid, primary_key: true
add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false
add :handle, :text, null: false
add :did, :text, null: false
add :access_jwt, :text, null: false
add :access_jwt_iat, :utc_datetime, null: false
add :access_jwt_exp, :utc_datetime, null: false
add :refresh_jwt, :text, null: false
add :refresh_jwt_iat, :utc_datetime, null: false
add :refresh_jwt_exp, :utc_datetime, null: false
timestamps(type: :utc_datetime_usec)
end
create unique_index(:bluesky_accounts, [:user_id])
create table(:bluesky_posts, primary_key: false) do
add :id, :uuid, primary_key: true
add :post_id, references(:posts, type: :uuid, on_delete: :delete_all), null: false
add :cid, :text, null: false
add :uri, :text, null: false
add :commit, :jsonb, null: false
timestamps(type: :utc_datetime_usec)
end
create unique_index(:bluesky_posts, [:post_id])
alter table(:posts) do
add :syndicate_to_bluesky, :boolean, default: false, null: false
end
end
end