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

210
nostr_publisher.py Normal file
View file

@ -0,0 +1,210 @@
"""
NIP-52 calendar-event publishing for the tasks extension.
Builds:
- kind 31922 task events (with `event-type: task` so the activities
feed can filter us out see aiolabs/webapp#25 for context),
- kind 31925 RSVP-shaped completion events (carrying `task-status`,
optional `occurrence`, and `completed_at`),
- kind 5 deletes referencing either an `a` (for tasks) or `e` tag
(for completions).
Signs with the wallet owner's Account keypair and publishes via the
NostrClient wrapping nostrclient's WebSocket.
"""
import time
import coincurve
from loguru import logger
from .models import Task, TaskCompletion
from .nostr.event import NostrEvent
def build_task_event(task: Task, pubkey: str) -> NostrEvent:
"""Convert a Task to a NIP-52 kind 31922 calendar event tagged
`event-type: task` so it is recognizable as a task on shared relays."""
tags: list[list[str]] = [
["d", task.d_tag],
["title", task.title],
["start", task.start_date],
["event-type", "task"],
]
if task.end_date:
tags.append(["end", task.end_date])
if task.location:
tags.append(["location", task.location])
if task.status:
tags.append(["status", task.status])
for cat in task.categories or []:
tags.append(["t", cat])
for participant in task.participants or []:
p_tag = ["p", participant.pubkey]
if participant.type:
# NIP-52 'p' tag accepts a 'relay' slot before role; we leave
# it empty (just ["p", pk, "", role]) to keep parity with the
# webapp emitter.
p_tag.extend(["", participant.type])
tags.append(p_tag)
if task.recurrence:
tags.append(["recurrence", task.recurrence.frequency])
if task.recurrence.day_of_week:
tags.append(["recurrence-day", task.recurrence.day_of_week])
if task.recurrence.end_date:
tags.append(["recurrence-end", task.recurrence.end_date])
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=31922,
tags=tags,
content=task.description or "",
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_completion_event(
task_address: str, completion: TaskCompletion, pubkey: str
) -> NostrEvent:
"""Build a kind 31925 RSVP carrying our extension's `task-status` tag."""
tags: list[list[str]] = [
["a", task_address],
["task-status", completion.task_status],
]
if completion.occurrence:
tags.append(["occurrence", completion.occurrence])
if completion.completed_at:
tags.append(["completed_at", str(completion.completed_at)])
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=31925,
tags=tags,
content=completion.notes or "",
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_delete_task_event(task: Task, pubkey: str) -> NostrEvent:
"""Kind 5 deletion of a (replaceable) kind 31922 task via its `a` tag."""
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=5,
tags=[
["a", f"31922:{pubkey}:{task.d_tag}"],
["k", "31922"],
],
content="Task deleted",
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_delete_completion_event(
completion_id: str, pubkey: str
) -> NostrEvent:
"""Kind 5 deletion of a kind 31925 completion via its `e` tag (the
completion is non-replaceable so we reference by event id, not address)."""
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=5,
tags=[
["e", completion_id],
["k", "31925"],
],
content="Task unclaimed",
)
nostr_event.id = nostr_event.event_id
return nostr_event
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_task_to_nostr(
nostr_client,
task: Task,
account_pubkey: str,
account_prvkey: str,
*,
delete: bool = False,
) -> NostrEvent | None:
if not nostr_client:
logger.debug("[TASKS] No NostrClient available, skipping publish")
return None
try:
nostr_event = (
build_delete_task_event(task, account_pubkey)
if delete
else build_task_event(task, account_pubkey)
)
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event)
logger.info(
f"[TASKS] Published {'delete' if delete else '31922 task'} "
f"{nostr_event.id[:16]}... (kind {nostr_event.kind})"
)
return nostr_event
except Exception as e:
logger.warning(f"[TASKS] Failed to publish task: {e}")
return None
async def publish_completion_to_nostr(
nostr_client,
task_address: str,
completion: TaskCompletion,
account_pubkey: str,
account_prvkey: str,
) -> NostrEvent | None:
if not nostr_client:
logger.debug("[TASKS] No NostrClient available, skipping publish")
return None
try:
nostr_event = build_completion_event(
task_address, completion, account_pubkey
)
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event)
logger.info(
f"[TASKS] Published 31925 completion {nostr_event.id[:16]}... "
f"(status: {completion.task_status})"
)
return nostr_event
except Exception as e:
logger.warning(f"[TASKS] Failed to publish completion: {e}")
return None
async def publish_completion_delete_to_nostr(
nostr_client,
completion_id: str,
account_pubkey: str,
account_prvkey: str,
) -> NostrEvent | None:
if not nostr_client:
return None
try:
nostr_event = build_delete_completion_event(completion_id, account_pubkey)
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event)
logger.info(
f"[TASKS] Published completion delete {nostr_event.id[:16]}..."
)
return nostr_event
except Exception as e:
logger.warning(f"[TASKS] Failed to publish completion delete: {e}")
return None