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:
parent
786857f568
commit
8d19da43c2
2 changed files with 212 additions and 0 deletions
202
notify.py
Normal file
202
notify.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue