This commit is contained in:
Arc 2026-07-30 23:11:48 +01:00
parent a9b3d6efaa
commit f336739f2d
10 changed files with 114 additions and 86 deletions

View file

@ -27,7 +27,6 @@ from lnbits.core.models.notifications import (
)
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import (
fetch_nip5_details,
resolve_nostr_recipient,
send_nostr_dm,
send_nostr_nip17_dm,
@ -36,7 +35,7 @@ from lnbits.core.services.nostr import (
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url, is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key
from lnbits.utils.nostr import is_ws_url, normalize_private_key
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
NostrDmType = Literal["nip04", "nip17", "nip17b"]
@ -185,8 +184,9 @@ async def send_nostr_notification(
if "@" in identifier:
user_pubkey, relays = await resolve_nostr_recipient(identifier)
relays = relays or _configured_nostr_notification_relays()
else:
fallback_relays = await _fetch_configured_nostr_notification_relays()
fallback_relays = _configured_nostr_notification_relays()
user_pubkey, relays = await resolve_nostr_recipient(
identifier,
fallback_relays,
@ -221,33 +221,9 @@ async def send_nostr_notification(
raise ValueError(f"Unsupported Nostr DM type: {dm_type}")
async def _fetch_configured_nostr_notification_relays() -> list[str]:
identifiers = list(
dict.fromkeys(
identifier
for identifier in settings.lnbits_nostr_notifications_identifiers
if "@" in identifier
)
)
if not identifiers:
return []
results = await asyncio.gather(
*(fetch_nip5_details(identifier) for identifier in identifiers),
return_exceptions=True,
)
relays: list[str] = []
for identifier, result in zip(identifiers, results, strict=True):
if isinstance(result, BaseException):
if isinstance(result, asyncio.CancelledError):
raise result
logger.warning(
f"Error fetching fallback Nostr relays from {identifier}: {result}"
)
continue
_, identifier_relays = result
relays.extend(identifier_relays)
return list(dict.fromkeys(relays))
def _configured_nostr_notification_relays() -> list[str]:
relays = settings.lnbits_nostr_notifications_relays
return list(dict.fromkeys(relay for relay in relays if is_ws_url(relay)))
async def send_telegram_notification(chat_id: str, message: str) -> dict:

View file

@ -480,6 +480,7 @@ class NotificationsSettings(LNbitsSettings):
lnbits_nostr_notifications_enabled: bool = Field(default=False)
lnbits_nostr_notifications_private_key: str = Field(default="")
lnbits_nostr_notifications_identifiers: list[str] = Field(default=[])
lnbits_nostr_notifications_relays: list[str] = Field(default=[])
lnbits_nostr_notifications_dm_types: list[Literal["nip17", "nip17b"]] = Field(
default=[]
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -314,6 +314,9 @@ window.localisation.en = {
notifications_nostr_dm_types: 'Other Direct Message Types Allowed',
notifications_nostr_dm_types_desc:
'Allow extensions to explicitly send notifications using these protocols.',
notifications_nostr_relays: 'Fallback Nostr Relays',
notifications_nostr_relays_desc:
'Relays used when no relay list is returned by NIP-05.',
notifications_telegram_config: 'Telegram Configuration',
notifications_enable_telegram: 'Enable Telegram',

View file

@ -4,6 +4,7 @@ window.app.component('lnbits-admin-notifications', {
data() {
return {
nostrNotificationIdentifier: '',
nostrNotificationRelay: '',
nostrDmTypeOptions: [
{label: 'NIP-17', value: 'nip17'},
{label: 'NIP-17B', value: 'nip17b'}
@ -52,6 +53,20 @@ window.app.component('lnbits-admin-notifications', {
m => m !== identifer
)
},
addNostrNotificationRelay() {
const relay = this.nostrNotificationRelay.trim()
const relays = this.formData.lnbits_nostr_notifications_relays || []
if (relay && !relays.includes(relay)) {
this.formData.lnbits_nostr_notifications_relays = [...relays, relay]
this.nostrNotificationRelay = ''
}
},
removeNostrNotificationRelay(relay) {
const relays = this.formData.lnbits_nostr_notifications_relays || []
this.formData.lnbits_nostr_notifications_relays = relays.filter(
item => item !== relay
)
},
addEmailNotificationAddress() {
const email = this.emailNotificationAddress.trim()
const emails = this.formData.lnbits_email_notifications_to_emails

View file

@ -113,6 +113,46 @@
></q-option-group>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_nostr_relays')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_nostr_relays_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="nostrNotificationRelay"
placeholder="wss://relay.example.com"
@keydown.enter="addNostrNotificationRelay"
>
<q-btn
@click="addNostrNotificationRelay()"
dense
flat
icon="add"
></q-btn>
</q-input>
</q-item-section>
</q-item>
<div>
<q-chip
v-for="relay in formData.lnbits_nostr_notifications_relays"
:key="relay"
removable
@remove="removeNostrNotificationRelay(relay)"
color="primary"
text-color="white"
class="ellipsis"
:label="relay"
><q-tooltip v-if="relay" anchor="top middle" self="bottom middle"
><span v-text="relay"></span></q-tooltip
></q-chip>
</div>
</div>
<div class="col-sm-12 col-md-6">

View file

@ -195,14 +195,16 @@ async def test_send_nostr_nip17_dm_builds_gift_wrap(
assert result["kind"] == 1059
assert result["tags"] == [["p", receiver.public_key().to_hex()]]
send_mock.assert_awaited_once()
gift = send_mock.await_args.args[0]
send_call = send_mock.await_args
assert send_call
gift = send_call.args[0]
unwrapped = await UnwrappedGift.from_gift_wrap(
NostrSigner.keys(receiver),
gift,
)
assert unwrapped.rumor().content() == "hello"
assert unwrapped.sender().to_hex() == sender.public_key().to_hex()
assert send_mock.await_args.args[1] == ["wss://relay"]
assert send_call.args[1] == ["wss://relay"]
@pytest.mark.anyio
@ -241,7 +243,9 @@ async def test_send_nostr_nip17b_dm_uses_epoch_ticket(
group.public_key().to_hex(),
["wss://group"],
)
gift = send_mock.await_args.args[0]
send_call = send_mock.await_args
assert send_call
gift = send_call.args[0]
unwrapped = await UnwrappedGift.from_gift_wrap(
NostrSigner.keys(epoch),
gift,
@ -256,7 +260,7 @@ async def test_send_nostr_nip17b_dm_uses_epoch_ticket(
["invitation_proof", "f" * 128],
]
assert unwrapped.sender().to_hex() == member.public_key().to_hex()
assert send_mock.await_args.args[1] == ["wss://group"]
assert send_call.args[1] == ["wss://group"]
@pytest.mark.anyio

View file

@ -28,7 +28,6 @@ from lnbits.core.models.wallets import (
WalletShareStatus,
)
from lnbits.core.services.notifications import (
_fetch_configured_nostr_notification_relays,
dispatch_webhook,
enqueue_admin_notification,
enqueue_user_notification,
@ -249,7 +248,7 @@ async def test_send_notification_uses_available_channels_and_swallows_exceptions
@pytest.mark.anyio
async def test_send_nostr_notifications_and_single_notification(
mocker: MockerFixture,
settings: Settings, mocker: MockerFixture
):
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_notification",
@ -277,7 +276,7 @@ async def test_send_nostr_notifications_and_single_notification(
resolve_mock = mocker.patch(
"lnbits.core.services.notifications.resolve_nostr_recipient",
mocker.AsyncMock(return_value=("pubkey", ["wss://relay"])),
mocker.AsyncMock(return_value=("pubkey", [])),
)
normalize_mock = mocker.patch(
"lnbits.core.services.notifications.normalize_private_key",
@ -288,7 +287,12 @@ async def test_send_nostr_notifications_and_single_notification(
mocker.AsyncMock(),
)
await send_nostr_notification("alice@example.com", "hello")
original_relays = list(settings.lnbits_nostr_notifications_relays)
try:
settings.lnbits_nostr_notifications_relays = ["wss://fallback"]
await send_nostr_notification("alice@example.com", "hello")
finally:
settings.lnbits_nostr_notifications_relays = original_relays
resolve_mock.assert_awaited_once_with("alice@example.com")
normalize_mock.assert_called_once()
@ -296,7 +300,7 @@ async def test_send_nostr_notifications_and_single_notification(
"server-private-key",
"pubkey",
"hello",
["wss://relay"],
["wss://fallback"],
)
@ -305,9 +309,11 @@ async def test_send_nostr_notification_selects_nip17_and_nip17b(
settings: Settings, mocker: MockerFixture
):
original_dm_types = list(settings.lnbits_nostr_notifications_dm_types)
original_relays = list(settings.lnbits_nostr_notifications_relays)
try:
settings.lnbits_nostr_notifications_dm_types = ["nip17", "nip17b"]
mocker.patch(
settings.lnbits_nostr_notifications_relays = ["wss://fallback"]
resolve_mock = mocker.patch(
"lnbits.core.services.notifications.resolve_nostr_recipient",
mocker.AsyncMock(return_value=("pubkey", ["wss://recipient"])),
)
@ -328,6 +334,7 @@ async def test_send_nostr_notification_selects_nip17_and_nip17b(
await send_nostr_notification("group@example.com", "hello", "nip17b")
finally:
settings.lnbits_nostr_notifications_dm_types = original_dm_types
settings.lnbits_nostr_notifications_relays = original_relays
nip17_mock.assert_awaited_once_with(
"server-private-key",
@ -341,65 +348,46 @@ async def test_send_nostr_notification_selects_nip17_and_nip17b(
"hello",
["wss://recipient"],
)
assert [call.args[0] for call in resolve_mock.await_args_list] == [
"alice@example.com",
"group@example.com",
]
@pytest.mark.anyio
async def test_bare_nostr_pubkey_uses_configured_nip5_relays(
async def test_bare_nostr_pubkey_uses_fallback_relays(
settings: Settings, mocker: MockerFixture
):
original_identifiers = list(settings.lnbits_nostr_notifications_identifiers)
original_relays = list(settings.lnbits_nostr_notifications_relays)
try:
settings.lnbits_nostr_notifications_identifiers = [
"alice@example.com",
"bare-pubkey",
"bob@example.com",
settings.lnbits_nostr_notifications_relays = [
"invalid",
"wss://fallback",
"wss://fallback",
]
fetch_mock = mocker.patch(
"lnbits.core.services.notifications.fetch_nip5_details",
mocker.AsyncMock(
side_effect=[
("alice-pubkey", ["wss://one", "wss://shared"]),
("bob-pubkey", ["wss://shared", "wss://two"]),
]
),
resolve_mock = mocker.patch(
"lnbits.core.services.notifications.resolve_nostr_recipient",
mocker.AsyncMock(return_value=("recipient-pubkey", ["wss://fallback"])),
)
mocker.patch(
"lnbits.core.services.notifications.normalize_private_key",
return_value="server-private-key",
)
dm_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_dm",
mocker.AsyncMock(),
)
relays = await _fetch_configured_nostr_notification_relays()
await send_nostr_notification("f" * 64, "hello")
finally:
settings.lnbits_nostr_notifications_identifiers = original_identifiers
settings.lnbits_nostr_notifications_relays = original_relays
assert relays == ["wss://one", "wss://shared", "wss://two"]
assert [call.args[0] for call in fetch_mock.await_args_list] == [
"alice@example.com",
"bob@example.com",
]
fallback_mock = mocker.patch(
"lnbits.core.services.notifications._fetch_configured_nostr_notification_relays",
mocker.AsyncMock(return_value=relays),
)
resolve_mock = mocker.patch(
"lnbits.core.services.notifications.resolve_nostr_recipient",
mocker.AsyncMock(return_value=("recipient-pubkey", relays)),
)
mocker.patch(
"lnbits.core.services.notifications.normalize_private_key",
return_value="server-private-key",
)
dm_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_dm",
mocker.AsyncMock(),
)
await send_nostr_notification("f" * 64, "hello")
fallback_mock.assert_awaited_once_with()
resolve_mock.assert_awaited_once_with("f" * 64, relays)
resolve_mock.assert_awaited_once_with("f" * 64, ["wss://fallback"])
dm_mock.assert_awaited_once_with(
"server-private-key",
"recipient-pubkey",
"hello",
relays,
["wss://fallback"],
)

View file

@ -363,6 +363,7 @@ def test_asset_security_and_notification_helpers(
assert security_settings.is_wallet_max_balance_exceeded(100) is False
assert notification_settings.is_nostr_notifications_configured() is True
assert notification_settings.lnbits_nostr_notifications_dm_types == []
assert notification_settings.lnbits_nostr_notifications_relays == []
assert notification_settings.is_telegram_notifications_configured() is True
legacy_super_settings = dict_to_model(