tasks/nostr_publisher.py
Padreug e307829b50 feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#3)
Closes aiolabs/tasks#3. Pre-cascade prerequisite for aiolabs/lnbits#17
(signer abstraction phase 1), which lands an m002 startup job that
NULLs the legacy `accounts.prvkey` column. After this migration, the
tasks extension reads no plaintext nsec and works with any
NostrSigner backend (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner).

## What changed

### nostr_hooks.py — three publisher entry points

Was: `_account_keys(wallet_id)` helper pulled `(account.pubkey,
account.prvkey)` from the wallet's owning account, returned None when
prvkey was missing, then passed both to the publishers.

Now: each of `publish_or_delete_task_event`,
`publish_task_completion`, and `publish_completion_delete` calls
`await resolve_for_wallet(...)` (the DRY helper from
aiolabs/lnbits#23 — wallet → account → signer → can_sign-check in
one call, returns None on any soft-fail). The resolved `NostrSigner`
is passed to the publisher. Soft-skip on None (wallet missing,
account unclassified, or ClientSideOnlySigner where the server has
no signing authority).

Removed the `_account_keys` helper entirely.

### nostr_publisher.py — three publishers

Was: `publish_task_to_nostr`, `publish_completion_to_nostr`, and
`publish_completion_delete_to_nostr` each accepted
`(account_pubkey: str, account_prvkey: str)` and signed via a local
`sign_nostr_event` helper that called `coincurve.PrivateKey
.sign_schnorr` directly on the plaintext nsec.

Now: each publisher accepts `signer: NostrSigner`. Signing is
factored into a shared `_sign_and_publish` helper that builds the
unsigned event dict (`kind`/`created_at`/`tags`/`content`), hands it
to `await signer.sign_event(...)`, and writes `id`/`pubkey`/`sig`
back onto the local `NostrEvent` model before publishing. The signer
backend (LocalSigner / RemoteBunkerSigner) is transparent.

Removed the `sign_nostr_event` helper entirely — the signer
abstraction handles all signing now.

Dropped the `coincurve` import; no direct crypto in this extension.

## Acceptance

- [x] `_account_keys` helper removed (nostr_hooks no longer touches account.prvkey)
- [x] all three publishers accept NostrSigner instead of (pubkey, prvkey)
- [x] extension-local Schnorr code removed (sign_nostr_event gone)
- [x] coincurve import dropped
- [x] re-grep `tasks/`: zero `account.prvkey` references
- [x] version bumped: 0.0.1 → 0.0.2 (catalog entry deferred until cascade lands)

Manual smoke testing + tag + catalog entry follow the migration
landing; will run against the regtest stack with lnbits on
`issue-18-phase-2.3` (which validates both LocalSigner and
RemoteBunkerSigner signing paths end-to-end).

## Cross-references

- aiolabs/tasks#3 — issue this commit closes
- aiolabs/lnbits#17 — the cascading signer-abstraction PR
- aiolabs/lnbits#23 — the resolve_for_wallet helper this uses
- aiolabs/lnbits#21 — umbrella audit (5 affected extensions)
- aiolabs/events#23 — sister migration (already on signer-abstraction branch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:18:04 +02:00

221 lines
6.9 KiB
Python

"""
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).
Signing routes through the core `NostrSigner` abstraction
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The signer
fills in `id`, `pubkey`, and `sig` per NIP-01 — same serialization
rules as our local `event_id` property uses, so the returned id
matches what we'd have computed locally.
"""
import time
from lnbits.core.signers import NostrSigner
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
async def _sign_and_publish(
nostr_client, nostr_event: NostrEvent, signer: NostrSigner
) -> NostrEvent:
"""Hand the unsigned event to the signer — it fills in `id`, `pubkey`,
and `sig` — then publish through the relay client. Mutates `nostr_event`
in place with the canonical signed values."""
unsigned = {
"kind": nostr_event.kind,
"created_at": nostr_event.created_at,
"tags": nostr_event.tags,
"content": nostr_event.content,
}
signed = await signer.sign_event(unsigned)
nostr_event.id = signed["id"]
nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"]
await nostr_client.publish_nostr_event(nostr_event)
return nostr_event
async def publish_task_to_nostr(
nostr_client,
task: Task,
signer: NostrSigner,
*,
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, signer.pubkey)
if delete
else build_task_event(task, signer.pubkey)
)
await _sign_and_publish(nostr_client, nostr_event, signer)
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,
signer: NostrSigner,
) -> 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, signer.pubkey
)
await _sign_and_publish(nostr_client, nostr_event, signer)
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,
signer: NostrSigner,
) -> NostrEvent | None:
if not nostr_client:
return None
try:
nostr_event = build_delete_completion_event(completion_id, signer.pubkey)
await _sign_and_publish(nostr_client, nostr_event, signer)
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