You've already forked tribes-plugin-aether
forked from tribes/tribes-plugin-template
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:
+7
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user