feat: implement NIP-17 direct messages

Use the session-unlocked Nostr private key to publish and read NIP-17/NIP-59 gift-wrapped DMs via Parrhesia events, with a read-only NIP-04 import path. Wire the chat UI to pass signer context and add nak-backed interoperability tests for inbound and outbound DMs.
This commit is contained in:
2026-05-25 17:13:34 +02:00
parent 2884f43f9a
commit 446fffcadc
7 changed files with 787 additions and 28 deletions
+7 -2
View File
@@ -81,12 +81,17 @@ defmodule Aether.Chat do
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(:conversation_kind, :dm)
|> Map.put_new(:backend, :public_sync)
|> 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),
@@ -1,14 +1,23 @@
defmodule Aether.Chat.Backends.NostrNip04ReadOnly do
@moduledoc """
Legacy NIP-04 DM read-only fallback scaffold.
Legacy NIP-04 DM read-only fallback.
NIP-04 is kept for compatibility/import views. New outbound DMs should prefer
NIP-17 once that backend is implemented.
This backend queries canonical Parrhesia kind-4 events and decrypts messages for
local display when the viewer has an unlocked session private key. Outbound
messages intentionally remain disabled; new DMs should use NIP-17.
"""
@behaviour Aether.Chat.Backend
alias Aether.Chat.Channel
require Ash.Query
alias Aether.Chat
alias Aether.Chat.{Channel, Message, Participant}
alias Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
@kind_nip04 4
@impl true
def capabilities do
@@ -18,19 +27,143 @@ defmodule Aether.Chat.Backends.NostrNip04ReadOnly do
non_custodial_signing?: true,
conversation_kinds: [:legacy_dm],
read_only?: true,
required_protocols: [:nip04]
required_protocols: [:nip04],
signer_modes: [:session_privkey, :future_external_signer]
}
end
@impl true
def ensure_conversation(_attrs, _opts), do: {:error, :not_implemented}
def ensure_conversation(attrs, opts) when is_map(attrs) do
attrs
|> Map.put(:backend, :nostr_nip04)
|> Map.put_new(:conversation_kind, :legacy_dm)
|> Chat.ensure_channel(opts)
end
@impl true
def list_messages(%Channel{}, _opts), do: {:error, :not_implemented}
def list_messages(%Channel{} = channel, opts) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, local_pubkey),
{:ok, events} <- query_legacy_dms(local_pubkey, opts) do
messages =
events
|> Enum.flat_map(&decrypt_message(&1, privkey, channel, participant_pubkeys))
|> Enum.sort_by(&message_sort_key/1)
|> maybe_take_latest(Keyword.get(opts, :limit, 100))
{:ok, messages}
else
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def send_message(%Channel{}, _attrs, _opts), do: {:error, :read_only_backend}
@impl true
def subscribe(%Channel{}, _pid, _opts), do: {:error, :not_implemented}
def subscribe(%Channel{}, _pid, _opts), do: :ok
defp fetch_privkey(opts) do
case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do
privkey when is_binary(privkey) -> {:ok, privkey}
_other -> {:error, :missing_session_privkey}
end
end
defp participant_pubkeys(%Channel{id: channel_id}) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.read(Chat.ash_opts())
|> case do
{:ok, participants} ->
pubkeys =
participants
|> Enum.map(& &1.pubkey)
|> Enum.filter(&valid_pubkey?/1)
|> MapSet.new()
{:ok, pubkeys}
{:error, _reason} = error ->
error
end
end
defp query_legacy_dms(local_pubkey, opts) do
filters = [
%{
"kinds" => [@kind_nip04],
"#p" => [local_pubkey],
"limit" => Keyword.get(opts, :event_limit, 200)
}
]
Events.query(filters, context: request_context([local_pubkey]))
end
defp decrypt_message(event, privkey, channel, participant_pubkeys) do
with %{"kind" => @kind_nip04, "pubkey" => sender_pubkey, "content" => ciphertext} <- event,
true <- MapSet.member?(participant_pubkeys, sender_pubkey),
true <- Enum.any?(p_tags(event), &MapSet.member?(participant_pubkeys, &1)),
{:ok, body} <- Nak.nip04_decrypt(privkey, sender_pubkey, ciphertext) do
[
%Message{
id: event["id"],
channel_id: channel.id,
author_pubkey: sender_pubkey,
body: body,
client_message_id: event["id"],
metadata: %{"nostr_kind" => @kind_nip04, "nostr_event_id" => event["id"]},
inserted_at: unix_to_datetime(event["created_at"])
}
]
else
_other -> []
end
end
defp p_tags(event) do
event
|> Map.get("tags", [])
|> Enum.flat_map(fn
["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey]
_tag -> []
end)
end
defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do
{DateTime.to_unix(inserted_at, :microsecond), id || ""}
end
defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do
if length(messages) > limit do
messages |> Enum.take(-limit)
else
messages
end
end
defp maybe_take_latest(messages, _limit), do: messages
defp unix_to_datetime(timestamp) when is_integer(timestamp) do
case DateTime.from_unix(timestamp, :second) do
{:ok, datetime} -> datetime
{:error, _reason} -> DateTime.utc_now()
end
end
defp unix_to_datetime(_timestamp), do: DateTime.utc_now()
defp request_context(pubkeys) do
%RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1))
}
end
defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/
defp valid_pubkey?(_pubkey), do: false
end
+267 -10
View File
@@ -1,15 +1,26 @@
defmodule Aether.Chat.Backends.NostrNip17 do
@moduledoc """
NIP-17 private DM backend scaffold.
NIP-17 private DM backend.
This backend is intended to use Parrhesia/Nostr events as canonical message
storage and a local Aether projection for UI state. It is not active until the
signer, gift-wrap, subscription, and projection pipeline is implemented.
Gift-wrapped Nostr events in Parrhesia are the canonical message store. The
Ash chat channel/participant records are local UI projections used to find the
conversation participants; decrypted message structs returned to the UI are not
persisted through the Ash synced message resource.
"""
@behaviour Aether.Chat.Backend
alias Aether.Chat.Channel
require Ash.Query
alias Aether.Chat
alias Aether.Chat.{Channel, Message, Participant}
alias Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
alias Parrhesia.API.Stream
@kind_giftwrap 1059
@kind_dm 14
@impl true
def capabilities do
@@ -19,19 +30,265 @@ defmodule Aether.Chat.Backends.NostrNip17 do
non_custodial_signing?: true,
conversation_kinds: [:dm],
read_only?: false,
required_protocols: [:nip17, :nip44, :nip59]
required_protocols: [:nip17, :nip44, :nip59],
signer_modes: [:session_privkey, :future_external_signer],
plaintext_projection?: false
}
end
@impl true
def ensure_conversation(_attrs, _opts), do: {:error, :not_implemented}
def ensure_conversation(attrs, opts) when is_map(attrs) do
attrs
|> Map.put(:backend, :nostr_nip17)
|> Map.put_new(:conversation_kind, :dm)
|> Chat.ensure_channel(opts)
end
@impl true
def list_messages(%Channel{}, _opts), do: {:error, :not_implemented}
def list_messages(%Channel{} = channel, opts) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, local_pubkey),
{:ok, events} <- query_giftwraps(local_pubkey, opts) do
messages =
events
|> Enum.flat_map(&unwrap_message(&1, privkey, channel, participant_pubkeys))
|> dedupe_messages()
|> Enum.sort_by(&message_sort_key/1)
|> maybe_take_latest(Keyword.get(opts, :limit, 100))
{:ok, messages}
else
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def send_message(%Channel{}, _attrs, _opts), do: {:error, :not_implemented}
def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do
body = attrs |> Map.get(:body) || Map.get(attrs, "body") || ""
body = String.trim(body)
with false <- body == "",
{:ok, privkey} <- fetch_privkey(opts),
{:ok, sender_pubkey} <- Nak.pubkey_from_private_key(privkey),
:ok <- validate_author(attrs, sender_pubkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, sender_pubkey),
{:ok, recipient_pubkey} <- recipient_pubkey(participant_pubkeys, sender_pubkey),
{:ok, rumor} <- Nak.nip17_rumor(privkey, recipient_pubkey, body),
{:ok, giftwraps} <- publish_giftwraps(privkey, rumor, [recipient_pubkey, sender_pubkey]) do
{:ok, message_from_rumor(rumor, channel, attrs, giftwraps)}
else
true -> {:error, :empty_message}
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def subscribe(%Channel{}, _pid, _opts), do: {:error, :not_implemented}
def subscribe(%Channel{} = channel, pid, opts) when is_pid(pid) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey) do
filters = [%{"kinds" => [@kind_giftwrap], "#p" => [local_pubkey]}]
Stream.subscribe(pid, "aether-chat-#{channel.id}", filters,
context: request_context([local_pubkey])
)
else
{:error, :missing_session_privkey} -> :ok
{:error, _reason} = error -> error
end
end
def unwrap_event(%Channel{} = channel, giftwrap, opts) when is_map(giftwrap) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, participant_pubkeys} <- participant_pubkeys(channel) do
case unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do
[message] -> {:ok, message}
[] -> {:error, :not_conversation_message}
end
end
end
defp fetch_privkey(opts) do
case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do
privkey when is_binary(privkey) -> {:ok, privkey}
_other -> {:error, :missing_session_privkey}
end
end
defp validate_author(attrs, sender_pubkey) do
case Map.get(attrs, :author_pubkey) || Map.get(attrs, "author_pubkey") do
nil -> :ok
^sender_pubkey -> :ok
_other -> {:error, :author_pubkey_mismatch}
end
end
defp participant_pubkeys(%Channel{id: channel_id}) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.read(Chat.ash_opts())
|> case do
{:ok, participants} ->
pubkeys =
participants
|> Enum.map(& &1.pubkey)
|> Enum.filter(&valid_pubkey?/1)
|> MapSet.new()
{:ok, pubkeys}
{:error, _reason} = error ->
error
end
end
defp recipient_pubkey(participant_pubkeys, sender_pubkey) do
case participant_pubkeys |> MapSet.delete(sender_pubkey) |> MapSet.to_list() do
[recipient_pubkey] -> {:ok, recipient_pubkey}
[] -> {:error, :missing_recipient}
_many -> {:error, :unsupported_multi_recipient_dm}
end
end
defp publish_giftwraps(privkey, rumor, delivery_pubkeys) do
delivery_pubkeys
|> Enum.uniq()
|> Enum.reduce_while({:ok, []}, fn delivery_pubkey, {:ok, acc} ->
with {:ok, giftwrap} <- Nak.nip17_wrap(privkey, delivery_pubkey, rumor),
:ok <- publish_event(giftwrap) do
{:cont, {:ok, [giftwrap | acc]}}
else
{:error, _reason} = error -> {:halt, error}
end
end)
|> case do
{:ok, giftwraps} -> {:ok, Enum.reverse(giftwraps)}
{:error, _reason} = error -> error
end
end
defp publish_event(event) do
with {:ok, result} <- Events.publish(event, context: request_context([event["pubkey"]])),
true <- result.accepted do
:ok
else
false -> {:error, :publish_rejected}
{:ok, %{reason: reason}} when not is_nil(reason) -> {:error, reason}
{:error, _reason} = error -> error
other -> {:error, other}
end
end
defp query_giftwraps(local_pubkey, opts) do
filters = [
%{
"kinds" => [@kind_giftwrap],
"#p" => [local_pubkey],
"limit" => Keyword.get(opts, :event_limit, 200)
}
]
Events.query(filters, context: request_context([local_pubkey]))
end
defp unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do
with {:ok, rumor} <- Nak.nip17_unwrap(privkey, giftwrap),
true <- conversation_rumor?(rumor, participant_pubkeys) do
[message_from_rumor(rumor, channel, %{}, [giftwrap])]
else
_other -> []
end
end
defp conversation_rumor?(%{"kind" => @kind_dm, "pubkey" => sender_pubkey} = rumor, pubkeys) do
recipient_pubkeys = p_tags(rumor)
MapSet.member?(pubkeys, sender_pubkey) and
Enum.any?(recipient_pubkeys, &MapSet.member?(pubkeys, &1))
end
defp conversation_rumor?(_rumor, _pubkeys), do: false
defp message_from_rumor(rumor, channel, attrs, giftwraps) do
%Message{
id: rumor["id"] || (List.first(giftwraps || []) && List.first(giftwraps)["id"]),
channel_id: channel.id,
author_id: Map.get(attrs, :author_id) || Map.get(attrs, "author_id"),
author_pubkey: rumor["pubkey"],
body: rumor["content"] || "",
client_message_id: rumor["id"],
metadata: message_metadata(rumor, attrs, giftwraps),
inserted_at: unix_to_datetime(rumor["created_at"])
}
end
defp message_metadata(rumor, attrs, giftwraps) do
attrs_metadata = Map.get(attrs, :metadata) || Map.get(attrs, "metadata") || %{}
attrs_metadata
|> Map.put_new("author_name", get_in(attrs_metadata, ["author_name"]))
|> Map.put("nostr_kind", @kind_dm)
|> Map.put("nostr_rumor_id", rumor["id"])
|> Map.put("nostr_giftwrap_ids", Enum.map(giftwraps || [], & &1["id"]))
end
defp p_tags(event) do
event
|> Map.get("tags", [])
|> Enum.flat_map(fn
["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey]
_tag -> []
end)
end
defp dedupe_messages(messages) do
messages
|> Enum.reduce({MapSet.new(), []}, fn message, {seen, acc} ->
key = message.client_message_id || message.id
if MapSet.member?(seen, key) do
{seen, acc}
else
{MapSet.put(seen, key), [message | acc]}
end
end)
|> elem(1)
end
defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do
{DateTime.to_unix(inserted_at, :microsecond), id || ""}
end
defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do
if length(messages) > limit do
messages |> Enum.take(-limit)
else
messages
end
end
defp maybe_take_latest(messages, _limit), do: messages
defp unix_to_datetime(timestamp) when is_integer(timestamp) do
case DateTime.from_unix(timestamp, :second) do
{:ok, datetime} -> datetime
{:error, _reason} -> DateTime.utc_now()
end
end
defp unix_to_datetime(_timestamp), do: DateTime.utc_now()
defp request_context(pubkeys) do
%RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1))
}
end
defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/
defp valid_pubkey?(_pubkey), do: false
end
+157
View File
@@ -0,0 +1,157 @@
defmodule Aether.Chat.Nostr.Nak do
@moduledoc false
@timeout_ms 30_000
def available?, do: System.find_executable("nak") != nil
def pubkey_from_private_key(privkey) do
with {:ok, privkey_hex} <- normalize_private_key(privkey) do
run_script(
~S'''
set -euo pipefail
nak key public "$1"
''',
[privkey_hex]
)
|> trim_result()
end
end
def nip17_rumor(sender_privkey, recipient_pubkey, body)
when is_binary(recipient_pubkey) and is_binary(body) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey) do
run_script(
~S'''
set -euo pipefail
nak event --sec "$1" -k 14 -p "$2" -c "$3" </dev/null 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, body]
)
|> decode_json_result()
end
end
def nip17_wrap(sender_privkey, recipient_pubkey, rumor) when is_map(rumor) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey),
{:ok, rumor_json} <- encode_json(rumor) do
run_script(
~S'''
set -euo pipefail
printf '%s\n' "$3" | nak gift wrap --sec "$1" -p "$2" 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, rumor_json]
)
|> decode_json_result()
end
end
def nip17_unwrap(recipient_privkey, giftwrap) when is_map(giftwrap) do
with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey),
{:ok, giftwrap_json} <- encode_json(giftwrap) do
run_script(
~S'''
set -euo pipefail
printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null
''',
[recipient_privkey_hex, giftwrap_json]
)
|> decode_json_result()
end
end
def nip04_encrypt(sender_privkey, recipient_pubkey, plaintext)
when is_binary(recipient_pubkey) and is_binary(plaintext) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey) do
run_script(
~S'''
set -euo pipefail
nak encrypt --nip04 --sec "$1" -p "$2" "$3" </dev/null 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, plaintext]
)
|> trim_result()
end
end
def nip04_decrypt(recipient_privkey, sender_pubkey, ciphertext)
when is_binary(sender_pubkey) and is_binary(ciphertext) do
with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey),
:ok <- validate_pubkey(sender_pubkey) do
run_script(
~S'''
set -euo pipefail
nak decrypt --nip04 --sec "$1" -p "$2" "$3" 2>/dev/null
''',
[recipient_privkey_hex, sender_pubkey, ciphertext]
)
|> trim_result()
end
end
def normalize_private_key(<<_::binary-size(32)>> = privkey),
do: {:ok, Base.encode16(privkey, case: :lower)}
def normalize_private_key(privkey) when is_binary(privkey) do
if privkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do
{:ok, String.downcase(privkey)}
else
{:error, :invalid_private_key}
end
end
def normalize_private_key(_privkey), do: {:error, :invalid_private_key}
defp validate_pubkey(pubkey) when is_binary(pubkey) do
if pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do
:ok
else
{:error, :invalid_pubkey}
end
end
defp validate_pubkey(_pubkey), do: {:error, :invalid_pubkey}
defp run_script(script, args) do
if available?() do
do_run_script(script, args)
else
{:error, :nak_not_found}
end
end
defp do_run_script(script, args) do
task =
Task.async(fn ->
System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true)
end)
case Task.yield(task, @timeout_ms) || Task.shutdown(task, :brutal_kill) do
{:ok, {output, 0}} -> {:ok, output}
{:ok, {output, status}} -> {:error, {:nak_failed, status, output}}
nil -> {:error, :nak_timeout}
end
end
defp trim_result({:ok, output}), do: {:ok, String.trim(output)}
defp trim_result({:error, _reason} = error), do: error
defp decode_json_result({:ok, output}) do
output
|> String.trim()
|> JSON.decode()
rescue
_error -> {:error, {:invalid_json, output}}
end
defp decode_json_result({:error, _reason} = error), do: error
defp encode_json(value) do
{:ok, JSON.encode!(value)}
rescue
_error -> {:error, :invalid_json}
end
end
+32 -2
View File
@@ -35,7 +35,9 @@ defmodule AetherWeb.ChatLive do
metadata: %{"recipient_username" => recipient.username}
}
case Chat.ensure_direct_conversation(current_user.pubkey_hex, recipient.pubkey_hex, attrs) do
case Chat.ensure_direct_conversation(current_user.pubkey_hex, recipient.pubkey_hex, attrs,
session_privkey: socket.assigns[:session_privkey]
) do
{:ok, channel} ->
{:noreply, push_navigate(socket, to: Chat.standalone_path(channel))}
@@ -47,6 +49,19 @@ defmodule AetherWeb.ChatLive do
end
end
def handle_info({:parrhesia, :events, _ref, _subscription_id, events}, socket)
when is_list(events) do
Enum.each(events, &send_unwrapped_message(socket, &1))
{:noreply, socket}
end
def handle_info({:parrhesia, :event, _ref, _subscription_id, event}, socket) do
send_unwrapped_message(socket, event)
{:noreply, socket}
end
def handle_info({:parrhesia, :eose, _ref, _subscription_id}, socket), do: {:noreply, socket}
@impl true
def render(%{chat_request: %{mode: :embedded}} = assigns) do
~H"""
@@ -58,6 +73,7 @@ defmodule AetherWeb.ChatLive do
backend={@chat_request.backend}
embedded?={true}
current_user={@current_user}
session_privkey={@session_privkey}
/>
</div>
"""
@@ -77,7 +93,7 @@ defmodule AetherWeb.ChatLive do
{channel_title(@chat_request.slug)}
</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-base-content/70">
Public synced group chat. Marmot-backed channels will come later without changing the chat surface.
Public synced rooms and NIP-17 direct messages share this chat surface.
</p>
</div>
<.live_component
@@ -96,6 +112,7 @@ defmodule AetherWeb.ChatLive do
backend={@chat_request.backend}
embedded?={false}
current_user={@current_user}
session_privkey={@session_privkey}
/>
</div>
</Layouts.app>
@@ -132,6 +149,19 @@ defmodule AetherWeb.ChatLive do
defp request(mode, slug, backend), do: %{mode: mode, slug: slug, backend: backend}
defp send_unwrapped_message(socket, event) do
request = socket.assigns.chat_request
with {:ok, %Aether.Chat.Channel{} = channel} <-
Chat.get_channel_by_slug(request.slug, Chat.ash_opts()),
backend <- Chat.backend_module(channel.backend),
true <- function_exported?(backend, :unwrap_event, 3),
{:ok, message} <-
backend.unwrap_event(channel, event, session_privkey: socket.assigns[:session_privkey]) do
send_update(ChatPanelComponent, id: @component_id, incoming_message: message)
end
end
defp recipient_label(%{display_name: display_name})
when is_binary(display_name) and display_name != "" do
display_name
+12 -5
View File
@@ -163,7 +163,7 @@ defmodule AetherWeb.ChatPanelComponent do
defp load_channel(socket, slug, backend) do
case Chat.ensure_conversation(%{slug: slug, title: channel_title(slug), backend: backend}) do
{:ok, %Channel{} = channel} ->
messages = load_messages(channel)
messages = load_messages(channel, socket.assigns)
message_ids = messages |> Enum.map(& &1.id) |> MapSet.new()
socket
@@ -192,7 +192,7 @@ defmodule AetherWeb.ChatPanelComponent do
defp maybe_subscribe(%{assigns: %{channel: %Channel{} = channel}} = socket) do
if connected?(socket) do
_ = Chat.subscribe_conversation(channel)
_ = Chat.subscribe_conversation(channel, self(), chat_opts(socket.assigns))
assign(socket, :subscribed_channel_id, channel.id)
else
socket
@@ -211,7 +211,7 @@ defmodule AetherWeb.ChatPanelComponent do
metadata: %{"author_name" => user_attr(user, :username)}
}
case Chat.send_message(socket.assigns.channel, attrs) do
case Chat.send_message(socket.assigns.channel, attrs, chat_opts(socket.assigns)) do
{:ok, message} ->
{:noreply, put_message(socket, message)}
@@ -241,13 +241,20 @@ defmodule AetherWeb.ChatPanelComponent do
end
end
defp load_messages(%Channel{} = channel) do
case Chat.list_conversation_messages(channel) do
defp load_messages(%Channel{} = channel, assigns) do
case Chat.list_conversation_messages(channel, chat_opts(assigns)) do
{:ok, messages} -> messages
{:error, _reason} -> []
end
end
defp chat_opts(assigns) do
case Map.get(assigns, :session_privkey) do
privkey when is_binary(privkey) -> [session_privkey: privkey]
_other -> []
end
end
defp channel_title("general"), do: "General Chat"
defp channel_title(slug) do
+171 -1
View File
@@ -2,8 +2,11 @@ defmodule Aether.ChatBackendTest do
use Tribes.PluginTest.PageCase, plugin: Tribes.Plugins.Aether.Plugin
alias Aether.Chat
alias Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
test "public sync DM flow sends a user-to-user message", %{
test "NIP-17 DM flow sends a user-to-user message", %{
signed_in_conn: sender_conn,
current_user: sender,
conn: conn
@@ -50,6 +53,92 @@ defmodule Aether.ChatBackendTest do
assert has_element?(recipient_view, "#chat-messages", "hello recipient")
end
test "Aether publishes real NIP-17 giftwraps that nak can unwrap" do
require_executable!("nak")
{sender_pubkey, sender_privkey} = Tribes.Keyring.generate_keypair()
recipient_privkey = nak_key_generate!()
recipient_pubkey = nak_key_public!(recipient_privkey)
{:ok, channel} =
Chat.ensure_direct_conversation(sender_pubkey, recipient_pubkey, %{
title: "NIP-17 interop"
})
assert {:ok, message} =
Chat.send_message(
channel,
%{body: "hello nak recipient", author_pubkey: sender_pubkey},
session_privkey: sender_privkey
)
assert message.body == "hello nak recipient"
[giftwrap] = giftwraps_for(recipient_pubkey)
rumor = nak_gift_unwrap!(recipient_privkey, giftwrap)
assert giftwrap["kind"] == 1059
assert ["p", recipient_pubkey] in Enum.map(giftwrap["tags"], &Enum.take(&1, 2))
assert rumor["kind"] == 14
assert rumor["pubkey"] == sender_pubkey
assert rumor["content"] == "hello nak recipient"
assert ["p", recipient_pubkey] in Enum.map(rumor["tags"], &Enum.take(&1, 2))
end
test "Aether reads external NIP-17 giftwraps produced by nak" do
require_executable!("nak")
external_sender_privkey = nak_key_generate!()
external_sender_pubkey = nak_key_public!(external_sender_privkey)
{recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair()
{:ok, channel} =
Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{
title: "External NIP-17"
})
giftwrap =
nak_nip17_giftwrap!(external_sender_privkey, recipient_pubkey, "hello from external nak")
:ok = publish_event!(giftwrap)
assert {:ok, [message]} =
Chat.list_conversation_messages(channel, session_privkey: recipient_privkey)
assert message.author_pubkey == external_sender_pubkey
assert message.body == "hello from external nak"
end
test "NIP-04 backend imports decryptable legacy DMs as read-only" do
require_executable!("nak")
external_sender_privkey = nak_key_generate!()
external_sender_pubkey = nak_key_public!(external_sender_privkey)
{recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair()
{:ok, channel} =
Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{
title: "Legacy NIP-04",
backend: :nostr_nip04,
conversation_kind: :legacy_dm
})
{:ok, ciphertext} =
Nak.nip04_encrypt(external_sender_privkey, recipient_pubkey, "legacy hello from nak")
legacy_event = nak_event!(external_sender_privkey, 4, recipient_pubkey, ciphertext)
:ok = publish_event!(legacy_event)
assert {:ok, [message]} =
Chat.list_conversation_messages(channel, session_privkey: recipient_privkey)
assert message.author_pubkey == external_sender_pubkey
assert message.body == "legacy hello from nak"
assert {:error, :read_only_backend} =
Chat.send_message(channel, %{body: "nope"}, session_privkey: recipient_privkey)
end
test "backend capabilities prepare Nostr-compatible non-custodial backends" do
assert %{canonical_store: :parrhesia_events, non_custodial_signing?: true} =
Chat.backend_capabilities(:nostr_nip17)
@@ -71,4 +160,85 @@ defmodule Aether.ChatBackendTest do
Tribes.Plugin.User.from_host(user)
end
defp publish_event!(event) do
assert {:ok, result} =
Events.publish(event,
context: %RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new([event["pubkey"]])
}
)
assert result.accepted
:ok
end
defp giftwraps_for(recipient_pubkey) do
assert {:ok, events} =
Events.query(
[%{"kinds" => [1059], "#p" => [recipient_pubkey], "limit" => 10}],
context: %RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new([recipient_pubkey])
}
)
events
end
defp nak_nip17_giftwrap!(sender_privkey, recipient_pubkey, body) do
script = ~S'''
set -euo pipefail
nak event --sec "$1" -k 14 -p "$2" -c "$3" </dev/null 2>/dev/null |
nak gift wrap --sec "$1" -p "$2" 2>/dev/null
'''
script |> run_script!([sender_privkey, recipient_pubkey, body]) |> JSON.decode!()
end
defp nak_event!(sender_privkey, kind, recipient_pubkey, content) do
script = ~S'''
set -euo pipefail
nak event --sec "$1" -k "$2" -p "$3" -c "$4" </dev/null 2>/dev/null
'''
script
|> run_script!([sender_privkey, to_string(kind), recipient_pubkey, content])
|> JSON.decode!()
end
defp nak_gift_unwrap!(recipient_privkey, giftwrap) do
script = ~S'''
set -euo pipefail
printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null
'''
script
|> run_script!([recipient_privkey, JSON.encode!(giftwrap)])
|> JSON.decode!()
end
defp nak_key_generate!, do: run_script!("nak key generate", [])
defp nak_key_public!(privkey), do: run_script!("nak key public \"$1\"", [privkey])
defp require_executable!(name) do
unless System.find_executable(name) do
flunk("expected #{name} in PATH")
end
end
defp run_script!(script, args) do
task =
Task.async(fn ->
System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true)
end)
case Task.yield(task, 30_000) || Task.shutdown(task, :brutal_kill) do
{:ok, {output, 0}} -> String.trim(output)
{:ok, {output, status}} -> flunk("script failed with status #{status}:\n#{output}")
nil -> flunk("script timed out")
end
end
end