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:
parent
6fbb6d4a42
commit
24acbe6674
6 changed files with 755 additions and 0 deletions
0
nostr/__init__.py
Normal file
0
nostr/__init__.py
Normal file
26
nostr/event.py
Normal file
26
nostr/event.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import hashlib
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NostrEvent(BaseModel):
|
||||
id: str = ""
|
||||
pubkey: str
|
||||
created_at: int
|
||||
kind: int
|
||||
tags: list[list[str]] = []
|
||||
content: str = ""
|
||||
sig: str | None = None
|
||||
|
||||
def serialize(self) -> list:
|
||||
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
|
||||
|
||||
def serialize_json(self) -> str:
|
||||
e = self.serialize()
|
||||
return json.dumps(e, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
@property
|
||||
def event_id(self) -> str:
|
||||
data = self.serialize_json()
|
||||
return hashlib.sha256(data.encode()).hexdigest()
|
||||
131
nostr/nostr_client.py
Normal file
131
nostr/nostr_client.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""
|
||||
Bidirectional Nostr client for the tasks extension.
|
||||
|
||||
Connects to the nostrclient extension's internal WebSocket to publish
|
||||
and subscribe to NIP-52 calendar events (kind 31922) and the
|
||||
task-status RSVP variant (kind 31925). Mirrors events/nostr/nostr_client.py
|
||||
with a TASKS log prefix and subscription-id namespace.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from asyncio import Queue
|
||||
from collections import OrderedDict
|
||||
|
||||
from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
|
||||
from lnbits.settings import settings
|
||||
from loguru import logger
|
||||
from websocket import WebSocketApp
|
||||
|
||||
from .event import NostrEvent
|
||||
|
||||
MAX_SEEN_EVENTS = 500
|
||||
|
||||
|
||||
class NostrClient:
|
||||
def __init__(self):
|
||||
self.receive_event_queue: Queue = Queue()
|
||||
self.send_req_queue: Queue = Queue()
|
||||
self.ws: WebSocketApp | None = None
|
||||
self.subscription_id = "tasks-" + urlsafe_short_hash()[:32]
|
||||
self.running = False
|
||||
self._seen_events: OrderedDict[str, None] = OrderedDict()
|
||||
|
||||
@property
|
||||
def is_websocket_connected(self):
|
||||
if not self.ws:
|
||||
return False
|
||||
return self.ws.keep_running
|
||||
|
||||
async def connect(self) -> WebSocketApp:
|
||||
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
|
||||
ws_url = (
|
||||
f"ws://localhost:{settings.port}" f"/nostrclient/api/v1/{relay_endpoint}"
|
||||
)
|
||||
|
||||
logger.info("[TASKS] Connecting to nostrclient WebSocket...")
|
||||
|
||||
def on_open(_):
|
||||
logger.info("[TASKS] Connected to nostrclient WebSocket")
|
||||
|
||||
def on_message(_, message):
|
||||
try:
|
||||
self.receive_event_queue.put_nowait(message)
|
||||
except Exception as e:
|
||||
logger.error(f"[TASKS] Failed to queue message: {e}")
|
||||
|
||||
def on_error(_, error):
|
||||
logger.warning(f"[TASKS] WebSocket error: {error}")
|
||||
|
||||
def on_close(_, status_code, message):
|
||||
logger.warning(f"[TASKS] WebSocket closed: {status_code} {message}")
|
||||
self.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
|
||||
|
||||
ws = WebSocketApp(
|
||||
ws_url,
|
||||
on_message=on_message,
|
||||
on_open=on_open,
|
||||
on_close=on_close,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
from threading import Thread
|
||||
|
||||
wst = Thread(target=ws.run_forever)
|
||||
wst.daemon = True
|
||||
wst.start()
|
||||
|
||||
return ws
|
||||
|
||||
async def run_forever(self):
|
||||
self.running = True
|
||||
while self.running:
|
||||
try:
|
||||
if not self.is_websocket_connected:
|
||||
self.ws = await self.connect()
|
||||
await asyncio.sleep(5)
|
||||
|
||||
req = await self.send_req_queue.get()
|
||||
assert self.ws
|
||||
self.ws.send(json.dumps(req))
|
||||
except Exception as ex:
|
||||
logger.warning(f"[TASKS] NostrClient error: {ex}")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
def is_duplicate_event(self, event_id: str) -> bool:
|
||||
if event_id in self._seen_events:
|
||||
return True
|
||||
self._seen_events[event_id] = None
|
||||
if len(self._seen_events) > MAX_SEEN_EVENTS:
|
||||
self._seen_events.popitem(last=False)
|
||||
return False
|
||||
|
||||
async def get_event(self):
|
||||
value = await self.receive_event_queue.get()
|
||||
if isinstance(value, ValueError):
|
||||
raise value
|
||||
return value
|
||||
|
||||
async def publish_nostr_event(self, e: NostrEvent):
|
||||
await self.send_req_queue.put(["EVENT", e.dict()])
|
||||
|
||||
async def subscribe(self, filters: list[dict]):
|
||||
self.subscription_id = "tasks-" + urlsafe_short_hash()[:32]
|
||||
await self.send_req_queue.put(["REQ", self.subscription_id, *filters])
|
||||
logger.info(
|
||||
f"[TASKS] Subscribed (sub: {self.subscription_id[:20]}...)"
|
||||
)
|
||||
|
||||
async def unsubscribe(self):
|
||||
await self.send_req_queue.put(["CLOSE", self.subscription_id])
|
||||
|
||||
async def stop(self):
|
||||
await self.unsubscribe()
|
||||
self.running = False
|
||||
await asyncio.sleep(2)
|
||||
if self.ws:
|
||||
try:
|
||||
self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
97
nostr_hooks.py
Normal file
97
nostr_hooks.py
Normal 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}")
|
||||
210
nostr_publisher.py
Normal file
210
nostr_publisher.py
Normal 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
|
||||
291
nostr_sync.py
Normal file
291
nostr_sync.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""
|
||||
Bidirectional Nostr sync for the tasks extension.
|
||||
|
||||
Subscribes to NIP-52 kind 31922 (filtered to `event-type: task` client-side
|
||||
because not all relays index our custom tag), kind 31925 completions, and
|
||||
kind 5 deletions targeting either. Upserts everything into the local DB
|
||||
so federated tasks published by other LNbits instances or webapp clients
|
||||
show up in `tasks.tasks` / `tasks.completions`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .crud import (
|
||||
create_completion,
|
||||
db,
|
||||
delete_completion,
|
||||
delete_task,
|
||||
delete_task_completions,
|
||||
get_completion_by_id,
|
||||
get_task_by_address,
|
||||
update_task,
|
||||
)
|
||||
from .models import (
|
||||
CreateTaskCompletion,
|
||||
Participant,
|
||||
Recurrence,
|
||||
Task,
|
||||
)
|
||||
from .nostr.nostr_client import NostrClient
|
||||
|
||||
|
||||
def _is_task_event(event_data: dict) -> bool:
|
||||
"""Filter 31922 events to ones that opt in to the task convention.
|
||||
|
||||
See aiolabs/webapp#25: kind 31922 is shared with activities so we
|
||||
must whitelist `event-type: task` to avoid swallowing community
|
||||
activity events."""
|
||||
for tag in event_data.get("tags", []):
|
||||
if len(tag) >= 2 and tag[0] == "event-type" and tag[1] == "task":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _handle_task_event(nostr_client: NostrClient, event_data: dict) -> None:
|
||||
event_id = event_data.get("id", "")
|
||||
if nostr_client.is_duplicate_event(event_id):
|
||||
return
|
||||
if not _is_task_event(event_data):
|
||||
return
|
||||
|
||||
raw_tags = event_data.get("tags", [])
|
||||
single_tags = {t[0]: t[1] for t in raw_tags if len(t) >= 2}
|
||||
tag_lists: dict[str, list[list[str]]] = {}
|
||||
for t in raw_tags:
|
||||
if len(t) >= 1:
|
||||
tag_lists.setdefault(t[0], []).append(t)
|
||||
|
||||
d_tag = single_tags.get("d")
|
||||
if not d_tag:
|
||||
return
|
||||
start = single_tags.get("start")
|
||||
if not start:
|
||||
return
|
||||
|
||||
title = single_tags.get("title", "Untitled Task")
|
||||
end = single_tags.get("end")
|
||||
description = event_data.get("content", "")
|
||||
location = single_tags.get("location")
|
||||
status = single_tags.get("status", "pending")
|
||||
event_type = single_tags.get("event-type", "task")
|
||||
|
||||
categories = [t[1] for t in tag_lists.get("t", []) if len(t) >= 2]
|
||||
participants: list[Participant] = []
|
||||
for t in tag_lists.get("p", []):
|
||||
if len(t) < 2:
|
||||
continue
|
||||
ptype = t[3] if len(t) >= 4 else None
|
||||
participants.append(Participant(pubkey=t[1], type=ptype))
|
||||
|
||||
recurrence = None
|
||||
rec_freq = single_tags.get("recurrence")
|
||||
if rec_freq in ("daily", "weekly"):
|
||||
recurrence = Recurrence(
|
||||
frequency=rec_freq,
|
||||
day_of_week=single_tags.get("recurrence-day"),
|
||||
end_date=single_tags.get("recurrence-end"),
|
||||
)
|
||||
|
||||
incoming_created_at = event_data.get("created_at", 0)
|
||||
pubkey = event_data.get("pubkey", "")
|
||||
|
||||
existing = await get_task_by_address(pubkey, d_tag)
|
||||
if existing:
|
||||
if (
|
||||
existing.nostr_event_created_at
|
||||
and incoming_created_at <= existing.nostr_event_created_at
|
||||
):
|
||||
return
|
||||
existing.title = title
|
||||
existing.start_date = start
|
||||
existing.end_date = end
|
||||
existing.description = description
|
||||
existing.location = location
|
||||
existing.status = status
|
||||
existing.event_type = event_type
|
||||
existing.participants = participants
|
||||
existing.categories = categories
|
||||
existing.recurrence = recurrence
|
||||
existing.nostr_event_id = event_id
|
||||
existing.nostr_event_created_at = incoming_created_at
|
||||
await update_task(existing)
|
||||
logger.info(f"[TASKS] Updated task from Nostr: {title}")
|
||||
return
|
||||
|
||||
# New task discovered from Nostr — wallet stays empty (we don't own it).
|
||||
new_task = Task(
|
||||
id=d_tag,
|
||||
wallet="",
|
||||
pubkey=pubkey,
|
||||
d_tag=d_tag,
|
||||
title=title,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
description=description,
|
||||
location=location,
|
||||
status=status,
|
||||
event_type=event_type,
|
||||
participants=participants,
|
||||
categories=categories,
|
||||
recurrence=recurrence,
|
||||
nostr_event_id=event_id,
|
||||
nostr_event_created_at=incoming_created_at,
|
||||
time=datetime.now(timezone.utc),
|
||||
)
|
||||
try:
|
||||
# Bypass db.insert so we can route the JSON-encoded payload through
|
||||
# the same serializer crud.create_task uses.
|
||||
from .crud import _serialize_task_jsonb
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO tasks.tasks (
|
||||
id, wallet, pubkey, d_tag, title, start_date, end_date,
|
||||
description, location, status, event_type, participants,
|
||||
categories, recurrence, nostr_event_id,
|
||||
nostr_event_created_at, time
|
||||
) VALUES (
|
||||
:id, :wallet, :pubkey, :d_tag, :title, :start_date,
|
||||
:end_date, :description, :location, :status, :event_type,
|
||||
:participants, :categories, :recurrence, :nostr_event_id,
|
||||
:nostr_event_created_at, :time
|
||||
)
|
||||
""",
|
||||
_serialize_task_jsonb(new_task.dict()),
|
||||
)
|
||||
logger.info(f"[TASKS] Discovered task from Nostr: {title}")
|
||||
except Exception as e:
|
||||
logger.debug(f"[TASKS] Skipped duplicate task insert: {e}")
|
||||
|
||||
|
||||
async def _handle_completion_event(
|
||||
nostr_client: NostrClient, event_data: dict
|
||||
) -> None:
|
||||
event_id = event_data.get("id", "")
|
||||
if nostr_client.is_duplicate_event(event_id):
|
||||
return
|
||||
|
||||
raw_tags = event_data.get("tags", [])
|
||||
single_tags = {t[0]: t[1] for t in raw_tags if len(t) >= 2}
|
||||
|
||||
task_address = single_tags.get("a")
|
||||
if not task_address or not task_address.startswith("31922:"):
|
||||
return
|
||||
|
||||
task_status = single_tags.get("task-status")
|
||||
if task_status not in (
|
||||
"claimed", "in-progress", "completed", "blocked", "cancelled"
|
||||
):
|
||||
# Fall back to NIP-52 `status` semantics if a plain RSVP arrives
|
||||
# claiming/accepting the task. Treat anything else as a claim.
|
||||
task_status = "claimed"
|
||||
|
||||
completed_at_str = single_tags.get("completed_at")
|
||||
completed_at = int(completed_at_str) if completed_at_str else None
|
||||
occurrence = single_tags.get("occurrence")
|
||||
|
||||
pubkey = event_data.get("pubkey", "")
|
||||
incoming_created_at = event_data.get("created_at", 0)
|
||||
|
||||
# If we already have a newer completion for this triple, skip.
|
||||
existing_dup = await get_completion_by_id(event_id)
|
||||
if existing_dup:
|
||||
return
|
||||
|
||||
await create_completion(
|
||||
pubkey,
|
||||
CreateTaskCompletion(
|
||||
task_address=task_address,
|
||||
task_status=task_status,
|
||||
occurrence=occurrence,
|
||||
completed_at=completed_at,
|
||||
notes=event_data.get("content", ""),
|
||||
),
|
||||
nostr_event_id=event_id,
|
||||
nostr_created_at=incoming_created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_deletion_event(event_data: dict) -> None:
|
||||
"""Route NIP-09 deletions by their `k` tag to the right resource."""
|
||||
raw_tags = event_data.get("tags", [])
|
||||
k_tag = next(
|
||||
(t[1] for t in raw_tags if len(t) >= 2 and t[0] == "k"),
|
||||
None,
|
||||
)
|
||||
author = event_data.get("pubkey", "")
|
||||
|
||||
if k_tag == "31922":
|
||||
# Delete by address: only honor when the deleter is the task's author.
|
||||
for t in raw_tags:
|
||||
if len(t) >= 2 and t[0] == "a" and t[1].startswith("31922:"):
|
||||
_, addr_pubkey, d_tag = t[1].split(":", 2)
|
||||
if addr_pubkey != author:
|
||||
continue
|
||||
existing = await get_task_by_address(addr_pubkey, d_tag)
|
||||
if existing:
|
||||
await delete_task(existing.id)
|
||||
await delete_task_completions(existing.address)
|
||||
logger.info(f"[TASKS] Deleted task from Nostr: {d_tag}")
|
||||
|
||||
elif k_tag == "31925":
|
||||
for t in raw_tags:
|
||||
if len(t) >= 2 and t[0] == "e":
|
||||
comp = await get_completion_by_id(t[1])
|
||||
if comp and comp.pubkey == author:
|
||||
await delete_completion(comp.id)
|
||||
|
||||
|
||||
async def process_nostr_message(nostr_client: NostrClient, message: str) -> None:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
if not isinstance(data, list) or len(data) < 2:
|
||||
return
|
||||
|
||||
msg_type = data[0]
|
||||
if msg_type == "EVENT" and len(data) >= 3:
|
||||
event_data = data[2]
|
||||
kind = event_data.get("kind")
|
||||
if kind == 31922:
|
||||
await _handle_task_event(nostr_client, event_data)
|
||||
elif kind == 31925:
|
||||
await _handle_completion_event(nostr_client, event_data)
|
||||
elif kind == 5:
|
||||
await _handle_deletion_event(event_data)
|
||||
elif msg_type == "EOSE":
|
||||
logger.debug("[TASKS] End of stored events from relay")
|
||||
elif msg_type == "NOTICE":
|
||||
logger.info(f"[TASKS] Relay notice: {data[1]}")
|
||||
|
||||
|
||||
async def wait_for_nostr_events(nostr_client: NostrClient) -> None:
|
||||
"""Background task: subscribe and feed inbound events through the
|
||||
handlers above. We can't use the `#event-type=task` filter at the
|
||||
relay level because most relays don't index custom single-letter
|
||||
tags, so we accept all 31922 and filter in _handle_task_event."""
|
||||
logger.info("[TASKS] Starting Nostr event sync...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await nostr_client.subscribe(
|
||||
[
|
||||
{"kinds": [31922]},
|
||||
{"kinds": [31925]},
|
||||
{"kinds": [5], "#k": ["31922", "31925"]},
|
||||
]
|
||||
)
|
||||
while True:
|
||||
message = await nostr_client.get_event()
|
||||
await process_nostr_message(nostr_client, message)
|
||||
except ValueError:
|
||||
logger.warning("[TASKS] Nostr connection lost, resubscribing...")
|
||||
await asyncio.sleep(10)
|
||||
except Exception as e:
|
||||
logger.error(f"[TASKS] Nostr sync error: {e}")
|
||||
await asyncio.sleep(30)
|
||||
Loading…
Add table
Add a link
Reference in a new issue