feat(notify): alert the operator over Nostr when a customer is owed cash

cash_owed / partial_pending now sends a NIP-17 gift-wrapped DM (kind 14
rumor → kind 13 seal signed through the operator's signer → kind 1059
wrap from a throwaway key) to the operator's own account pubkey, or to
super_config.alerts_pubkey when set. Best-effort: a failed publish is
logged and the dispense report is still acked; the worklist is the
durable record. No email channel, no NIP-04.
This commit is contained in:
Padreug 2026-10-10 22:26:45 +02:00
commit 8d19da43c2
2 changed files with 212 additions and 0 deletions

202
notify.py Normal file
View file

@ -0,0 +1,202 @@
"""
Operator alerts over Nostr (bitspire ADR-005 §6 / Future directions).
When a cash-out settlement lands in `cash_owed` or `partial_pending` — a
customer has paid and is owed cash — the operator is told, immediately, as a
Nostr event addressed to their pubkey. Not email, not SMS (lamassu's
`notifyOperator` channel). The pieces this reuses:
- the recipient is the operator's own LNbits-account pubkey (`account.pubkey`,
which `get_machine_config` already refuses to run without), or
`super_config.alerts_pubkey` when set — a phone identity, say;
- the sender signs and NIP-44-encrypts through the operator's signer
(bunker-backed where the operator has moved to one), so no key is at rest
here. For the default recipient this is a note to self, which every NIP-46
client renders as a DM thread with yourself.
Wire: NIP-17. A kind-14 rumor (unsigned) is sealed (kind 13, NIP-44 to the
recipient, signed by the sender) and gift-wrapped (kind 1059, NIP-44 to the
recipient, signed by a throwaway key, `p`-tagged to the recipient, stamped at
a random moment in the last two days). The relay sees a throwaway pubkey and
ciphertext; only the recipient can open it. NIP-04 is not used anywhere in
this stack.
Best-effort by contract: a failed alert is logged and never fails the RPC
that triggered it — the dispense report must still be acknowledged, and the
worklist is the durable record. `operator_notified_at` is set only on a
successful publish, so a report resend cannot re-alert.
"""
from __future__ import annotations
import hashlib
import json
import random
import time
from loguru import logger
from .crud import get_super_config, mark_settlement_notified
from .models import DcaSettlement, DispenseReportIn, Machine
from .nostr_publish import (
NostrPublishError,
nip44_encrypt_via_signer,
publish_signed_event,
resolve_operator_signer,
sign_as_operator,
)
KIND_DM = 14
KIND_SEAL = 13
KIND_GIFT_WRAP = 1059
# NIP-17: randomise outer timestamps up to two days in the past so the wrap
# does not reveal when the conversation happened.
_MAX_BACKDATE_S = 2 * 24 * 60 * 60
def _canonical_id(
pubkey: str, created_at: int, kind: int, tags: list, content: str
) -> str:
from lnbits.utils.nostr import json_dumps
return hashlib.sha256(
json_dumps([0, pubkey, created_at, kind, tags, content]).encode()
).hexdigest()
def _backdated_now() -> int:
return int(time.time()) - random.randint(0, _MAX_BACKDATE_S) # noqa: S311
def _wrap_with_ephemeral(plaintext: str, recipient: str, created_at: int) -> dict:
"""Kind-1059 gift wrap: NIP-44-encrypt `plaintext` to `recipient` from a
freshly generated key, sign with that key, and forget it."""
import coincurve
from lnbits.utils.nostr import generate_keypair, sign_event
from .nip44 import encrypt_for
priv_hex, pub_hex = generate_keypair()
wrap = {
"kind": KIND_GIFT_WRAP,
"created_at": created_at,
"tags": [["p", recipient]],
"content": encrypt_for(plaintext, priv_hex, recipient),
}
return sign_event(wrap, pub_hex, coincurve.PrivateKey(bytes.fromhex(priv_hex)))
async def build_gift_wrapped_dm(
operator_user_id: str, recipient_pubkey_hex: str, text: str
) -> dict:
"""Build a NIP-17 gift-wrapped DM from the operator's identity to
`recipient_pubkey_hex`. Returns the signed kind-1059 event, ready to publish.
Raises the typed NostrPublishError subclasses from nostr_publish on a hard
failure (no pubkey on file, signer unavailable, …)."""
account, signer = await resolve_operator_signer(operator_user_id)
sender = account.pubkey
recipient = recipient_pubkey_hex.lower()
# 1. rumor — unsigned kind 14, id computed so clients can thread it
rumor_created = int(time.time())
rumor_tags = [["p", recipient]]
rumor = {
"id": _canonical_id(sender, rumor_created, KIND_DM, rumor_tags, text),
"pubkey": sender,
"created_at": rumor_created,
"kind": KIND_DM,
"tags": rumor_tags,
"content": text,
}
# 2. seal — NIP-44 to the recipient, signed by the sender (the bunker
# path never sees the operator's key here)
sealed_content = await nip44_encrypt_via_signer(
account, signer, json.dumps(rumor, separators=(",", ":")), recipient
)
seal = {"kind": KIND_SEAL, "tags": [], "content": sealed_content}
seal = await sign_as_operator(operator_user_id, seal)
# 3. gift wrap — NIP-44 from a throwaway key to the recipient, p-tagged,
# backdated, signed by that same throwaway key, which is then forgotten
return _wrap_with_ephemeral(
json.dumps(seal, separators=(",", ":")), recipient, _backdated_now()
)
async def resolve_alert_recipient(operator_user_id: str) -> str:
"""`super_config.alerts_pubkey` when set, else the operator's own pubkey."""
cfg = await get_super_config()
override = (getattr(cfg, "alerts_pubkey", None) or "").strip() if cfg else ""
if override:
return override.lower()
account, _signer = await resolve_operator_signer(operator_user_id)
return account.pubkey
def format_cash_outcome_alert(
settlement: DcaSettlement, machine: Machine, report: DispenseReportIn, status: str
) -> str:
"""The message the operator reads on their phone. Plain text, the facts
they need to act, no raw dispenser code (that is in the dashboard)."""
name = machine.name or machine.id
paid = f"{report.fiat_cents / 100:.2f} {report.currency}"
out = f"{report.dispensed_fiat_cents / 100:.2f} {report.currency}"
if status == "cash_owed":
head = f"⚠️ CASH OWED — {name}"
body = f"A customer paid {paid} and received nothing."
else:
head = f"⚠️ PARTIAL DISPENSE — {name}"
body = f"A customer paid {paid} and received {out}."
lines = [
head,
body,
f"Cause: {report.error or 'no dispenser error reported'}"
+ (f" ({report.error_code})" if report.error_code else ""),
f"Transaction: {report.txid}",
f"Settlement: {settlement.id}",
"Nothing has been distributed. Open spirekeeper → Worklist to resolve "
"(remediate at the machine, or settle off-machine).",
]
if report.error_class == "terminal":
lines.append(
"The machine has taken cash-out out of service until you recount "
"or resume it."
)
return "\n".join(lines)
async def notify_cash_outcome(
settlement: DcaSettlement,
machine: Machine,
report: DispenseReportIn,
status: str,
) -> bool:
"""Alert the operator that `settlement` needs a human. Best-effort:
returns True if the DM was published, False (after logging) otherwise.
Never raises — the caller is the dispense-report RPC and must ack."""
if getattr(settlement, "operator_notified_at", None):
return False
try:
recipient = await resolve_alert_recipient(machine.operator_user_id)
text = format_cash_outcome_alert(settlement, machine, report, status)
wrapped = await build_gift_wrapped_dm(machine.operator_user_id, recipient, text)
await publish_signed_event(wrapped)
await mark_settlement_notified(settlement.id)
logger.info(
f"spirekeeper: operator alerted ({status}) for settlement {settlement.id} "
f"→ {recipient[:12]}… (gift wrap {wrapped['id'][:12]}…)"
)
return True
except NostrPublishError as exc:
logger.warning(
f"spirekeeper: could not alert operator for settlement {settlement.id}: {exc} "
"— the worklist still has it"
)
except Exception as exc: # never let an alert break the RPC
logger.error(
f"spirekeeper: unexpected error alerting operator for settlement "
f"{settlement.id}: {exc!r}"
)
return False