diff --git a/__init__.py b/__init__.py
index 9e188f2..b4f7af0 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,4 +1,5 @@
import asyncio
+from asyncio import Queue, Task
from typing import List
from fastapi import APIRouter
@@ -25,15 +26,29 @@ def nostrmarket_renderer():
return template_renderer(["lnbits/extensions/nostrmarket/templates"])
-scheduled_tasks: List[asyncio.Task] = []
+recieve_event_queue: Queue = Queue()
+send_req_queue: Queue = Queue()
+scheduled_tasks: List[Task] = []
-from .tasks import subscribe_nostrclient_ws, wait_for_paid_invoices
+
+from .tasks import (
+ subscribe_to_nostr_client,
+ wait_for_nostr_events,
+ wait_for_paid_invoices,
+)
from .views import * # noqa
from .views_api import * # noqa
def nostrmarket_start():
+ async def _subscribe_to_nostr_client():
+ await subscribe_to_nostr_client(recieve_event_queue, send_req_queue)
+
+ async def _wait_for_nostr_events():
+ await wait_for_nostr_events(recieve_event_queue, send_req_queue)
+
loop = asyncio.get_event_loop()
task1 = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
- task2 = loop.create_task(catch_everything_and_restart(subscribe_nostrclient_ws))
- scheduled_tasks.append([task1, task2])
+ task2 = loop.create_task(catch_everything_and_restart(_subscribe_to_nostr_client))
+ task3 = loop.create_task(catch_everything_and_restart(_wait_for_nostr_events))
+ scheduled_tasks.append([task1, task2, task3])
diff --git a/crud.py b/crud.py
index 990b9ad..2e6013b 100644
--- a/crud.py
+++ b/crud.py
@@ -1,5 +1,4 @@
import json
-import time
from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash
@@ -7,6 +6,7 @@ from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
Merchant,
+ Order,
PartialMerchant,
PartialProduct,
PartialStall,
@@ -45,6 +45,23 @@ async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
return Merchant.from_row(row) if row else None
+async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
+ row = await db.fetchone(
+ """SELECT * FROM nostrmarket.merchants WHERE public_key = ? """,
+ (public_key,),
+ )
+
+ return Merchant.from_row(row) if row else None
+
+
+async def get_public_keys_for_merchants() -> List[str]:
+ rows = await db.fetchall(
+ """SELECT public_key FROM nostrmarket.merchants""",
+ )
+
+ return [row[0] for row in rows]
+
+
async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
row = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = ? """,
@@ -189,7 +206,7 @@ async def delete_stall(user_id: str, stall_id: str) -> None:
)
-######################################## STALL ########################################
+######################################## PRODUCTS ########################################
async def create_product(user_id: str, data: PartialProduct) -> Product:
@@ -197,7 +214,7 @@ async def create_product(user_id: str, data: PartialProduct) -> Product:
await db.execute(
f"""
- INSERT INTO nostrmarket.products (user_id, id, stall_id, name, images, price, quantity, category_list, meta)
+ INSERT INTO nostrmarket.products (user_id, id, stall_id, name, image, price, quantity, category_list, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
@@ -261,6 +278,28 @@ async def get_products(user_id: str, stall_id: str) -> List[Product]:
return [Product.from_row(row) for row in rows]
+async def get_products_by_ids(user_id: str, product_ids: List[str]) -> List[Product]:
+ q = ",".join(["?"] * len(product_ids))
+ rows = await db.fetchall(
+ f"SELECT id, stall_id, name, price, quantity, category_list, meta FROM nostrmarket.products WHERE user_id = ? AND id IN ({q})",
+ (user_id, *product_ids),
+ )
+ return [Product.from_row(row) for row in rows]
+
+
+async def get_wallet_for_product(product_id: str) -> Optional[str]:
+ row = await db.fetchone(
+ """
+ SELECT s.wallet FROM nostrmarket.products p
+ INNER JOIN nostrmarket.stalls s
+ ON p.stall_id = s.id
+ WHERE p.id=?
+ """,
+ (product_id,),
+ )
+ return row[0] if row else None
+
+
async def delete_product(user_id: str, product_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.products WHERE user_id =? AND id = ?",
@@ -269,3 +308,100 @@ async def delete_product(user_id: str, product_id: str) -> None:
product_id,
),
)
+
+
+######################################## ORDERS ########################################
+
+
+async def create_order(user_id: str, o: Order) -> Order:
+ await db.execute(
+ f"""
+ INSERT INTO nostrmarket.orders (user_id, id, event_id, pubkey, address, contact_data, extra_data, order_items, stall_id, invoice_id, total)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ user_id,
+ o.id,
+ o.event_id,
+ o.pubkey,
+ o.address,
+ json.dumps(o.contact.dict() if o.contact else {}),
+ json.dumps(o.extra.dict()),
+ json.dumps([i.dict() for i in o.items]),
+ o.stall_id,
+ o.invoice_id,
+ o.total,
+ ),
+ )
+ order = await get_order(user_id, o.id)
+ assert order, "Newly created order couldn't be retrieved"
+
+ return order
+
+
+async def get_order(user_id: str, order_id: str) -> Optional[Order]:
+ row = await db.fetchone(
+ "SELECT * FROM nostrmarket.orders WHERE user_id =? AND id = ?",
+ (
+ user_id,
+ order_id,
+ ),
+ )
+ return Order.from_row(row) if row else None
+
+
+async def get_order_by_event_id(user_id: str, event_id: str) -> Optional[Order]:
+ row = await db.fetchone(
+ "SELECT * FROM nostrmarket.orders WHERE user_id =? AND event_id =?",
+ (
+ user_id,
+ event_id,
+ ),
+ )
+ return Order.from_row(row) if row else None
+
+
+async def get_orders(user_id: str) -> List[Order]:
+ rows = await db.fetchall(
+ "SELECT * FROM nostrmarket.orders WHERE user_id = ? ORDER BY time DESC",
+ (user_id,),
+ )
+ return [Order.from_row(row) for row in rows]
+
+
+async def get_orders_for_stall(user_id: str, stall_id: str) -> List[Order]:
+ rows = await db.fetchall(
+ "SELECT * FROM nostrmarket.orders WHERE user_id = ? AND stall_id = ? ORDER BY time DESC",
+ (
+ user_id,
+ stall_id,
+ ),
+ )
+ return [Order.from_row(row) for row in rows]
+
+
+async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
+ await db.execute(
+ f"UPDATE nostrmarket.orders SET paid = ? WHERE id = ?",
+ (paid, order_id),
+ )
+ row = await db.fetchone(
+ "SELECT * FROM nostrmarket.orders WHERE id = ?",
+ (order_id,),
+ )
+ return Order.from_row(row) if row else None
+
+
+async def update_order_shipped_status(
+ user_id: str, order_id: str, shipped: bool
+) -> Optional[Order]:
+ await db.execute(
+ f"UPDATE nostrmarket.orders SET shipped = ? WHERE user_id = ? AND id = ?",
+ (shipped, user_id, order_id),
+ )
+
+ row = await db.fetchone(
+ "SELECT * FROM nostrmarket.orders WHERE id = ?",
+ (order_id,),
+ )
+ return Order.from_row(row) if row else None
diff --git a/helpers.py b/helpers.py
index 8747c48..ee73cd9 100644
--- a/helpers.py
+++ b/helpers.py
@@ -1,7 +1,7 @@
import base64
import json
import secrets
-from typing import Optional
+from typing import Any, Optional, Tuple
import secp256k1
from cffi import FFI
@@ -31,7 +31,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str:
return unpadded_data.decode()
-def encrypt_message(message: str, encryption_key, iv: Optional[bytes]) -> str:
+def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str:
padder = padding.PKCS7(128).padder()
padded_data = padder.update(message.encode()) + padder.finalize()
@@ -73,9 +73,11 @@ def copy_x(output, x32, y32, data):
return 1
-def is_json(string: str):
+def order_from_json(s: str) -> Tuple[Optional[Any], Optional[str]]:
try:
- json.loads(string)
- except ValueError as e:
- return False
- return True
+ order = json.loads(s)
+ return (
+ (order, None) if (type(order) is dict) and "items" in order else (None, s)
+ )
+ except ValueError:
+ return None, s
diff --git a/migrations.py b/migrations.py
index 680c3cc..f006d36 100644
--- a/migrations.py
+++ b/migrations.py
@@ -71,39 +71,28 @@ async def m001_initial(db):
"""
Initial orders table.
"""
+ empty_object = "{}"
await db.execute(
f"""
CREATE TABLE nostrmarket.orders (
+ user_id TEXT NOT NULL,
id TEXT PRIMARY KEY,
- wallet TEXT NOT NULL,
- username TEXT,
- pubkey TEXT,
- shipping_zone TEXT NOT NULL,
+ event_id TEXT,
+ pubkey TEXT NOT NULL,
+ contact_data TEXT NOT NULL DEFAULT '{empty_object}',
+ extra_data TEXT NOT NULL DEFAULT '{empty_object}',
+ order_items TEXT NOT NULL,
address TEXT,
- email TEXT,
total REAL NOT NULL,
+ stall_id TEXT NOT NULL,
invoice_id TEXT NOT NULL,
- paid BOOLEAN NOT NULL,
- shipped BOOLEAN NOT NULL,
+ paid BOOLEAN NOT NULL DEFAULT false,
+ shipped BOOLEAN NOT NULL DEFAULT false,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
- """
- Initial order details table.
- """
- await db.execute(
- f"""
- CREATE TABLE nostrmarket.order_details (
- id TEXT PRIMARY KEY,
- order_id TEXT NOT NULL,
- product_id TEXT NOT NULL,
- quantity INTEGER NOT NULL
- );
- """
- )
-
"""
Initial market table.
"""
@@ -117,19 +106,6 @@ async def m001_initial(db):
"""
)
- """
- Initial market stalls table.
- """
- await db.execute(
- f"""
- CREATE TABLE nostrmarket.market_stalls (
- id TEXT PRIMARY KEY,
- market_id TEXT NOT NULL,
- stall_id TEXT NOT NULL
- );
- """
- )
-
"""
Initial chat messages table.
"""
diff --git a/models.py b/models.py
index 194cf04..f228ed5 100644
--- a/models.py
+++ b/models.py
@@ -6,7 +6,14 @@ from typing import List, Optional
from pydantic import BaseModel
-from .helpers import sign_message_hash
+from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
+
+from .helpers import (
+ decrypt_message,
+ encrypt_message,
+ get_shared_secret,
+ sign_message_hash,
+)
from .nostr.event import NostrEvent
######################################## NOSTR ########################################
@@ -39,6 +46,28 @@ class Merchant(PartialMerchant):
def sign_hash(self, hash: bytes) -> str:
return sign_message_hash(self.private_key, hash)
+ def decrypt_message(self, encrypted_message: str, public_key: str) -> str:
+ encryption_key = get_shared_secret(self.private_key, public_key)
+ return decrypt_message(encrypted_message, encryption_key)
+
+ def encrypt_message(self, clear_text_message: str, public_key: str) -> str:
+ encryption_key = get_shared_secret(self.private_key, public_key)
+ return encrypt_message(clear_text_message, encryption_key)
+
+ def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent:
+ content = self.encrypt_message(message, to_pubkey)
+ event = NostrEvent(
+ pubkey=self.public_key,
+ created_at=round(time.time()),
+ kind=4,
+ tags=[["p", to_pubkey]],
+ content=content,
+ )
+ event.id = event.event_id
+ event.sig = self.sign_hash(bytes.fromhex(event.id))
+
+ return event
+
@classmethod
def from_row(cls, row: Row) -> "Merchant":
merchant = cls(**dict(row))
@@ -213,3 +242,120 @@ class Product(PartialProduct, Nostrable):
product.config = ProductConfig(**json.loads(row["meta"]))
product.categories = json.loads(row["category_list"])
return product
+
+
+class ProductOverview(BaseModel):
+ id: str
+ name: str
+ price: float
+
+
+######################################## ORDERS ########################################
+
+
+class OrderItem(BaseModel):
+ product_id: str
+ quantity: int
+
+
+class OrderContact(BaseModel):
+ nostr: Optional[str]
+ phone: Optional[str]
+ email: Optional[str]
+
+
+class OrderExtra(BaseModel):
+ products: List[ProductOverview]
+ currency: str
+ btc_price: str
+
+ @classmethod
+ async def from_products(cls, products: List[Product]):
+ currency = products[0].config.currency
+ exchange_rate = (
+ (await btc_price(currency)) if currency and currency != "sat" else 1
+ )
+ return OrderExtra(products=products, currency=currency, btc_price=exchange_rate)
+
+
+class PartialOrder(BaseModel):
+ id: str
+ event_id: Optional[str]
+ pubkey: str
+ items: List[OrderItem]
+ contact: Optional[OrderContact]
+ address: Optional[str]
+
+ def validate_order(self):
+ assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
+
+ def validate_order_items(self, product_list: List[Product]):
+ assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
+ assert (
+ len(product_list) != 0
+ ), f"No products found for order. Order: '{self.id}'"
+
+ product_ids = [p.id for p in product_list]
+ for item in self.items:
+ if item.product_id not in product_ids:
+ raise ValueError(
+ f"Order ({self.id}) item product does not exist: {item.product_id}"
+ )
+
+ stall_id = product_list[0].stall_id
+ for p in product_list:
+ if p.stall_id != stall_id:
+ raise ValueError(
+ f"Order ({self.id}) has products from different stalls"
+ )
+
+ async def total_sats(self, products: List[Product]) -> float:
+ product_prices = {}
+ for p in products:
+ product_prices[p.id] = p
+
+ amount: float = 0 # todo
+ for item in self.items:
+ price = product_prices[item.product_id].price
+ currency = product_prices[item.product_id].config.currency or "sat"
+ if currency != "sat":
+ price = await fiat_amount_as_satoshis(price, currency)
+ amount += item.quantity * price
+
+ return amount
+
+
+class Order(PartialOrder):
+ stall_id: str
+ invoice_id: str
+ total: float
+ paid: bool = False
+ shipped: bool = False
+ time: Optional[int]
+ extra: OrderExtra
+
+ @classmethod
+ def from_row(cls, row: Row) -> "Order":
+ contact = OrderContact(**json.loads(row["contact_data"]))
+ extra = OrderExtra(**json.loads(row["extra_data"]))
+ items = [OrderItem(**z) for z in json.loads(row["order_items"])]
+ order = cls(**dict(row), contact=contact, items=items, extra=extra)
+ return order
+
+
+class OrderStatusUpdate(BaseModel):
+ id: str
+ message: Optional[str]
+ paid: Optional[bool]
+ shipped: Optional[bool]
+
+
+class PaymentOption(BaseModel):
+ type: str
+ link: str
+
+
+class PaymentRequest(BaseModel):
+ id: str
+ message: Optional[str]
+ payment_options: List[PaymentOption]
diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py
index 306c123..3e8a47e 100644
--- a/nostr/nostr_client.py
+++ b/nostr/nostr_client.py
@@ -1,5 +1,9 @@
+from threading import Thread
+from typing import Callable
+
import httpx
from loguru import logger
+from websocket import WebSocketApp
from lnbits.app import settings
from lnbits.helpers import url_for
@@ -10,7 +14,7 @@ from .event import NostrEvent
async def publish_nostr_event(e: NostrEvent):
url = url_for("/nostrclient/api/v1/publish", external=True)
data = dict(e)
- # print("### published", dict(data))
+ print("### published", dict(data))
async with httpx.AsyncClient() as client:
try:
await client.post(
@@ -19,3 +23,44 @@ async def publish_nostr_event(e: NostrEvent):
)
except Exception as ex:
logger.warning(ex)
+
+
+async def connect_to_nostrclient_ws(
+ on_open: Callable, on_message: Callable
+) -> WebSocketApp:
+ def on_error(_, error):
+ logger.warning(error)
+
+ logger.debug(f"Subscribing to websockets for nostrclient extension")
+ ws = WebSocketApp(
+ f"ws://localhost:{settings.port}/nostrclient/api/v1/filters",
+ on_message=on_message,
+ on_open=on_open,
+ on_error=on_error,
+ )
+
+ wst = Thread(target=ws.run_forever)
+ wst.daemon = True
+ wst.start()
+
+ return ws
+
+
+# async def handle_event(event, pubkeys):
+# tags = [t[1] for t in event["tags"] if t[0] == "p"]
+# to_merchant = None
+# if tags and len(tags) > 0:
+# to_merchant = tags[0]
+
+# if event["pubkey"] in pubkeys or to_merchant in pubkeys:
+# logger.debug(f"Event sent to {to_merchant}")
+# pubkey = to_merchant if to_merchant in pubkeys else event["pubkey"]
+# # Send event to market extension
+# await send_event_to_market(event=event, pubkey=pubkey)
+
+
+# async def send_event_to_market(event: dict, pubkey: str):
+# # Sends event to market extension, for decrypt and handling
+# market_url = url_for(f"/market/api/v1/nip04/{pubkey}", external=True)
+# async with httpx.AsyncClient() as client:
+# await client.post(url=market_url, json=event)
diff --git a/static/components/order-list/order-list.html b/static/components/order-list/order-list.html
new file mode 100644
index 0000000..e629b1f
--- /dev/null
+++ b/static/components/order-list/order-list.html
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+ {{toShortId(props.row.id)}}
+ {{props.row.total}}
+
+
+
+
+
+
+
+
+
+
+ {{toShortId(props.row.pubkey)}}
+
+ {{formatDate(props.row.time)}}
+
+
+
+
+
+
+
+
+
{{item.quantity}}
+
x
+
+ {{productOverview(props.row, item.product_id)}}
+
+
+
+
+
+
+
+
+
+
+
Customer Public Key:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
diff --git a/static/components/order-list/order-list.js b/static/components/order-list/order-list.js
new file mode 100644
index 0000000..482cc8a
--- /dev/null
+++ b/static/components/order-list/order-list.js
@@ -0,0 +1,137 @@
+async function orderList(path) {
+ const template = await loadTemplateAsync(path)
+ Vue.component('order-list', {
+ name: 'order-list',
+ props: ['stall-id', 'adminkey', 'inkey'],
+ template,
+
+ data: function () {
+ return {
+ orders: [],
+ selectedOrder: null,
+ shippingMessage: '',
+ showShipDialog: false,
+ filter: '',
+ ordersTable: {
+ columns: [
+ {
+ name: '',
+ align: 'left',
+ label: '',
+ field: ''
+ },
+ {
+ name: 'id',
+ align: 'left',
+ label: 'ID',
+ field: 'id'
+ },
+ {
+ name: 'total',
+ align: 'left',
+ label: 'Total',
+ field: 'total'
+ },
+ {
+ name: 'paid',
+ align: 'left',
+ label: 'Paid',
+ field: 'paid'
+ },
+ {
+ name: 'shipped',
+ align: 'left',
+ label: 'Shipped',
+ field: 'shipped'
+ },
+ {
+ name: 'pubkey',
+ align: 'left',
+ label: 'Customer',
+ field: 'pubkey'
+ },
+ {
+ name: 'time',
+ align: 'left',
+ label: 'Date',
+ field: 'time'
+ }
+ ],
+ pagination: {
+ rowsPerPage: 10
+ }
+ }
+ }
+ },
+ methods: {
+ toShortId: function (value) {
+ return value.substring(0, 5) + '...' + value.substring(value.length - 5)
+ },
+ formatDate: function (value) {
+ return Quasar.utils.date.formatDate(
+ new Date(value * 1000),
+ 'YYYY-MM-DD HH:mm'
+ )
+ },
+ productOverview: function (order, productId) {
+ product = order.extra.products.find(p => p.id === productId)
+ if (product) {
+ return `${product.name} (${product.price} ${order.extra.currency})`
+ }
+ return ''
+ },
+ getOrders: async function () {
+ try {
+ const ordersPath = this.stallId
+ ? `/stall/order/${this.stallId}`
+ : '/order'
+ const {data} = await LNbits.api.request(
+ 'GET',
+ '/nostrmarket/api/v1' + ordersPath,
+ this.inkey
+ )
+ this.orders = data.map(s => ({...s, expanded: false}))
+ console.log('### this.orders', this.orders)
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ }
+ },
+ updateOrderShipped: async function () {
+ console.log('### order', this.selectedOrder)
+ this.selectedOrder.shipped = !this.selectedOrder.shipped
+ try {
+ await LNbits.api.request(
+ 'PATCH',
+ `/nostrmarket/api/v1/order/${this.selectedOrder.id}`,
+ this.adminkey,
+ {
+ id: this.selectedOrder.id,
+ message: this.shippingMessage,
+ shipped: this.selectedOrder.shipped
+ }
+ )
+ this.$q.notify({
+ type: 'positive',
+ message: 'Order updated!'
+ })
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ }
+ this.showShipDialog = false
+ },
+ showShipOrderDialog: function (order) {
+ this.selectedOrder = order
+ this.shippingMessage = order.shipped
+ ? `The order has been shipped! Order ID: '${order.id}' `
+ : `The order has NOT yet been shipped! Order ID: '${order.id}'`
+
+ // do not change the status yet
+ this.selectedOrder.shipped = !order.shipped
+ this.showShipDialog = true
+ }
+ },
+ created: async function () {
+ await this.getOrders()
+ }
+ })
+}
diff --git a/static/components/stall-details/stall-details.html b/static/components/stall-details/stall-details.html
index b26caf3..4239f68 100644
--- a/static/components/stall-details/stall-details.html
+++ b/static/components/stall-details/stall-details.html
@@ -185,7 +185,13 @@
-
+
+
+
diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html
index 86ae34e..bc6236e 100644
--- a/static/components/stall-list/stall-list.html
+++ b/static/components/stall-list/stall-list.html
@@ -50,7 +50,7 @@
{{props.row.name}}
-
+ {{props.row.currency}}
{{props.row.config.description}}
diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js
index 5fd8ffd..d41c062 100644
--- a/static/components/stall-list/stall-list.js
+++ b/static/components/stall-list/stall-list.js
@@ -35,6 +35,12 @@ async function stallList(path) {
label: 'Name',
field: 'id'
},
+ {
+ name: 'currency',
+ align: 'left',
+ label: 'Currency',
+ field: 'currency'
+ },
{
name: 'description',
align: 'left',
diff --git a/static/js/index.js b/static/js/index.js
index 5b26d4c..3eb50ad 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -5,6 +5,7 @@ const merchant = async () => {
await shippingZones('static/components/shipping-zones/shipping-zones.html')
await stallDetails('static/components/stall-details/stall-details.html')
await stallList('static/components/stall-list/stall-list.html')
+ await orderList('static/components/order-list/order-list.html')
const nostr = window.NostrTools
diff --git a/tasks.py b/tasks.py
index 3254dcc..c97c2b2 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1,18 +1,31 @@
import asyncio
import json
-import threading
+from asyncio import Queue
import httpx
import websocket
from loguru import logger
+from websocket import WebSocketApp
+from lnbits.core import get_wallet
from lnbits.core.models import Payment
-from lnbits.helpers import url_for
+from lnbits.helpers import Optional, url_for
from lnbits.tasks import register_invoice_listener
+from .crud import (
+ get_merchant_by_pubkey,
+ get_public_keys_for_merchants,
+ get_wallet_for_product,
+ update_order_paid_status,
+)
+from .helpers import order_from_json
+from .models import OrderStatusUpdate, PartialOrder
+from .nostr.event import NostrEvent
+from .nostr.nostr_client import connect_to_nostrclient_ws, publish_nostr_event
+
async def wait_for_paid_invoices():
- invoice_queue = asyncio.Queue()
+ invoice_queue = Queue()
register_invoice_listener(invoice_queue)
while True:
@@ -24,8 +37,141 @@ async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "nostrmarket":
return
- print("### on_invoice_paid")
+ order_id = payment.extra.get("order_id")
+ merchant_pubkey = payment.extra.get("merchant_pubkey")
+ if not order_id or not merchant_pubkey:
+ return None
+
+ await handle_order_paid(order_id, merchant_pubkey)
-async def subscribe_nostrclient_ws():
+async def handle_order_paid(order_id: str, merchant_pubkey: str):
+ try:
+ order = await update_order_paid_status(order_id, True)
+ assert order, f"Paid order cannot be found. Order id: {order_id}"
+ order_status = OrderStatusUpdate(
+ id=order_id, message="Payment received.", paid=True, shipped=order.shipped
+ )
+
+ merchant = await get_merchant_by_pubkey(merchant_pubkey)
+ assert merchant, f"Merchant cannot be found for order {order_id}"
+ dm_content = json.dumps(
+ order_status.dict(), separators=(",", ":"), ensure_ascii=False
+ )
+
+ dm_event = merchant.build_dm_event(dm_content, order.pubkey)
+ await publish_nostr_event(dm_event)
+ except Exception as ex:
+ logger.warning(ex)
+
+
+async def subscribe_to_nostr_client(recieve_event_queue: Queue, send_req_queue: Queue):
print("### subscribe_nostrclient_ws")
+
+ def on_open(_):
+ logger.info("Connected to 'nostrclient' websocket")
+
+ def on_message(_, message):
+ print("### on_message", message)
+ recieve_event_queue.put_nowait(message)
+
+ # wait for 'nostrclient' extension to initialize
+ await asyncio.sleep(5)
+ ws: WebSocketApp = None
+ while True:
+ try:
+ req = None
+ if not ws:
+ ws = await connect_to_nostrclient_ws(on_open, on_message)
+ # be sure the connection is open
+ await asyncio.sleep(3)
+ req = await send_req_queue.get()
+ ws.send(json.dumps(req))
+ except Exception as ex:
+ logger.warning(ex)
+ if req:
+ await send_req_queue.put(req)
+ ws = None # todo close
+ await asyncio.sleep(5)
+
+
+async def wait_for_nostr_events(recieve_event_queue: Queue, send_req_queue: Queue):
+ public_keys = await get_public_keys_for_merchants()
+ for p in public_keys:
+ await send_req_queue.put(
+ ["REQ", f"direct-messages:{p}", {"kind": 4, "#p": [p]}]
+ )
+
+ while True:
+ message = await recieve_event_queue.get()
+ await handle_message(message)
+
+
+async def handle_message(msg: str):
+ try:
+ type, subscription_id, event = json.loads(msg)
+ _, public_key = subscription_id.split(":")
+ if type.upper() == "EVENT":
+ event = NostrEvent(**event)
+ if event.kind == 4:
+ await handle_nip04_message(public_key, event)
+
+ except Exception as ex:
+ logger.warning(ex)
+
+
+async def handle_nip04_message(public_key: str, event: NostrEvent):
+ merchant = await get_merchant_by_pubkey(public_key)
+ assert merchant, f"Merchant not found for public key '{public_key}'"
+
+ clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
+ dm_content = await handle_dirrect_message(event.pubkey, event.id, clear_text_msg)
+ if dm_content:
+ dm_event = merchant.build_dm_event(dm_content, event.pubkey)
+ await publish_nostr_event(dm_event)
+
+
+async def handle_dirrect_message(
+ from_pubkey: str, event_id: str, msg: str
+) -> Optional[str]:
+ order, text_msg = order_from_json(msg)
+ try:
+ if order:
+ order["pubkey"] = from_pubkey
+ order["event_id"] = event_id
+ return await handle_new_order(PartialOrder(**order))
+ else:
+ print("### text_msg", text_msg)
+ return None
+ except Exception as ex:
+ logger.warning(ex)
+ return None
+
+
+async def handle_new_order(order: PartialOrder):
+ ### todo: check that event_id not parsed already
+
+ order.validate_order()
+
+ first_product_id = order.items[0].product_id
+ wallet_id = await get_wallet_for_product(first_product_id)
+ assert wallet_id, f"Cannot find wallet id for product id: {first_product_id}"
+
+ wallet = await get_wallet(wallet_id)
+ assert wallet, f"Cannot find wallet for product id: {first_product_id}"
+
+ market_url = url_for(f"/nostrmarket/api/v1/order", external=True)
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ url=market_url,
+ headers={
+ "X-Api-Key": wallet.adminkey,
+ },
+ json=order.dict(),
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ if data:
+ return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
+
+ return None
diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html
index fa07f17..b866226 100644
--- a/templates/nostrmarket/index.html
+++ b/templates/nostrmarket/index.html
@@ -150,6 +150,7 @@
+
{% endblock %}
diff --git a/views_api.py b/views_api.py
index 4680977..6d561d8 100644
--- a/views_api.py
+++ b/views_api.py
@@ -6,18 +6,20 @@ from fastapi import Depends
from fastapi.exceptions import HTTPException
from loguru import logger
+from lnbits.core import create_invoice
from lnbits.decorators import (
WalletTypeInfo,
+ check_admin,
get_key_type,
require_admin_key,
require_invoice_key,
)
-from lnbits.extensions.nostrmarket.nostr.event import NostrEvent
from lnbits.utils.exchange_rates import currencies
-from . import nostrmarket_ext
+from . import nostrmarket_ext, scheduled_tasks
from .crud import (
create_merchant,
+ create_order,
create_product,
create_stall,
create_zone,
@@ -25,12 +27,19 @@ from .crud import (
delete_stall,
delete_zone,
get_merchant_for_user,
+ get_order,
+ get_order_by_event_id,
+ get_orders,
+ get_orders_for_stall,
get_product,
get_products,
+ get_products_by_ids,
get_stall,
get_stalls,
+ get_wallet_for_product,
get_zone,
get_zones,
+ update_order_shipped_status,
update_product,
update_stall,
update_zone,
@@ -38,14 +47,21 @@ from .crud import (
from .models import (
Merchant,
Nostrable,
+ Order,
+ OrderExtra,
+ OrderStatusUpdate,
PartialMerchant,
+ PartialOrder,
PartialProduct,
PartialStall,
PartialZone,
+ PaymentOption,
+ PaymentRequest,
Product,
Stall,
Zone,
)
+from .nostr.event import NostrEvent
from .nostr.nostr_client import publish_nostr_event
######################################## MERCHANT ########################################
@@ -80,7 +96,7 @@ async def api_get_merchant(
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- detail="Cannot create merchant",
+ detail="Cannot get merchant",
)
@@ -95,13 +111,13 @@ async def api_get_zones(wallet: WalletTypeInfo = Depends(get_key_type)) -> List[
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- detail="Cannot create merchant",
+ detail="Cannot get zone",
)
@nostrmarket_ext.post("/api/v1/zone")
async def api_create_zone(
- data: PartialZone, wallet: WalletTypeInfo = Depends(get_key_type)
+ data: PartialZone, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
zone = await create_zone(wallet.wallet.user, data)
@@ -110,7 +126,7 @@ async def api_create_zone(
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- detail="Cannot create merchant",
+ detail="Cannot create zone",
)
@@ -136,7 +152,7 @@ async def api_update_zone(
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- detail="Cannot create merchant",
+ detail="Cannot update zone",
)
@@ -157,7 +173,7 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- detail="Cannot create merchant",
+ detail="Cannot delete zone",
)
@@ -274,6 +290,22 @@ async def api_get_stall_products(
)
+@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
+async def api_get_stall_orders(
+ stall_id: str,
+ wallet: WalletTypeInfo = Depends(require_invoice_key),
+):
+ try:
+ orders = await get_orders_for_stall(wallet.wallet.user, stall_id)
+ return orders
+ except Exception as ex:
+ logger.warning(ex)
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail="Cannot get stall products",
+ )
+
+
@nostrmarket_ext.delete("/api/v1/stall/{stall_id}")
async def api_delete_stall(
stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
@@ -418,6 +450,132 @@ async def api_delete_product(
)
+######################################## ORDERS ########################################
+
+
+@nostrmarket_ext.post("/api/v1/order")
+async def api_create_order(
+ data: PartialOrder, wallet: WalletTypeInfo = Depends(require_admin_key)
+) -> Optional[PaymentRequest]:
+ try:
+ # print("### new order: ", json.dumps(data.dict()))
+ if await get_order(wallet.wallet.user, data.id):
+ return None
+ if data.event_id and await get_order_by_event_id(
+ wallet.wallet.user, data.event_id
+ ):
+ return None
+
+ merchant = await get_merchant_for_user(wallet.wallet.user)
+ assert merchant, "Cannot find merchant!"
+
+ products = await get_products_by_ids(
+ wallet.wallet.user, [p.product_id for p in data.items]
+ )
+ data.validate_order_items(products)
+
+ total_amount = await data.total_sats(products)
+
+ wallet_id = await get_wallet_for_product(data.items[0].product_id)
+ assert wallet_id, "Missing wallet for order `{data.id}`"
+
+ payment_hash, invoice = await create_invoice(
+ wallet_id=wallet_id,
+ amount=round(total_amount),
+ memo=f"Order '{data.id}' for pubkey '{data.pubkey}'",
+ extra={
+ "tag": "nostrmarket",
+ "order_id": data.id,
+ "merchant_pubkey": merchant.public_key,
+ },
+ )
+
+ order = Order(
+ **data.dict(),
+ stall_id=products[0].stall_id,
+ invoice_id=payment_hash,
+ total=total_amount,
+ extra=await OrderExtra.from_products(products),
+ )
+ await create_order(wallet.wallet.user, order)
+
+ return PaymentRequest(
+ id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)]
+ )
+ except Exception as ex:
+ logger.warning(ex)
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail="Cannot create order",
+ )
+
+
+nostrmarket_ext.get("/api/v1/order/{order_id}")
+
+
+async def api_get_order(order_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
+ try:
+ order = await get_order(wallet.wallet.user, order_id)
+ if not order:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Order does not exist.",
+ )
+ return order
+ except HTTPException as ex:
+ raise ex
+ except Exception as ex:
+ logger.warning(ex)
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail="Cannot get order",
+ )
+
+
+@nostrmarket_ext.get("/api/v1/order")
+async def api_get_orders(wallet: WalletTypeInfo = Depends(get_key_type)):
+ try:
+ orders = await get_orders(wallet.wallet.user)
+ return orders
+ except Exception as ex:
+ logger.warning(ex)
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail="Cannot get orders",
+ )
+
+
+@nostrmarket_ext.patch("/api/v1/order/{order_id}")
+async def api_update_order_status(
+ data: OrderStatusUpdate,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> Order:
+ try:
+ assert data.shipped != None, "Shipped value is required for order"
+ order = await update_order_shipped_status(
+ wallet.wallet.user, data.id, data.shipped
+ )
+ assert order, "Cannot find updated order"
+
+ merchant = await get_merchant_for_user(wallet.wallet.user)
+ assert merchant, f"Merchant cannot be found for order {data.id}"
+
+ data.paid = order.paid
+ dm_content = json.dumps(data.dict(), separators=(",", ":"), ensure_ascii=False)
+
+ dm_event = merchant.build_dm_event(dm_content, order.pubkey)
+ await publish_nostr_event(dm_event)
+
+ return order
+
+ except Exception as ex:
+ logger.warning(ex)
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail="Cannot update order",
+ )
+
+
######################################## OTHER ########################################
@@ -426,6 +584,16 @@ async def api_list_currencies_available():
return list(currencies.keys())
+@nostrmarket_ext.delete("/api/v1", status_code=HTTPStatus.OK)
+async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
+ for t in scheduled_tasks:
+ try:
+ t.cancel()
+ except Exception as ex:
+ logger.warning(ex)
+
+ return {"success": True}
+
######################################## HELPERS ########################################