ADR-005 slice 2: operator alert, settle off-machine, glossary + guide #50
2 changed files with 212 additions and 0 deletions
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.
commit
8d19da43c2
|
|
@ -80,6 +80,14 @@ def _spawn_distribution(settlement_id: str) -> None:
|
||||||
task.add_done_callback(_inflight.discard)
|
task.add_done_callback(_inflight.discard)
|
||||||
|
|
||||||
|
|
||||||
|
async def _alert_operator(settlement, machine: Machine, report: DispenseReportIn, status: str) -> None:
|
||||||
|
"""ADR-005 §6: a human is now owed money — tell the operator over Nostr.
|
||||||
|
Best-effort; the report is acked regardless (see notify.py)."""
|
||||||
|
from .notify import notify_cash_outcome
|
||||||
|
|
||||||
|
await notify_cash_outcome(settlement, machine, report, status)
|
||||||
|
|
||||||
|
|
||||||
async def apply_report_to_settlement(
|
async def apply_report_to_settlement(
|
||||||
settlement: DcaSettlement,
|
settlement: DcaSettlement,
|
||||||
report: DispenseReportIn,
|
report: DispenseReportIn,
|
||||||
|
|
@ -118,6 +126,7 @@ async def apply_report_to_settlement(
|
||||||
f"{report.fiat_cents / 100:.2f} {report.currency}) — distributing"
|
f"{report.fiat_cents / 100:.2f} {report.currency}) — distributing"
|
||||||
)
|
)
|
||||||
elif new_status == "partial_pending":
|
elif new_status == "partial_pending":
|
||||||
|
await _alert_operator(updated or settlement, machine, report, new_status)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"spirekeeper: PARTIAL dispense for settlement {settlement.id} "
|
f"spirekeeper: PARTIAL dispense for settlement {settlement.id} "
|
||||||
f"(machine={machine.id}, txid={report.txid}): "
|
f"(machine={machine.id}, txid={report.txid}): "
|
||||||
|
|
@ -127,6 +136,7 @@ async def apply_report_to_settlement(
|
||||||
f"{report.raw_code or ''}. Held until the operator resolves the shortfall."
|
f"{report.raw_code or ''}. Held until the operator resolves the shortfall."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
await _alert_operator(updated or settlement, machine, report, new_status)
|
||||||
logger.error(
|
logger.error(
|
||||||
f"spirekeeper: CASH OWED — settlement {settlement.id} "
|
f"spirekeeper: CASH OWED — settlement {settlement.id} "
|
||||||
f"(machine={machine.id}, txid={report.txid}): customer paid "
|
f"(machine={machine.id}, txid={report.txid}): customer paid "
|
||||||
|
|
|
||||||
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