Files
self b4b8c83ddb feat: namespace plugin identity
Adopt canonical plugin id/slug manifest fields, vendor-prefixed OTP app naming, and fully-qualified capability ids for Aether.
2026-05-27 19:05:51 +02:00

353 lines
12 KiB
Elixir

defmodule TribeOne.TribesPlugin.Aether.Chat do
@moduledoc """
Chat domain and provider facade for TribeOne.TribesPlugin.Aether.
"""
use Ash.Domain,
otp_app: :tribe_one_aether
require Ash.Query
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
@default_channel_slug "general"
@default_channel_title "General Chat"
@pubsub Tribes.PubSub
resources do
resource Channel do
define(:create_channel, action: :create)
define(:get_channel, action: :by_id, args: [:id])
define(:get_channel_by_slug, action: :by_slug, args: [:slug])
define(:list_channels, action: :read)
end
resource Message do
define(:create_message, action: :create)
define(:get_message, action: :by_id, args: [:id])
define(:list_all_messages, action: :read)
end
resource Participant do
define(:create_participant, action: :create)
define(:get_participant, action: :by_channel_pubkey, args: [:channel_id, :pubkey])
define(:list_participants, action: :read)
define(:list_participants_by_pubkey, action: :by_pubkey, args: [:pubkey])
end
end
def backend_module(:public_sync), do: TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync
def backend_module(:nostr_nip17), do: TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17
def backend_module(:nostr_nip04),
do: TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly
def backend_module(:marmot), do: TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot
def backend_module(_backend), do: TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync
def backend_capabilities(backend) do
module = backend_module(backend)
module.capabilities()
end
def supported_conversation_kinds, do: [:group, :context_group, :dm, :legacy_dm, :marmot_group]
def supported_backends, do: [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
def chat_panel_component, do: TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent
def recipient_picker_component, do: TribeOne.TribesPlugin.AetherWeb.ChatRecipientPickerComponent
def default_channel_slug, do: @default_channel_slug
def standalone_path(%Channel{slug: slug}), do: standalone_path(slug)
def standalone_path(slug) when is_binary(slug), do: "/aether/chat/" <> slug
def embed_path(%Channel{slug: slug}), do: embed_path(slug)
def embed_path(slug) when is_binary(slug), do: "/aether/chat/embed/" <> slug
def marmot_path(%Channel{slug: slug}), do: marmot_path(slug)
def marmot_path(slug) when is_binary(slug), do: "/aether/chat/marmot/" <> slug
def ensure_context_channel(provider, type, id, attrs \\ %{}, opts \\ [])
when is_binary(provider) and is_binary(type) and is_binary(id) and is_map(attrs) do
attrs
|> Map.merge(%{
context_provider: provider,
context_type: type,
context_id: id
})
|> ensure_conversation(opts)
end
def ensure_direct_conversation(sender, recipient, attrs \\ %{}, opts \\ [])
def ensure_direct_conversation(sender_pubkey, recipient_pubkey, attrs, opts)
when is_binary(sender_pubkey) and is_binary(recipient_pubkey) and is_map(attrs) do
participants = Enum.sort([sender_pubkey, recipient_pubkey])
attrs =
attrs
|> Map.update(:metadata, %{"participants" => participants}, fn metadata ->
Map.put(metadata || %{}, "participants", participants)
end)
|> Map.put_new(:slug, direct_slug(sender_pubkey, recipient_pubkey))
|> Map.put_new(:title, "Direct Message")
|> Map.put_new(:conversation_kind, :dm)
|> Map.put_new(:backend, :nostr_nip17)
with {:ok, %Channel{} = channel} <- ensure_conversation(attrs, opts),
{:ok, _sender} <- ensure_participant(channel, sender_pubkey, %{role: :member}, opts),
{:ok, _recipient} <-
ensure_participant(channel, recipient_pubkey, %{role: :member}, opts) do
{:ok, channel}
end
end
def direct_slug(pubkey_a, pubkey_b) when is_binary(pubkey_a) and is_binary(pubkey_b) do
digest =
[pubkey_a, pubkey_b]
|> Enum.sort()
|> Enum.join(":")
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
"dm-" <> digest
end
def ash_opts(context \\ nil) do
[
authorize?: false,
context: %{
private: %{
system?: true,
system_purpose: :aether_chat,
plugin_management_context: context
}
}
]
end
def ensure_conversation(attrs \\ %{}, opts \\ []) when is_map(attrs) do
backend = attrs |> atomize_known_keys() |> Map.get(:backend, :public_sync)
backend_module(backend).ensure_conversation(attrs, opts)
end
def ensure_channel(attrs \\ %{}, opts \\ []) when is_map(attrs) do
attrs = normalize_channel_attrs(attrs)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
case get_existing_channel(attrs.slug, ash_opts) do
{:ok, nil} -> create_channel(attrs, ash_opts)
{:ok, %Channel{} = channel} -> {:ok, channel}
{:error, _reason} = error -> error
end
end
defp get_existing_channel(slug, ash_opts) do
Channel
|> Ash.Query.filter(slug == ^slug)
|> Ash.Query.limit(1)
|> Ash.read_one(ash_opts)
end
defp get_existing_participant(channel_id, pubkey, ash_opts) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id and pubkey == ^pubkey)
|> Ash.Query.limit(1)
|> Ash.read_one(ash_opts)
end
def ensure_participant(%Channel{} = channel, pubkey, attrs \\ %{}, opts \\ [])
when is_binary(pubkey) and is_map(attrs) do
attrs = atomize_known_keys(attrs)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
case get_existing_participant(channel.id, pubkey, ash_opts) do
{:ok, nil} ->
attrs
|> Map.merge(%{channel_id: channel.id, pubkey: pubkey})
|> Map.put_new(:role, :member)
|> Map.put_new(:metadata, %{})
|> create_participant(ash_opts)
{:ok, %Participant{} = participant} ->
{:ok, participant}
{:error, _reason} = error ->
error
end
end
def list_conversation_messages(%Channel{backend: backend} = channel, opts \\ []) do
backend_module(backend).list_messages(channel, opts)
end
def list_messages(channel_or_id, opts \\ []) do
channel_id = channel_id(channel_or_id)
limit = Keyword.get(opts, :limit, 100)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
result =
Message
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.Query.sort(inserted_at: :desc, id: :desc)
|> Ash.Query.limit(limit)
|> Ash.read(ash_opts)
case result do
{:ok, messages} -> {:ok, Enum.reverse(messages)}
{:error, _reason} = error -> error
end
end
def send_message(%Channel{backend: backend} = channel, attrs, opts \\ []) when is_map(attrs) do
backend_module(backend).send_message(channel, attrs, opts)
end
def post_message(%Channel{} = channel, attrs, opts \\ []) when is_map(attrs) do
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
attrs
|> normalize_message_attrs(channel)
|> create_message(ash_opts)
|> maybe_broadcast_message()
end
def subscribe_conversation(%Channel{backend: backend} = channel, pid \\ self(), opts \\ []) do
backend_module(backend).subscribe(channel, pid, opts)
end
def subscribe_channel(%Channel{} = channel), do: subscribe_channel(channel.id)
def subscribe_channel(channel_id) when is_binary(channel_id) do
if pubsub_started?() do
Phoenix.PubSub.subscribe(@pubsub, topic(channel_id))
else
{:error, :pubsub_not_started}
end
end
def topic(channel_id) when is_binary(channel_id), do: "aether:chat:" <> channel_id
defp maybe_broadcast_message({:ok, %Message{} = message}) do
broadcast_message(message)
{:ok, message}
end
defp maybe_broadcast_message({:error, _reason} = error), do: error
defp broadcast_message(%Message{} = message) do
if pubsub_started?() do
Phoenix.PubSub.broadcast_from(
@pubsub,
self(),
topic(message.channel_id),
{:aether_chat, :message, message}
)
end
end
defp pubsub_started?, do: Process.whereis(@pubsub) != nil
defp normalize_channel_attrs(attrs) do
attrs = atomize_known_keys(attrs)
title = attrs[:title] || @default_channel_title
slug = attrs[:slug] || context_slug(attrs) || slugify(title) || @default_channel_slug
%{
slug: slug,
title: title,
description: attrs[:description],
backend: attrs[:backend] || :public_sync,
conversation_kind: attrs[:conversation_kind] || default_conversation_kind(attrs),
context_provider: attrs[:context_provider],
context_type: attrs[:context_type],
context_id: attrs[:context_id],
metadata: attrs[:metadata] || %{}
}
end
defp normalize_message_attrs(attrs, %Channel{} = channel) do
attrs = atomize_known_keys(attrs)
%{
channel_id: channel.id,
author_id: attrs[:author_id],
author_pubkey: attrs[:author_pubkey],
body: String.trim(attrs[:body] || ""),
client_message_id: attrs[:client_message_id],
metadata: attrs[:metadata] || %{}
}
end
defp atomize_known_keys(attrs) do
Enum.reduce(attrs, %{}, fn
{key, value}, acc when is_atom(key) -> Map.put(acc, key, value)
{key, value}, acc when is_binary(key) -> put_known_key(acc, key, value)
end)
end
defp put_known_key(acc, key, value) do
case key do
"slug" -> Map.put(acc, :slug, value)
"title" -> Map.put(acc, :title, value)
"description" -> Map.put(acc, :description, value)
"backend" -> Map.put(acc, :backend, parse_atom(value))
"conversation_kind" -> Map.put(acc, :conversation_kind, parse_atom(value))
"context_provider" -> Map.put(acc, :context_provider, value)
"context_type" -> Map.put(acc, :context_type, value)
"context_id" -> Map.put(acc, :context_id, value)
"metadata" -> Map.put(acc, :metadata, value)
"pubkey" -> Map.put(acc, :pubkey, value)
"user_id" -> Map.put(acc, :user_id, value)
"display_name" -> Map.put(acc, :display_name, value)
"role" -> Map.put(acc, :role, parse_atom(value))
"author_id" -> Map.put(acc, :author_id, value)
"author_pubkey" -> Map.put(acc, :author_pubkey, value)
"body" -> Map.put(acc, :body, value)
"client_message_id" -> Map.put(acc, :client_message_id, value)
_other -> acc
end
end
defp parse_atom(value) when is_atom(value), do: value
defp parse_atom("public_sync"), do: :public_sync
defp parse_atom("nostr_nip17"), do: :nostr_nip17
defp parse_atom("nostr_nip04"), do: :nostr_nip04
defp parse_atom("marmot"), do: :marmot
defp parse_atom("group"), do: :group
defp parse_atom("context_group"), do: :context_group
defp parse_atom("dm"), do: :dm
defp parse_atom("legacy_dm"), do: :legacy_dm
defp parse_atom("marmot_group"), do: :marmot_group
defp parse_atom("owner"), do: :owner
defp parse_atom("member"), do: :member
defp parse_atom(_value), do: nil
defp context_slug(%{context_provider: provider, context_type: type, context_id: id})
when is_binary(provider) and is_binary(type) and is_binary(id) do
Enum.map_join([provider, type, id], "-", &slugify/1)
end
defp context_slug(_attrs), do: nil
defp default_conversation_kind(%{context_provider: provider}) when is_binary(provider),
do: :context_group
defp default_conversation_kind(_attrs), do: :group
defp channel_id(%Channel{id: id}), do: id
defp channel_id(id) when is_binary(id), do: id
defp slugify(value) when is_binary(value) do
slug =
value
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
if slug == "", do: nil, else: slug
end
end