add Nostr publishing and bidirectional sync

- nostr/ vendors NostrEvent + the nostrclient WebSocket bridge from
  the events extension, retagged [TASKS] / subscription-id "tasks-*".
- nostr_publisher builds kind 31922 with the `event-type: task` tag
  (per aiolabs/webapp#25 — disambiguates from kind-31922 activities on
  shared relays), kind 31925 with task-status / occurrence /
  completed_at, and kind 5 deletions for both.
- nostr_hooks bridges task/completion mutations to the publisher and
  persists the resulting nostr_event_id back onto the local row.
- nostr_sync subscribes to {31922, 31925, 5/#k} and filters 31922
  client-side on `event-type: task` because most relays don't index
  custom single-letter tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-13 11:38:42 +02:00
commit 24acbe6674
6 changed files with 755 additions and 0 deletions

97
nostr_hooks.py Normal file
View file

@ -0,0 +1,97 @@
"""Helpers that bridge task-mutation handlers to the Nostr publisher.
Sits between views_api and nostr_publisher so we don't pull the publisher
through the views module (which would create an import cycle via models)."""
from loguru import logger
from .crud import update_task
from .models import Task, TaskCompletion
from .nostr_publisher import (
publish_completion_delete_to_nostr,
publish_completion_to_nostr,
publish_task_to_nostr,
)
async def _account_keys(wallet_id: str) -> tuple[str, str] | None:
"""Fetch (pubkey, prvkey) for the wallet's owning account. Returns None
when the account is missing keys, so callers can skip cleanly."""
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
wallet_obj = await get_wallet(wallet_id)
if not wallet_obj:
return None
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey: # type: ignore[attr-defined]
return None
return account.pubkey, account.prvkey # type: ignore[attr-defined]
async def publish_or_delete_task_event(
task: Task, *, delete: bool = False
) -> None:
"""Publish (or delete-publish) the NIP-52 kind 31922 for `task`.
Errors are logged and swallowed so a Nostr outage doesn't break the
HTTP flow that triggered the publish."""
try:
from . import nostr_client
keys = await _account_keys(task.wallet)
if not keys:
return
pubkey, prvkey = keys
nostr_event = await publish_task_to_nostr(
nostr_client, task, pubkey, prvkey, delete=delete
)
if nostr_event and not delete:
task.nostr_event_id = nostr_event.id
task.nostr_event_created_at = nostr_event.created_at
await update_task(task)
except Exception as exc:
logger.warning(f"[TASKS] Nostr task publish failed: {exc}")
async def publish_task_completion(
task: Task, completion: TaskCompletion
) -> str | None:
"""Publish a kind 31925 completion. Returns the Nostr event id so the
caller can persist it as the completion's primary key, replacing the
locally-generated hash from the optimistic insert."""
try:
from . import nostr_client
keys = await _account_keys(task.wallet)
if not keys:
return None
pubkey, prvkey = keys
nostr_event = await publish_completion_to_nostr(
nostr_client, task.address, completion, pubkey, prvkey
)
return nostr_event.id if nostr_event else None
except Exception as exc:
logger.warning(f"[TASKS] Nostr completion publish failed: {exc}")
return None
async def publish_completion_delete(
wallet_id: str, completion_id: str
) -> None:
"""Publish a NIP-09 delete for a previously-published completion."""
try:
from . import nostr_client
keys = await _account_keys(wallet_id)
if not keys:
return
pubkey, prvkey = keys
await publish_completion_delete_to_nostr(
nostr_client, completion_id, pubkey, prvkey
)
except Exception as exc:
logger.warning(f"[TASKS] Nostr completion delete failed: {exc}")